vm2 seventeen-GHSA escape wave — capability-bearing builtins, allowlist grammar drift, and shape-typed guards¶
Source: GitHub Security Advisories published 2026-10-01 15:15–15:42Z (seventeen vm2 advisories; representative reproduction runs on vm2 3.11.5/3.11.6 head and Node.js v24–v26). Companion pages: May 29 vm2/NodeVM + SGLang batch and the sibling-prefix resolver escape already covered on the Oct 1 MCP trust-layers page.
This is the largest single-library wave this wiki has tracked, and it collapses into four reusable audit rules for any JS-sandbox or plugin-isolation product (vm2, isolated-vm siblings, QuickJS wrappers, low-code script engines, agent tool runtimes): a read-only wrapper does not disarm a callable; every allowlist is a grammar that normalizes differently at check time vs load time; guards written as type/shape tests fail on shape variants; and fix patches gate the call site they were written for, not the property.
Class 1 — "read-only" wraps process-global capability sinks (only a narrow builtin grant needed)¶
The generic builtin loader exposes host modules through a recursive read-only proxy: vm.readonly(hostRequire(key)). Read-only blocks property assignment; it does not reduce the authority of callable exports that mutate process-wide host state. One narrowly allowed builtin is enough:
| Advisory | Allowed builtin(s) | Callable sink | Crossed boundary |
|---|---|---|---|
| GHSA-h85j-hv3c-qfgq (critical) | https | https.globalAgent.on('free', …) on the host shared agent | Sandbox receives host request options (Bearer tokens, private host/port) and can attach a data listener to the released TLS socket to read later host responses in plaintext, then replay the stolen token |
| GHSA-98xx-8mx4-x7cm (critical) | tls, url | tls.setDefaultCACertificates() | Sandbox replaces the CA list trusted by subsequent host-realm TLS clients (attacker-CA trust + destruction of normal roots); needs a host-realm array — obtained via the allowed url module's URLSearchParams |
| GHSA-46pr-c5wc-xffx (critical) | crypto | crypto.setEngine(pathToBundledLibrary) | OpenSSL runs the dynamic-library constructor before engine validation — arbitrary native code executes even though the call then throws ERR_CRYPTO_ENGINE_UNKNOWN |
| GHSA-6w8r-xxw2-g3hx (critical) | node:sqlite | DatabaseSync + loadExtension() (extension loading enabled, in-memory DB) | Native code execution from a library bundled inside the untrusted plugin package; reached with the double-prefix spelling require('node:node:sqlite') because exactly one node: prefix is stripped |
| GHSA-qhwx-74w5-xhxq (critical, ≥3.9.6, Node 24+) | node:test | run({ execArgv: ['--eval=<JS>'] }) spawns a real unrestricted Node process | Full host RCE; same double-prefix path require('node:node:test'); only reachable because Node 24's module.builtinModules exposes the scheme-only key |
| GHSA-jf8q-945g-9q4c (medium) | stream bridge | Object.getOwnPropertySymbols(ReadableStream.prototype) returns unfiltered nodejs.stream.disturbed / nodejs.stream.errored | Symbol blocklist is a fixed list while the Symbol.for trap is namespace-based — new Node-registered symbols leak; sandbox can lie about host stream state (isDisturbed() → false after read) |
Operator rule: for any sandbox that admits named builtins, enumerate the allowed module's exports for process-global mutators and shared singletons (setDefaultCACertificates, setEngine, setServers, globalAgent, loadExtension, process-spawning test runners). The maintainers' DANGEROUS_BUILTINS list grew reactively (dns because of dns.setServers()); the class is unbounded and grows with every Node release. Check-time question: "does a call on this module change state the host trusts, or expose traffic the host carries?" If yes, read-only wrapping is theater. Validation in a lab: allow one builtin, register a free listener on a synthetic host request to your own callback, or swap CAs against a local TLS server with an attacker cert — marker-only, never production traffic.
Class 2 — allowlist grammars: check-time and load-time normalizers disagree¶
| Advisory | Policy written | What actually loads |
|---|---|---|
| GHSA-6rh5-qq4q-97xh (high) | builtin: ['*', '-fs', '-child_process'] | require('fs/promises') and node:fs/promises still resolve — negative entries match exact names, not subpaths; host file writes despite denied fs |
| GHSA-8686-vhfx-7r3j (critical) | builtin: ['*', '-node:child_process'] | child_process stays loaded — require() strips the node: prefix at load time but the wildcard deny check is an exact string match against canonical names at check time |
| GHSA-7q3f-wx44-378m (medium) | external allowlist, transitive: false | isPathAllowedForModule uses raw path.startsWith(mod.path) — node_modules/foo2 passes as "inside" allowlisted node_modules/foo (prefix-sharing sibling; third occurrence of this class this quarter) |
| GHSA-c48m-32m9-vx93 (critical) | external: ['left-pad'] + custom resolve | allowlist pre-check builds an unanchored regex — evil-left-pad / left-pad-backdoor pass the substring test and the custom resolver loads the colliding package in host context (plugin systems that auto-download packages make the collision attacker-supplied) |
Operator rule: every allowlist deserves four canaries: the subpath spelling (<denied>/promises), the scheme-prefix spelling (node: on both sides of the policy), the prefix-sharing sibling (allowed + x), and — where a custom resolver exists — a name-colliding package containing the allowlisted name. A policy whose check and enforcement use different normalizations (prefix-strip, canonicalization, anchoring) is the finding.
Class 3 — shape-typed guards and fix-site gates¶
require: []defeats the nesting guard → host RCE (GHSA-8hr7-r645-pc6w, critical). The constructor validates "explicit require config" withtypeof requireOpts === 'object'— arrays are objects.{nesting: true, require: []}degrades to a vm2-only resolver, sandbox coderequire('vm2')s the host module, and spawns an innerNodeVMwith an attacker-chosen builtin list (child_process,execSync('id')). Guard-the-shape, fail-open on shape variants; test every config guard with[],{},0,'',NaN, and a Proxy.vm.freeze()/vm.readonly()bypass via accessor descriptor (GHSA-633r-hq9m-c4ff, medium).Object.getOwnPropertyDescriptor(frozen, 'k').set.call(frozen, v)(or__lookupSetter__) runs the host setter against the unwrapped host object — the [[Set]] anddefinePropertytraps hold, the extracted-function path does not. Freeze guarantees cover data properties only; any frozen host object with a getter/setter is writable from the sandbox.- Fix bypass of GHSA-m283-3h24-438v via
call/applyindirection → host RCE (GHSA-647f-g98j-qq25, critical). The host-rejection sanitizer identity-checks only the direct handler target. Registering viap.then.call(p, undefined, cb)makes the intercepted targetFunction.prototype.call, the sanitizer never runs, and a host error carryingerr.detail = processreaches sandbox code intact →e.detail.mainModule.require('child_process').bind/Reflect.applyare sanitized; the gate covers one spelling. Audit rule: for any interception wrapper, enumerate the metaprogramming spellings (call/apply/bind/Reflect/Proxy/Symbol.species) that reach the same sink without passing the gate. - Stale
PromiseThenLookupChainprotector escape on Node.js 26 (GHSA-27g9-p43v-cw3v, critical, vm2 3.11.5 + current main — no fix at advisory time). V8'sSetPrototypePropertiesupdates the patchedPromise.prototype.thendata property without invalidating vm2's internal protector, sofinally()takes a fast path that invokes the original nativethen, skipping vm2's wrapper and its species reset; attacker-controlledSymbol.speciesthen supplies the reaction, and a calibrated stack overflow yields a raw hostRangeErrorwhose constructor chain reaches hostFunction→process. Operator value: sandbox escape strength is a function of the host runtime version, not just the library version — fingerprint Node/V8 alongside the sandbox version; escapes can be runtime-gated and silently absent/present across deployments.
Class 4 — the documented CLI has no sandbox at all¶
vm2 <file> runs host-realm require() on the script itself (GHSA-jxxv-8r27-vm4p, high). The CLI's NodeVM.file(path, {require:{external:true}}) with no root/context/builtin yields context: 'host' resolution — one require(__filename) call re-executes the attacker's file in the host process. The "sandbox runner" is equivalent to node ./script.js. For any tool marketed as a sandbox runner, run the require(__filename) self-echo canary first: if the module body's host-side side effects (env read, marker write in a disposable dir) appear, the tool provides zero isolation and every downstream "safe because sandboxed" assumption is false.
Replayable validation boundaries¶
- Lab-only: disposable
NodeVM/VMharness with a synthetic embedder; marker proofs (temp-file canary,process.versionread, owned local TLS/HTTP callback with attacker cert). Never run escapes on production plugin hosts, and never load realchild_processpayloads outside the harness. - Config fingerprint first: capture vm2 version, Node/V8 version,
require.builtin/require.externalshape (including array/empty shapes),nesting, and any customresolvebefore choosing which class to test. - Builtin-capability battery: for each allowed builtin, call its known process-global mutators against lab-owned sinks and diff host state before/after (CA list via a lab TLS handshake, agent events via an owned request, engine load via a harmless compiled marker library).
- Allowlist grammar battery: subpath,
node:-dual-spelling, prefix-sibling, and colliding-package canaries against every allow/deny mechanism, both as check input and as load path. - Guard-shape battery:
[],{},'',0,nullvsundefined, Proxy-wrapped config against every "is configured?" guard. - Fix-battery on patched builds: re-run the previous advisory's PoC (here GHSA-m283-3h24-438v) plus its call/apply/bind/Reflect/spelling variants on the fixed version — the patch is a token closure, not a property closure.
Tracked, not promoted from the same wave¶
pypdf resource-exhaustion septet (102993–103000, availability-only), jackson-core unbounded-error-token + float-regex ReDoS pair (89425/89407), devalue CPU-amplification singles (see May 15 sparse-array page), basic-ftp listing-parser ReDoS (102990), devalue async-rejection leak (x5rw).
Source: hourly offensive-security scan, 2026-10-01. All seventeen vm2 advisories plus adjacent tracked IDs recorded in the source index.