{"repro_id":"REPRO-2026-00383","version":6,"title":"vm2 before 3.12.1 sandbox escape via host-realm Promise Symbol.species (VM and NodeVM)","repro_type":"security","status":"published","severity":"critical","description":"vm2 (npm) versions 3.12.0 and earlier contain a sandbox escape in both `VM` and `NodeVM`. When an embedder exposes a host API that returns a host-realm Promise, the bridge's rejection sanitizer (`hostPromiseSanitizeReject` / `makeSanitizedPromiseCallback` / `normalizeHostPromiseCallbacks` in `lib/bridge.js`) only wraps `then`/`catch` rejection slots that hold a function, and the sandbox-side `Symbol.species` / `.then` neutralization is installed only on the sandbox intrinsic `Promise.prototype`, so it never applies to a host Promise. Sandboxed code can therefore regain a host-realm Promise constructor / host `__proto__` access and escape to run arbitrary code in the host Node.js process (filesystem access, child_process, env vars, outbound network).","root_cause":"# CVE-2026-93606 — Root Cause Analysis\n\n## Summary\nvm2 (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.\n\n## Impact\n- **Package:** `vm2` (npm), `lib/bridge.js` + `lib/setup-sandbox.js`\n- **Affected versions:** ≤ 3.12.0 (all prior lines; the vulnerability is in the bridge's promise-rejection sanitizer introduced with the m283 defenses)\n- **Patched version:** 3.12.1 (also current latest 3.12.2)\n- **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.\n\n## Impact Parity\n- **Disclosed/claimed maximum impact:** sandbox escape → host arbitrary code execution.\n- **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.\n- **Parity:** `full`.\n\n## Root Cause\nTwo defense gaps compose:\n\n1. **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.\n\n2. **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.\n\n**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.\n\n## Reproduction Steps\n1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice consecutively — both runs exit 0).\n2. 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`.\n3. 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.\n\n## Evidence\n- Per-attempt logs: `bundle/repro/proof/{vulnerable,fixed}-{VM,NodeVM}-{1,2}.log`\n- 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`\n- Exploit harness executed: `bundle/repro/harness.js`\n- Diagnostics: `bundle/logs/reproduction_steps.log`\n- Machine-readable manifest with sha256 of every proof artifact and npm-tarball-bound target identity: `bundle/repro/runtime_manifest.json`\n- Key excerpt (vulnerable, VM):\n  `[VM:vulnerable-1] sandbox returned: {\"isProxy\":true,\"envSecret\":\"CANARY123\",\"exec\":\"PWNED_FROM_SANDBOX\",\"markerWritten\":true}` → `ESCAPE_CONFIRMED`\n- Key excerpt (fixed, VM): `[VM:fixed-1] sandbox returned: UNSET` → `ESCAPE_NOT_CONFIRMED`\n- Environment: Node.js v24.18.0, linux x86_64; vm2@3.12.0 tarball sha256 `263d59bfcdd5107915551b4181228fb5c8dd98f043faa78f2b8fb33f8fe8ffa8`; vm2@3.12.1 tarball sha256 `afa9d765ff89edcd8472ef2fb3e1707d2e9077aeffe8ea861fd46aa696726513`.\n\n## Recommendations / Next Steps\n- 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.\n- 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.\n- Embedders should additionally avoid returning host promises that reject with host-pivotable values, and should freeze exposed host objects where feasible.\n\n## Additional Notes\n- 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.\n- 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.\n- 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.\n","cve_id":"CVE-2026-93606","cwe_id":"CWE-693","source_url":"https://github.com/advisories/GHSA-q84h-7qfg-c6j8","package":{"name":"patriksimek/vm2","ecosystem":"github","affected_versions":"<= 3.12.0 (all versions 0 through 3.12.0 per VulnCheck affected range)","fixed_version":"3.12.1"},"reproduced_at":"2026-10-09T14:10:51.887309+00:00","duration_secs":2704.0,"tool_calls":159,"handoffs":2,"total_cost_usd":3.318328,"agent_costs":{"claim_matcher":0.021592,"judge":0.490016,"learning_policy":0.013937,"repro":0.960668,"support":0.098193,"vuln_variant":1.733922},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.021592},"judge":{"gpt-5.6-sol":0.490016},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.013937},"repro":{"accounts/fireworks/models/kimi-k3":0.960668},"support":{"accounts/fireworks/models/kimi-k3":0.098193},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.733922}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-10-09T14:10:52.535849+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7535,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10084,"category":"reproduction_script"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":2081,"category":"log"},{"path":"bundle/repro/harness.js","filename":"harness.js","size":3270,"category":"other"},{"path":"bundle/repro/proof/fixed-NodeVM-1.log","filename":"fixed-NodeVM-1.log","size":79,"category":"log"},{"path":"bundle/repro/proof/fixed-NodeVM-2.log","filename":"fixed-NodeVM-2.log","size":79,"category":"log"},{"path":"bundle/repro/proof/fixed-VM-2.log","filename":"fixed-VM-2.log","size":71,"category":"log"},{"path":"bundle/repro/proof/marker-vulnerable-NodeVM-1.txt","filename":"marker-vulnerable-NodeVM-1.txt","size":25,"category":"other"},{"path":"bundle/repro/proof/marker-vulnerable-NodeVM-2.txt","filename":"marker-vulnerable-NodeVM-2.txt","size":25,"category":"other"},{"path":"bundle/repro/proof/vulnerable-NodeVM-1.log","filename":"vulnerable-NodeVM-1.log","size":169,"category":"log"},{"path":"bundle/repro/proof/vulnerable-NodeVM-2.log","filename":"vulnerable-NodeVM-2.log","size":169,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2875,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1510,"category":"other"}]}