GHSA-Q3FM-4WCW-G57X

Vulnerability from github – Published: 2026-05-29 17:38 – Updated: 2026-05-29 17:38
VLAI
Summary
vm2 setup-sandbox.js violates Defense Invariant #11 in stack-trace formatter
Details

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.

// 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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.11.3"
      },
      "package": {
        "ecosystem": "npm",
        "name": "vm2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.11.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-693"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-29T17:38:33Z",
    "nvd_published_at": null,
    "severity": "LOW"
  },
  "details": "## Summary\n\n`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\u0027s safe-default stack-trace formatting and observes / intercepts each appended line.\n\n## Details\n\nThe post-9qj6 audit note in `docs/ATTACKS.md` (line 2111) states:\n\n\u003e 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.\n\nThe 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:\n\n```\n// lib/setup-sandbox.js, lines 601-610\nconst lines = [header];\nfor (let i = 0; i \u003c callSites.length; i++) {\n    try {\n        lines[lines.length] = \u0027    at \u0027 + callSites[i];\n    } catch (e) {\n        lines[lines.length] = \u0027    at \u003cerror formatting frame\u003e\u0027;\n    }\n}\nreturn lines.join(\u0027\\n\u0027);\n```\n\nThis 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.\n\nThe currently-assigned value is the string `\u0027    at \u0027 + callSites[i]` (the wrapped `CallSite` class\u0027s safe `toString()` returns `\u0027CallSite {}\u0027`), which limits the immediate impact to a side channel, not an RCE pivot. The concern is structural rather than exploit-today:\n\n- The just-codified Defense Invariant #11 explicitly requires that any list, set, or map allocated for the bridge\u0027s exclusive use must read and write through identity-stable, prototype-bypassing primitives. This site does not.\n- 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.\n- 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.\n\nThe 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.\n\n```javascript\n// Suggested patch (sketch)\nlet linesLen = 1;\nfunction append(s) {\n    localReflectDefineProperty(lines, linesLen, {\n        __proto__: null,\n        value: s,\n        writable: true,\n        enumerable: true,\n        configurable: true,\n    });\n    linesLen++;\n}\nfor (let i = 0; i \u003c callSites.length; i++) {\n    try {\n        append(\u0027    at \u0027 + callSites[i]);\n    } catch (e) {\n        append(\u0027    at \u003cerror formatting frame\u003e\u0027);\n    }\n}\n```\n\nThe 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.\n\n## PoC\n\nvm2 v3.11.2, Node v24.\n\n```javascript\nconst { VM } = require(\u0027vm2\u0027);\nconst result = new VM().run(`\n    var observed = { setterFired: false, capturedValue: null, indexFired: null };\n    Object.defineProperty(Array.prototype, 1, {\n        configurable: true,\n        set(value) {\n            observed.setterFired = true;\n            observed.indexFired = 1;\n            observed.capturedValue =\n                typeof value === \u0027string\u0027 ? value.slice(0, 40) : typeof value;\n        },\n        get() { return undefined; }\n    });\n    var e = new Error(\u0027x\u0027);\n    e.stack;\n    observed;\n`);\nconsole.log(result);\n// {\n//   setterFired: true,\n//   capturedValue: \u0027    at CallSite {}\u0027,\n//   indexFired: 1\n// }\n```\n\nSandbox 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.\n\n## Impact\n\nHardening / 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\u0027s \"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.",
  "id": "GHSA-q3fm-4wcw-g57x",
  "modified": "2026-05-29T17:38:33Z",
  "published": "2026-05-29T17:38:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-q3fm-4wcw-g57x"
    },
    {
      "type": "WEB",
      "url": "https://github.com/patriksimek/vm2/commit/ad31adc1fc4a2c163f2f8c11ab4af206074528fd"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/patriksimek/vm2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/patriksimek/vm2/releases/tag/v3.11.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "vm2 setup-sandbox.js violates Defense Invariant #11 in stack-trace formatter"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…