Read the docs
TI-2026-66806 Aliases GHSA-q3fm-4wcw-g57x

vm2 setup-sandbox.js violates Defense Invariant #11 in stack-trace formatter

Priority LOW 38CWE-693Published · Updated

Key facts

Attack vector
—
Privileges required
—
User interaction
—
Attack complexity
—
Impact type
Security bypass
Confidentiality · integrity · availability
—
Fix status
Fixed

No CVSS vector from any source. Fix status from GitHub. Impact type from CWE-693.

Summary

## Summary `defaultSandboxPrepareStackTrace` in `lib/setup-sandbox.js` (lines 605, 607) appends to a fresh sandbox-realm `lines = []` via `lines[lines.length] = value`. This is the exact invariant-violating pattern that GHSA-9qj6-qjgg-37qq (commit ca195f0, 2026-05-01) just patched in `neutralizeArraySpeciesBatch` and codified as Defense Invariant #11 ("Bridge-internal containers must not invoke sandbox code"). A sandbox-installed `Array.prototype[N]` setter fires during the bridge's safe-default stack-trace formatting and observes / intercepts each appended line. ## Details The post-9qj6 audit note in `docs/ATTACKS.md` (line 2111) states: > Equivalent pattern elsewhere in the bridge: audited; thisFromOtherArguments, otherFromThisArguments, and every other index-write site already use thisReflectDefineProperty or otherReflectDefineProperty. neutralizeArraySpeciesBatch was the lone outlier. The audit is scoped to `lib/bridge.js`. `lib/setup-sandbox.js` was not covered. `defaultSandboxPrepareStackTrace` (added under post-#563 hardening for GHSA-v27g) constructs a sandbox-realm `[header]` array and appends each frame via the prototype-walking index assignment: ``` // lib/setup-sandbox.js, lines 601-610 const lines = [header]; for (let i = 0; i < callSites.length; i++) { try { lines[lines.length] = ' at ' + callSites[i]; } catch (e) { lines[lines.length] = ' at <error formatting frame>'; } } return lines.join('\n'); ``` This function runs every time sandbox code reads `error.stack` (or any path that triggers `Error.prepareStackTrace`). At the time it runs, user code has already had the opportunity to install a setter on `Array.prototype[N]`. Because `lines` starts at length 1, the first iteration writes index 1; if `lines[1]` has no own data property, V8 walks the prototype chain and invokes the sandbox-controlled setter. The currently-assigned value is the string `' at ' + callSites[i]` (the wrapped `CallSite` class's safe `toString()` returns `'CallSite {}'`), which limits the immediate impact to a side channel, not an RCE pivot. The concern is structural rather than exploit-today: - The just-codified Defense Invariant #11 explicitly requires that any list, set, or map allocated for the bridge's exclusive use must read and write through identity-stable, prototype-bypassing primitives. This site does not. - The `catch` branch at line 607 also uses the same pattern, so a sandbox getter that throws on `callSites[i]` access still routes its retry write through the prototype chain. - A future change that makes the appended slot value an object holding a host-realm reference (for example, an enriched frame record) would re-introduce the exact GHSA-9qj6 attack shape against this codepath. The fix is mechanical and mirrors the GHSA-9qj6 patch: install entries via `localReflectDefineProperty` so each appended slot is an own data property and the prototype-chain setter is bypassed. ```javascript // Suggested patch (sketch) let linesLen = 1; function append(s) { localReflectDefineProperty(lines, linesLen, { __proto__: null, value: s, writable: true, enumerable: true, configurable: true, }); linesLen++; } for (let i = 0; i < callSites.length; i++) { try { append(' at ' + callSites[i]); } catch (e) { append(' at <error formatting frame>'); } } ``` The same pattern at `callSiteGetters[callSiteGetters.length] = {...}` (line 649) runs only at sandbox setup, before user code can install setters, so it is safe today. Converting it for symmetry would be cheap and forward-compatible. ## PoC vm2 v3.11.2, Node v24. ```javascript const { VM } = require('vm2'); const result = new VM().run(` var observed = { setterFired: false, capturedValue: null, indexFired: null }; Object.defineProperty(Array.prototype, 1, { configurable: true, set(value) { observed.setterFired = true; observed.indexFired = 1; observed.capturedValue = typeof value === 'string' ? value.slice(0, 40) : typeof value; }, get() { return undefined; } }); var e = new Error('x'); e.stack; observed; `); console.log(result); // { // setterFired: true, // capturedValue: ' at CallSite {}', // indexFired: 1 // } ``` Sandbox code observed and intercepted the bridge-internal write to `lines[1]`. Repeating the PoC with the setter installed at multiple indices (0, 1, 2, ...) captures every frame the formatter would otherwise return. ## Impact Hardening / Defense Invariant #11 violation. No direct sandbox escape on the current codebase: the value passed to the setter is a primitive string after the wrapped `CallSite.toString()`, so attacker-controlled code does not gain a host-realm reference from the setter argument alone. The GHSA-9qj6 entry's "Considered Attack Surfaces" note states the audit covered `lib/bridge.js` index-write sites; this filing reports the equivalent pattern in `lib/setup-sandbox.js` so the invariant is uniform across the bridge boundary and future enrichments of the appended record cannot regress into the GHSA-9qj6 shape.

Why it matters now

Fix Fixed vm2 (npm): 3.11.4

SSVC decision points

Not available: CISA has not published SSVC decision points for this record.

Affected products

VendorProductAffected versionsFixed inStatusSource
—vm2 (npm)before 3.11.43.11.4Patch availableGHSA-q3fm-4wcw-g57x
  • vm2 (npm) Vendor— Affected before 3.11.4 Fixed in 3.11.4 Patch available GHSA-q3fm-4wcw-g57x

Remediation

  • Fix · GHSA-q3fm-4wcw-g57x vm2 (npm) Fixed in 3.11.4 Advisory →

Timeline

  1. 2026-10-03 Threatist record created from GHSA-q3fm-4wcw-g57x

References

Sources

This product uses the NVD API but is not endorsed or certified by the NVD. Sources and licences