SORA.SEC WIRED ARCHIVE 网络安全
1172 字
6 分钟
Getshell! VM2@3.11.6沙箱逃逸

自己报的一条,记一下

VM2@3.11.6沙箱逃逸#

前段时间提的新issue,写篇分析

又翻了翻vm2。

之前那篇VM2@3.11.2是异步迭代器关掉generator的时候,V8自己去调thenable。

这次入口换了。

但是之前的包装还在,不过Node 26的V8仍是不走包装的

为此又看了一遍lib/setup-sandbox.js

入口还是Promise.prototype.then和catch

请看

const globalPromiseThen = globalPromise.prototype.then;
const globalPromiseCatch = globalPromise.prototype.catch;
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
resetPromiseSpecies(this);
if (typeof onFulfilled === 'function') {
const origOnFulfilled = onFulfilled;
onFulfilled = function onFulfilled(value) {
value = ensureThis(value);
return apply(origOnFulfilled, this, [value]);
};
}
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = handleException(error);
return apply(origOnRejected, this, [error]);
};
}
return apply(globalPromiseThen, this, [onFulfilled, onRejected]);
};

可以看到第一句就是resetPromiseSpecies(this)

也就是把constructor[Symbol.species]清掉,不让它活到原生Promise reaction里去。

catch同理,也是先reset再apply回缓存的原生实现。

then里还会把onFulfilled / onRejected再包一层,fulfilled走ensureThis,rejected走handleException。这些是老补丁留下来的,回调要洗,异常也要洗,不然沙箱里的Promise一resolve就能把宿主值带进来。

表面上看then和catch都钩住了,

当然,promise这边一直是vm2的老战场。

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

问题在这两段是连续直接赋值。

then写完紧接着写catch,中间没有别的。

globalPromise.prototype.then = function then(...) { ... };
globalPromise.prototype.catch = function _catch(...) { ... };

Node 26的V8已经到14.6了,14.6有个proto_assign_seq_opt

连续给原型写数据属性会收成SetPrototypeProperties

本来按V8的规矩,改Promise.prototype.then这种点,是要把PromiseThenLookupChain保护器弄失效的。

保护器还在,就代表发动机还相信then没被换过。后面InvokeThen可以直接走原生快路径,不用再做一次JS属性查找。

这条优化路径里Object::SetDataProperty把值写进去了,没有it.UpdateProtector()

可以看到,JS层面Promise.prototype.then已经是vm2的包装了,

V8的PromiseThenLookupChain还以为没人动过。

这里就岔开了。

你要是自己在沙箱里调.then(),其实还是会进vm2的包装,reset也会跑。

问题是Promise.prototype.finally。

finally内部会做SpeciesConstructor,再InvokeThen。保护器还有效,InvokeThen就直接调原生then,根本不去读现在的Promise.prototype.then。

vm2那层进不去,resetPromiseSpecies自然也没跑。

species就还在。

原生reaction的resolve/reject是species给的,后面宿主realm的异常一旦落到可控的reject上,constructor链就能摸到宿主Function,再摸到process。

这点和之前那篇其实能对上,还是落到宿主异常对象上,只是入口从异步迭代器换成了finally这条快路径。

普通async函数返回的那颗Promise就够用,不一定要自己new一个。(async () => 1)()这种,本来就是vm2装包装时那颗globalPromise。

eval: false,wasm: false都没用,默认new VM()就中。allowAsync默认是开的,这条用了async,所以必须能跑async,而这也是默认。

不需要wasm,不需要动态eval,不需要暴露宿主对象,也不需要嵌入者自己塞一颗Promise进来。

这倒不是新的V8洞,上游数据属性那条已经修了,V8/v8@7b26e81,懒访问器还有一刀4635ddd。Node 26释放线当时没带回这些,vm2这边只能自己兜了。

关掉--no-proto-assign-seq-opt就能从HOST变SAFE,包装也会被真正调到。也就是说根因很死,就是这条赋值优化。保护器一旦被正确弄失效,finally就老老实实去找包装过的then。

版本在我的测试中

vm2 3.10.2到3.11.5,当时的main 7a1f5100,Node 26.7.0都是HOST。

3.11.6官方npm又走了一遍,gitHead a5b31cd,默认配置10/10 HOST,关eval和wasm同样10/10。

Node 22.23.2 / V8 12.4、24.19.0 / V8 13.6、25.9.0 / V8 14.1没事,26.0.0 / V8 14.6.202.33开始中,一直到26.7.0。当时v27 nightly还是V8 14.6,也中,v8-canary到15.3就SAFE了。

并且在Linux musl、glibc、Windows x64都能复现,并非发行版特有的。

跟22709不是一条,那条是异步函数返回的那颗内建Promise,回调没洗干净,修的是then/catch包装本身。这次包装在,快路径不走,包装等于没装。

47208是localPromise的reject吞噬尾巴里少调resetPromiseSpecies,3.11.4标过修。这次入口是内建Promise加finally的InvokeThen,3.11.4和3.11.5照样中。

47210要JSPI和wasm。这次普通async就没问题了。

3.11.7改成defineProperty装then/catch,finally自己也包一层,进去先resetPromiseSpecies再交给缓存的原生finally。

const globalPromiseFinally = globalPromise.prototype.finally;
const wrappedPromiseFinally = typeof globalPromiseFinally === 'function'
? function _finally(onFinally) {
resetPromiseSpecies(this);
return apply(globalPromiseFinally, this, [onFinally]);
}
: undefined;

defineProperty这条路会让保护器失效,finally自己包一层是因为finally内部本来就会读species。就算以后再有快路径,进来第一件事也是reset。

then/catch/finally都走localReflectDefineProperty,不再连续直接赋值。

CVE估计很快就来了

至此终了

Getshell! VM2@3.11.6沙箱逃逸
https://ymsora.com/posts/vm2-node26沙箱逃逸/
作者
萦梦sora~X
发布于
2026-09-09
许可协议
Unlicensed
LAST UPDATE / 最后更新