# CVE-2026-93606 — Root Cause Analysis

## Summary
vm2 (npm) versions ≤ 3.12.0 contain a sandbox escape in both `VM` and `NodeVM`. When an embedder exposes a host function that returns a genuine host-realm `Promise` (the canonical vm2 embedding pattern), sandboxed code can hijack the promise's `constructor[Symbol.species]` channel — which V8 reads directly off the raw host object, bypassing every bridge trap — and call `.then()` with **no** `onRejected` handler. V8 substitutes its internal `Thrower` reaction, which delivers the **raw, unsanitized host rejection value** into the attacker-captured reaction-capability `reject` closure. The value arrives as a fully functional bridge proxy of a host object (`isProxy === true`), from which `mainModule.require('child_process').execSync(...)` yields arbitrary host code execution with the embedding Node.js process's privileges.

## Impact
- **Package:** `vm2` (npm), `lib/bridge.js` + `lib/setup-sandbox.js`
- **Affected versions:** ≤ 3.12.0 (all prior lines; the vulnerability is in the bridge's promise-rejection sanitizer introduced with the m283 defenses)
- **Patched version:** 3.12.1 (also current latest 3.12.2)
- **Risk:** Critical (CVSS v4 10.0, GHSA-6454-5x88-m6jw). Any embedder that hands the sandbox a Promise-returning host API (caching layers, RPC stubs, fetch-like wrappers) exposes full host RCE: filesystem, `child_process`, env vars, outbound network.

## Impact Parity
- **Disclosed/claimed maximum impact:** sandbox escape → host arbitrary code execution.
- **Reproduced impact from this run:** full sandbox escape with host command execution. From inside both `VM` and `NodeVM`, the sandboxed script (a) received the raw host `process` object as a live bridge proxy (`isProxy: true`), (b) read a host-only environment variable (`HOST_ONLY_SECRET=CANARY123`) invisible to the sandbox's own `process` stub, and (c) executed host shell commands via `hostValue.mainModule.require('child_process').execSync`, writing unique per-attempt marker files on the host filesystem.
- **Parity:** `full`.

## Root Cause
Two defense gaps compose:

1. **Species neutralization is sandbox-realm-only.** `lib/setup-sandbox.js` overrides `then/catch/finally` on the sandbox intrinsic `Promise.prototype` to call `resetPromiseSpecies(this)` (GHSA-27g9-p43v-cw3v). A **host** Promise crossing the bridge keeps the host `Promise.prototype` methods, so this neutralization never runs for it. Meanwhile `BaseHandler.set` deliberately allows ordinary sandbox writes onto a non-frozen host object, so `p.constructor = { [Symbol.species]: Evil }` lands on the raw host promise.

2. **The rejection sanitizer only wraps function-valued slots.** The bridge's apply-trap interception of host `Promise.prototype.then/catch` (`normalizeHostPromiseCallbacks` / `makeSanitizedPromiseCallback` in `lib/bridge.js`) wraps `onFulfilled`/`onRejected` **only when the slot holds a function**. Per `PerformPromiseThen`, a missing/non-callable `onRejected` makes V8 substitute its internal `Thrower`, which performs `throw reason` into `resultCapability.[[Reject]]` with the **raw host value**. Because `resultCapability` was built via `SpeciesConstructor(p, %Promise%)` → `new Evil(GetCapabilitiesExecutor)` — executed back in the sandbox through the proxy's `[[Construct]]` trap — `[[Reject]]` is an attacker sandbox closure. No `handleException`, `ensureThis`, or `hostPromiseSanitizeReject` chokepoint exists on this path.

**Fix (vm2 3.12.1, GHSA-6454-5x88-m6jw):** `peelEffectivePromiseCall` now returns the effective receiver of host `then/catch/finally` (also unwinding `Reflect.apply`), and `neutralizeHostPromiseSpeciesOn` installs `constructor = undefined` as an own data property on the raw host promise for the duration of the call, forcing `SpeciesConstructor` to fall back to the realm-correct host `%Promise%`. The reaction capability is then a genuine host promise; the raw settlement can only be observed by attaching a fresh `.then/.catch`, which re-enters the sanitizer. Verified: on 3.12.1 the sandbox script's hijack closure is never invoked (`sandbox returned: UNSET`) and no host marker file is created.

## Reproduction Steps
1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice consecutively — both runs exit 0).
2. The script downloads the immutable npm tarballs `vm2@3.12.0` (vulnerable) and `vm2@3.12.1` (fixed), installs them with pinned integrity into the prepared project cache (`/pruva/project-cache/vm2-pkgs`, fallback `bundle/artifacts/vm2-pkgs`), generates `bundle/repro/harness.js`, and runs **two clean attempts per build per sandbox class** (`VM` ×2, `NodeVM` ×2 on each version) via `node harness.js <vm2-dir> <VM|NodeVM> <id>`, each invocation bounded by `timeout 60`.
3. Expected evidence: every vulnerable attempt prints `ESCAPE_CONFIRMED` with `{"isProxy":true,"envSecret":"CANARY123","exec":"PWNED_FROM_SANDBOX","markerWritten":true}` and creates a host-side marker file `repro/proof/marker-vulnerable-<mode>-<n>.txt` containing the unique attempt token; every fixed attempt prints `ESCAPE_NOT_CONFIRMED` (`sandbox returned: UNSET`) and creates no marker.

## Evidence
- Per-attempt logs: `bundle/repro/proof/{vulnerable,fixed}-{VM,NodeVM}-{1,2}.log`
- Host-written marker files (proof of host command execution from the sandbox): `bundle/repro/proof/marker-vulnerable-VM-{1,2}.txt`, `bundle/repro/proof/marker-vulnerable-NodeVM-{1,2}.txt`
- Exploit harness executed: `bundle/repro/harness.js`
- Diagnostics: `bundle/logs/reproduction_steps.log`
- Machine-readable manifest with sha256 of every proof artifact and npm-tarball-bound target identity: `bundle/repro/runtime_manifest.json`
- Key excerpt (vulnerable, VM):
  `[VM:vulnerable-1] sandbox returned: {"isProxy":true,"envSecret":"CANARY123","exec":"PWNED_FROM_SANDBOX","markerWritten":true}` → `ESCAPE_CONFIRMED`
- Key excerpt (fixed, VM): `[VM:fixed-1] sandbox returned: UNSET` → `ESCAPE_NOT_CONFIRMED`
- Environment: Node.js v24.18.0, linux x86_64; vm2@3.12.0 tarball sha256 `263d59bfcdd5107915551b4181228fb5c8dd98f043faa78f2b8fb33f8fe8ffa8`; vm2@3.12.1 tarball sha256 `afa9d765ff89edcd8472ef2fb3e1707d2e9077aeffe8ea861fd46aa696726513`.

## Recommendations / Next Steps
- Upgrade to vm2 ≥ 3.12.1 immediately; note vm2 is formally discontinued upstream, so prefer migrating to `isolated-vm` or Node's `vm` with out-of-process isolation for any untrusted-code workload.
- The 3.12.1 fix (species neutralization at the `SpeciesConstructor` chokepoint + `Reflect.apply` peel) is the correct layered defense; regression tests should cover `.then()`, `.catch()`, `.finally()`, `Function.prototype.call/apply`, and `Reflect.apply` indirection against host promises.
- Embedders should additionally avoid returning host promises that reject with host-pivotable values, and should freeze exposed host objects where feasible.

## Additional Notes
- Idempotent: the script reuses the cached package installs keyed by version, removes stale markers before each attempt, and was run twice consecutively with identical confirmed results.
- The PoC mechanics were reconstructed from the vendor advisory GHSA-6454-5x88-m6jw and independently re-executed against the published npm artifacts in this run; no historical proof was reused.
- Limitations: the escape requires an embedder-exposed host function returning a host-realm Promise (the documented vm2 pattern) and a rejection path; impact is unconditional once those hold, independent of what the promise rejects with.
