GitHub Advisoryadvisory · npm
GHSA-q3fm-4wcw-g57x vm2 setup-sandbox.js violates Defense Invariant #11 in stack-trace formatter
LOW
Official page ↗
- Ecosystem
- npm
- Packages
- vm2
- Severity
- Low
- CVSS vector
- CVSS:4.0/AV:L/AC:H/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N
- Weaknesses
- CWE-693
- Reviewed
- 2026-05-29
Affected packages
• npm vm2: < 3.11.4 → fixed in 3.11.4
Description
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 ';
}
}
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 ');
}
}
```
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.
Related
Weakness · CWE (1)
- CWE-693 Protection Mechanism Failure weakness