自己报的一条,记一下
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估计很快就来了
至此终了