CVE-2026-93606: Verified Reproduction
CVE-2026-93606: vm2 before 3.12.1 sandbox escape via host-realm Promise Symbol.species VM and NodeVM
CVE-2026-93606 is verified against patriksimek/vm2 · github. Affected versions: <= 3.12.0 (all versions 0 through 3.12.0 per VulnCheck affected range). Fixed in 3.12.1. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00383.
What Is CVE-2026-93606?
CVE-2026-93606 is a critical-severity vulnerability affecting patriksimek/vm2 <= 3.12.0 (all versions 0 through 3.12.0 per VulnCheck affected range). Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00383).
CVE-2026-93606 Severity
CVE-2026-93606 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected patriksimek/vm2 Versions
patriksimek/vm2 · github versions <= 3.12.0 (all versions 0 through 3.12.0 per VulnCheck affected range) are affected.
How to Reproduce CVE-2026-93606
pruva-verify REPRO-2026-00383 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00383/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-93606
- reached the target end-to-end
- full exploit chain demonstrated
- high confidence
- the upstream fix blocks the same trigger
Sandboxed script run inside vm2 VM/NodeVM; script sets p.constructor[Symbol.species] on a host-realm Promise and calls p.then() with no onRejected
- vm.run()
- host Promise.prototype.then bridge apply-trap
- V8 PerformPromiseThen Thrower
- species-hijacked resultCapability.[[Reject]]
- raw host process object delivered to sandbox closure
- host child_process.execSync
How the agent worked
Root Cause and Exploit Chain for CVE-2026-93606
- 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
VMandNodeVM, the sandboxed script (a) received the raw hostprocessobject as a live bridge proxy (isProxy: true), (b) read a host-only environment variable (HOST_ONLY_SECRET=CANARY123) invisible to the sandbox's ownprocessstub, and (c) executed host shell commands viahostValue.mainModule.require('child_process').execSync, writing unique per-attempt marker files on the host filesystem. - Parity:
full.
Root Cause
Two defense gaps compose:
Species neutralization is sandbox-realm-only.
lib/setup-sandbox.jsoverridesthen/catch/finallyon the sandbox intrinsicPromise.prototypeto callresetPromiseSpecies(this)(GHSA-27g9-p43v-cw3v). A host Promise crossing the bridge keeps the hostPromise.prototypemethods, so this neutralization never runs for it. MeanwhileBaseHandler.setdeliberately allows ordinary sandbox writes onto a non-frozen host object, sop.constructor = { [Symbol.species]: Evil }lands on the raw host promise.The rejection sanitizer only wraps function-valued slots. The bridge's apply-trap interception of host
Promise.prototype.then/catch(normalizeHostPromiseCallbacks/makeSanitizedPromiseCallbackinlib/bridge.js) wrapsonFulfilled/onRejectedonly when the slot holds a function. PerPerformPromiseThen, a missing/non-callableonRejectedmakes V8 substitute its internalThrower, which performsthrow reasonintoresultCapability.[[Reject]]with the raw host value. BecauseresultCapabilitywas built viaSpeciesConstructor(p, %Promise%)→new Evil(GetCapabilitiesExecutor)— executed back in the sandbox through the proxy's[[Construct]]trap —[[Reject]]is an attacker sandbox closure. NohandleException,ensureThis, orhostPromiseSanitizeRejectchokepoint 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
bundle/repro/reproduction_steps.sh(self-contained; run twice consecutively — both runs exit 0).- The script downloads the immutable npm tarballs
vm2@3.12.0(vulnerable) andvm2@3.12.1(fixed), installs them with pinned integrity into the prepared project cache (/pruva/project-cache/vm2-pkgs, fallbackbundle/artifacts/vm2-pkgs), generatesbundle/repro/harness.js, and runs two clean attempts per build per sandbox class (VM×2,NodeVM×2 on each version) vianode harness.js <vm2-dir> <VM|NodeVM> <id>, each invocation bounded bytimeout 60. - Expected evidence: every vulnerable attempt prints
ESCAPE_CONFIRMEDwith{"isProxy":true,"envSecret":"CANARY123","exec":"PWNED_FROM_SANDBOX","markerWritten":true}and creates a host-side marker filerepro/proof/marker-vulnerable-<mode>-<n>.txtcontaining the unique attempt token; every fixed attempt printsESCAPE_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 sha256afa9d765ff89edcd8472ef2fb3e1707d2e9077aeffe8ea861fd46aa696726513.
Recommendations / Next Steps
- Upgrade to vm2 ≥ 3.12.1 immediately; note vm2 is formally discontinued upstream, so prefer migrating to
isolated-vmor Node'svmwith out-of-process isolation for any untrusted-code workload. - The 3.12.1 fix (species neutralization at the
SpeciesConstructorchokepoint +Reflect.applypeel) is the correct layered defense; regression tests should cover.then(),.catch(),.finally(),Function.prototype.call/apply, andReflect.applyindirection 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.
CVE-2026-93606 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /pruva/project-cache/ 2>/dev/null; node --version; npm --version; ls /pruva/project-cache/repo 2>/dev/null | headtotal 4 drwx------ 4 vscode vscode 80 Oct 9 13:08 . drwxr-xr-x 3 root root 4096 Oct 9 13:08 .. drwx------ 2 vscode vscode 60 Oct 9 13:07 .pruva drwx------ 2 vscode vscode 40 Oct 9 13:07 repo-mirrors v24.18.0 11.16.0
npm view vm2 versions --json 2>&1 | tail -20; npm view vm2@3.12.1 dist.tarball 2>&1"3.10.4", "3.10.5", "3.11.0", "3.11.1", "3.11.2", "3.11.3", "3.11.4", "3.11.5",
mkdir -p /workspace/work/vm2-3.12.0 /workspace/work/vm2-3.12.1 && cd /workspace/work/vm2-3.12.0 && npm pack vm2@3.12.0 --silent && tar xzf vm2-3.12.0.tgz && cd /workspace/work/vm2-3.12.1 && npm pack vm2@3.12.1 --silent && tar xzf vm2-3.12.1.tgz && diff -ru /workspace/work/vm2-3.12.0/package /workspace/work/vm2-3.12.1/package | head -300{"cwd":"/workspace","duration_ms":1296,"exit_code":0,"stderr":"","stdout":"vm2-3.12.0.tgz\nvm2-3.12.1.tgz\ndiff -ru /workspace/work/vm2-3.12.0/package/lib/bridge.js /workspace/work/vm2-3.12.1/package/lib/bridge.js\n--- /workspace/work/vm2-3.12.0/package/lib/bridge.js\t1985-10-26 08:15:00.000000000 +0000\n+++ /workspace/work/vm2-3.12.1/package/lib/bridge.js\t1985-10-26 08:15:00.000000000 +0000\n@@ -70,6 +70,17 @@\n \n const OPNA = 'Operation not allowed on contextified object.';\n \n+// SECURITY (GHSA-j89j-5m6r-cr2q): cache THIS realm's global object at module\n+// init. bridge.js is loaded in both the host and the sandbox realm, so this\n+// constant resolves to the host global in the host copy and the sandbox global\n+// in the sandbox copy. Each bridge publishes it (as `result.global`) so the\n+// OTHER bridge can recognise — and refuse to deliver — the other realm's\n+// global object across the boundary. `global` (Node's global object binding)\n+// exists on every supported Node version, unlike `globalThis` (Node 12+), and\n+// is identical to the object V8's OrdinaryCallBindThis substitutes for a\n+// sloppy-mode function's nullish `this`.\n+const thisRealmGlobal = global;\n+\… [truncated]diff -ru /workspace/work/vm2-3.12.0/package /workspace/work/vm2-3.12.1/package | sed -n '300,600p'{"cwd":"/workspace","duration_ms":66,"exit_code":0,"stderr":"","stdout":"+\t\t\tdefined = otherReflectDefineProperty(obj, 'constructor', {\n+\t\t\t\t__proto__: null,\n+\t\t\t\tvalue: undefined,\n+\t\t\t\twritable: true,\n+\t\t\t\tenumerable: false,\n+\t\t\t\tconfigurable: true,\n+\t\t\t});\n+\t\t} catch (e) {\n+\t\t\tthrow thisFromOtherForThrow(e);\n+\t\t}\n+\t\tif (!defined) {\n+\t\t\tthrow new VMError('Unsafe host promise state; cannot neutralize species');\n+\t\t}\n+\n+\t\treturn {\n+\t\t\t__proto__: null,\n+\t\t\tobj: obj,\n+\t\t\toriginalDesc: originalDesc,\n+\t\t\tmarker: PROMISE_SPECIES_NEUTRALIZED,\n+\t\t};\n+\t}\n+\n+\t// SECURITY: restore the raw host promise's `constructor` after the guarded\n+\t// then/catch/finally call. Called from a `finally`; must not throw.\n+\tfunction restoreHostPromiseSpeciesOn(saved) {\n+\t\tif (!saved || saved.marker !== PROMISE_SPECIES_NEUTRALIZED) return;\n+\t\tconst obj = saved.obj;\n+\t\tconst originalDesc = saved.originalDesc;\n+\t\ttry {\n+\t\t\tif (originalDesc) {\n+\t\t\t\totherReflectDefineProperty(obj, 'constructor', originalDesc);\n+\t\t\t} else {\n+\t\t\t\totherReflectDeleteProperty(obj, 'constructor');\n+\t\t\t}\n+\t\t} catch (e) … [truncated]Artifacts and Evidence for CVE-2026-93606
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-93606
Upgrade patriksimek/vm2 · github to 3.12.1 or later.
FAQ: CVE-2026-93606
Is CVE-2026-93606 exploitable?
How severe is CVE-2026-93606?
What type of vulnerability is CVE-2026-93606?
Which versions of patriksimek/vm2 are affected by CVE-2026-93606?
Is there a fix for CVE-2026-93606?
How can I reproduce CVE-2026-93606?
Is the CVE-2026-93606 reproduction verified?
References for CVE-2026-93606
Authoritative sources for CVE-2026-93606 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.