# vm2 sandbox escape via WebAssembly.compileStreaming Promise species bypass

vm2 on Node 26 leaks a raw host-realm Promise rejection from WebAssembly.compileStreaming; species-controlled finally delivers a host TypeError, enabling constructor walk to host process.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: thesmartshadow, zolbooo
- Disclosed: 2026-10-05
- CVE: CVE-2026-92956
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-wjwh-qqvp-g4p4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-wjwh-qqvp-g4p4
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:critical tech:evm tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:prototype-pollution vuln:sandbox-escape

**Target:** vm2 default VM sandbox on Node.js 26
**Root cause:** WebAssembly.compileStreaming/instantiateStreaming return a raw host-realm Promise path not intercepted by vm2's Promise hardening or bridge, and invalid streaming sources reject with a host-realm TypeError. Attacker-controlled Symbol.species via Promise.prototype.finally receives that host error, whose constructor chain reaches the host Function constructor.
**Steps:**
1. Run default `new VM({ timeout: 8000 })` on Node 26.2.0 with vm2 3.11.5.
2. Sandbox calls `WebAssembly.compileStreaming(0)`; invalid source causes rejection with host-realm error.
3. Install controlled `constructor` accessor on returned Promise and custom `Symbol.species` constructor.
4. Call `p.finally(() => {})` to trigger NewPromiseCapability(F) with attacker constructor.
5. On rejection, host-realm error reaches attacker rejection path.
6. Evaluate `hostError.constructor.constructor('return process')()` to recover host process.
7. Load host fs via `process.mainModule.require('fs')` and write marker file.
**How found:** Recognized the earlier JSPI fix removed WebAssembly.promising/Suspending because they exposed a non-bridge-proxied host Promise path; hypothesized other WebAssembly APIs returning Promises could expose the same shape, then tested streaming APIs and found Node 26 delivers a host-realm rejection exploitable via the remaining species-sensitive Promise.prototype.finally.
**Exploitation:** Attacker supplies JS to vm.run() in a default VM; no require/process/host objects needed. Species-controlled finally captures the host-realm rejection from compileStreaming, then constructor walk yields host process and fs, giving host code execution in the Node process.
**Escalation:** Patch-bypass of GHSA-6j2x-vhqr-qr7q JSPI fix: same unsafe raw host-Promise shape reached via a different WebAssembly API.
**Insight:** When a sandbox fix removes specific intrinsics for leaking host-realm Promises, enumerate every other intrinsic returning a Promise (streaming WebAssembly, dynamic import, etc.) and check whether its rejection value is host-realm; Promise.prototype.finally is a species-sensitive combinator often left unhardened.

