GHSA-x965-fc75-jpqh: Critical severity npm/vm2 vulnerability
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
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 - Compensating control
Fix vm2's handleException cycle handling so that, when a host-wrapped AggregateError or SuppressedError carrier is revisited, it returns the memoized sandbox-realm replacement instead of the raw host carrier; alternatively, seal those host-wrapped carriers in place before recursing into sub-errors, mirroring sanitizeHostOwnProps.
Event History
Frequently Asked Questions
Which installations should be treated as affected?
The affected software is npm/vm2 version 3.11.6, identified as this fork's latest release. The provided data does not identify a fixed version.
What must occur for exploitation?
Sandbox code must catch a host-wrapped AggregateError that is revisited during a single handleException traversal. This can occur through a self-cycle, a mutual cycle, or the same host aggregate appearing more than once in an errors[] array.
What is the practical impact if the condition is reached?
The sandbox can receive a live, unsanitized host proxy in the supposedly sanitized errors[] data. This yields full host remote code execution through the exception throw channel.