//nefariousplan

CVE-2026-26332: vm2 Wraps Every Error the Sandbox Throws. SuppressedError Carries the Two V8 Threw.

patterns

cve

proof of concept

vm2 v3.10.4 ships a Proxy bridge that wraps every Error object it sees the sandbox construct. CVE-2026-26332 escapes through two Error objects vm2 did not see the sandbox construct, because the sandbox did not construct them. V8 built both, in C++, during routine stack formatting and routine DisposableStack disposal, and handed both back to the sandbox as one SuppressedError whose .suppressed slot is a host-realm TypeError.

e.suppressed.constructor.constructor is the host Function. The PoC needs three property accesses after the catch and zero custom exploit logic. The two features it chains, DisposableStack and SuppressedError, both shipped in ES2024. vm2's wrap list shipped against ES2023.

The exploit is two ES2024 features composed

The full PoC, as published in the GHSA advisory, verbatim:

const { VM } = require("vm2");
const vm = new VM();
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) {
  const Function = e.suppressed.constructor.constructor;
  const process = new Function("return process;")();
  const { execSync } = process.mainModule.require("node:child_process");
  execSync("echo pwned", { stdio: "inherit" });
}
`);

Two defer registrations, one dispose, one catch, three property accesses to the host Function. The PoC reads as if the author was learning DisposableStack from MDN. There is no descriptor walk, no Reflect chain, no Symbol.toPrimitive trick, no prototype pollution. The first Function invocation returns the host process because the constructor it was called through belongs to the host realm. The rest is child_process.execSync.

The two errors that escape are not constructed in the sandbox

DisposableStack.prototype.dispose (Explicit Resource Management, ES2024) drains deferred callbacks in LIFO order. The second-registered defer runs first; the first-registered defer runs second. The disposal protocol specifies that if a pending error already exists when the next disposer throws, the new error is wrapped in a SuppressedError whose error slot is the latest throw and whose suppressed slot is the prior throw.

The second-registered defer constructs an Error(), sets e.name = Symbol(), and reads e.stack. V8's Error.prototype.stack getter formats the error by composing name and message through String coercion. String of a Symbol is a TypeError per ECMA-262 §7.1.17 (Symbol-to-String coercion is intentionally unsafe; the spec wants the developer to call .toString() explicitly). V8 constructs and throws that TypeError from C++ during stack formatting. The Error() constructor was wrapped by vm2's bridge; the TypeError thrown by String(symbol) was not, because the sandbox never called new TypeError.

The first-registered defer runs second and throws null. The disposal protocol catches null, sees that a TypeError is already pending, and constructs a SuppressedError whose error is null and whose suppressed is the host-realm TypeError. The SuppressedError itself is built by V8's implementation of the disposal protocol, also in C++. dispose() rethrows it across the sandbox boundary.

The catch block receives a host-realm SuppressedError carrying a host-realm TypeError. Both objects crossed back into the sandbox without being constructed by sandbox code. vm2's Proxy bridge intercepts new and property access on objects it knows about. It does not see V8 manufacturing objects on the sandbox's behalf during error formatting and disposal.

e.suppressed.constructor returns the host TypeError constructor. .constructor on that returns the host Function. The PoC has the host realm in three reads.

vm2's wrap list is the TC39 changelog, one release behind

This is the third vm2 post on this blog naming the same primitive from a different V8 angle.

CVE-2026-24118 escaped because ArraySpeciesCreate reads this.constructor[Symbol.species] from the raw object, in V8's C++ implementation of Array.prototype.map and its siblings. The Proxy bridge's get trap does not see C++ reads. vm2's defense is neutralizeArraySpeciesBatch, a per-built-in shim that hides the host array's constructor slot for the duration of every host call. Per built-in, because the wrap list cannot wrap V8's C++ reads in general.

CVE-2026-26956 escaped because WebAssembly.JSTag's identity is read by the wasm runtime through a C++ slot, not through the JS property descriptor. A Proxy around JSTag is invisible to the matching logic in the try_table instruction. vm2's defense is to delete WebAssembly.JSTag from the sandbox global. By deletion, because the wrap list cannot wrap a C++ read.

CVE-2026-26332 is the third class. The escape does not exploit a C++ read. The escape exploits two C++ constructions: V8 constructs a TypeError on e.stack's behalf when name is a Symbol, and V8 constructs a SuppressedError on dispose()'s behalf when two disposers both throw. Both objects reach the sandbox through ES2024 carriers (e.stack's side-effect, dispose()'s rethrow). The wrap list does not yet cover either carrier, because both shipped in the same ECMAScript edition vm2's last patch was written against.

The pattern is the design-debt-driver the prior vm2 posts named. Every TC39 release that adds a new way to box, propagate, or aggregate Error objects gives the sandbox a new way to leak the host realm. error.cause (ES2022) gave it one. AggregateError.errors (ES2021) gave it one. SuppressedError.suppressed (ES2024) gives it one. AsyncDisposableStack is stage 3 and asynchronous, with its own disposal-protocol exception path; whichever stage-3 proposal in ES2025 introduces an error-carrying property next will be the entry point of the next CVE.

vm2's README says "New bypasses will likely be discovered in the future." The sentence is the maintainer naming the unpatchable-primitive. The wrap list is the inventory of carriers TC39 has shipped; the inventory is closed at release time and reopened the next time TC39 ships.

The patch will add SuppressedError to the wrap list

vm2 v3.11.0 patches this CVE by adding SuppressedError to setup-sandbox.js's list of wrapped error constructors and intercepting the disposal protocol's rethrow path. The fix shape is constrained: the sandbox cannot prevent V8 from constructing host-realm objects during disposal; the sandbox can only attempt to re-wrap them on the way back across the bridge.

That re-wrapping is the only available defense, and it is necessarily reactive. A SuppressedError constructed by V8's disposal protocol does not pass through any sandbox-controlled constructor; the only place to interpose is the disposal protocol's rethrow path itself. vm2's setup-sandbox.js is going to grow another defense whose name documents the V8 surface it patches, the way resetPromiseSpecies documents Promise.then and neutralizeArraySpeciesBatch documents the species-using array methods. The defense vocabulary is now an inventory of the places vm2's bridge has formally given up.

PoC: GHSA-55hx-c926-fr95

vm2's wrap list is one ECMAScript edition behind by definition. The list is written after the features ship.