# vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector

vm2's Promise.prototype.then/catch wrappers are bypassed on Node.js 26 because V8's stale PromiseThenLookupChain protector lets finally() invoke native then, leaking host Function and process.

- Payout: Research
- Severity: critical
- Program: vm2
- Found by: YMs0ra
- Disclosed: 2026-10-01
- CVE: CVE-2026-92944
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-27g9-p43v-cw3v
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-27g9-p43v-cw3v
- Tags: area:oss domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:incomplete-fix technique:source-review vuln:rce vuln:sandbox-escape

**Target:** vm2 sandbox (lib/setup-sandbox.js Promise wrappers) on Node.js 26 / V8 14.6
**Root cause:** V8 14.6's proto_assign_seq_opt combines vm2's consecutive Promise.prototype.then/catch assignments into SetPrototypeProperties, whose existing-data-property branch sets the value without invalidating the PromiseThenLookupChain protector. finally()'s internal InvokeThen then trusts the stale protector and calls native then, skipping vm2's wrapper and resetPromiseSpecies.
**Steps:**
1. Install vm2@3.11.5 on Node.js 26.7.0 (V8 14.6.202.34) with default `new VM()`.
2. Inside the sandbox, create an ordinary fulfilled async-function Promise and set an attacker-controlled `constructor[Symbol.species]` on it.
3. Call `p.finally()`: V8's InvokeThen fast path selects native `then` (protector stale), so vm2's wrapper and `resetPromiseSpecies(this)` never run.
4. The attacker species supplies capability resolve/reject; trigger a calibrated stack overflow at the native reaction boundary so V8 passes a raw host-realm `RangeError` to the attacker reject.
5. Reach host via `error.constructor.constructor` -> host `Function` -> `process` (PoC reads `process.version`).
6. Confirm control: `node --no-proto-assign-seq-opt poc.js` flips HOST to SAFE and wrapperCalls 0 to 1.
**How found:** Reporter noticed vm2 hardens Promise.prototype.then/catch with wrappers plus resetPromiseSpecies, then tested whether engine fast paths could skip them on new V8. They observed wrapperCalls=0 on Node 26, hypothesized a stale PromiseThenLookupChain protector, and confirmed via `--no-proto-assign-seq-opt` flipping the result to SAFE, tying it to V8's SetPrototypeProperties data-property branch.
**Exploitation:** Attacker with code execution inside a vm2 VM uses an ordinary async Promise with a malicious species constructor; finally() bypasses the wrapper, the species capability receives a host-realm RangeError at a calibrated stack depth, and error.constructor.constructor yields host Function and process, enabling arbitrary host code execution. PoC is non-destructive, reading only process.version.
**Escalation:** From a host-realm RangeError, walk `constructor.constructor` to host Function and then `process`, converting a sandbox escape into full host RCE with filesystem, env, network and child-process access.
**Insight:** When a sandbox monkey-patches intrinsics, check whether the engine has fast paths (protectors, InvokeThen, species lookups) that bypass observable property lookup; test on newest V8 and use engine flags like --no-proto-assign-seq-opt to isolate protector-invalidation bugs.

