# REPRO-2026-00383: vm2 before 3.12.1 sandbox escape via host-realm Promise Symbol.species (VM and NodeVM) ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-693 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00383 CVE: CVE-2026-93606 ## Package Name: patriksimek/vm2 Ecosystem: github Affected: <= 3.12.0 (all versions 0 through 3.12.0 per VulnCheck affected range) Fixed: 3.12.1 ## Root Cause # 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 `, 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--.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. ## Reproduction Details Reproduced: 2026-10-09T14:10:51.887Z Duration: 2704 seconds Tool calls: 159 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00383 pruva-verify CVE-2026-93606 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00383&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00383/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-93606 - Source: https://github.com/advisories/GHSA-q84h-7qfg-c6j8 ## Artifacts - bundle/repro/rca_report.md (analysis, 7535 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 10084 bytes) - bundle/logs/reproduction_steps.log (log, 2081 bytes) - bundle/repro/harness.js (other, 3270 bytes) - bundle/repro/proof/fixed-NodeVM-1.log (log, 79 bytes) - bundle/repro/proof/fixed-NodeVM-2.log (log, 79 bytes) - bundle/repro/proof/fixed-VM-2.log (log, 71 bytes) - bundle/repro/proof/marker-vulnerable-NodeVM-1.txt (other, 25 bytes) - bundle/repro/proof/marker-vulnerable-NodeVM-2.txt (other, 25 bytes) - bundle/repro/proof/vulnerable-NodeVM-1.log (log, 169 bytes) - bundle/repro/proof/vulnerable-NodeVM-2.log (log, 169 bytes) - bundle/repro/runtime_manifest.json (other, 2875 bytes) - bundle/repro/validation_verdict.json (other, 1510 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00383 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00383/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00383 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev