Skip to content

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" with typeof requireOpts === 'object' — arrays are objects. {nesting: true, require: []} degrades to a vm2-only resolver, sandbox code require('vm2')s the host module, and spawns an inner NodeVM with 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]] and defineProperty traps 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/apply indirection → host RCE (GHSA-647f-g98j-qq25, critical). The host-rejection sanitizer identity-checks only the direct handler target. Registering via p.then.call(p, undefined, cb) makes the intercepted target Function.prototype.call, the sanitizer never runs, and a host error carrying err.detail = process reaches sandbox code intact → e.detail.mainModule.require('child_process'). bind/Reflect.apply are 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 PromiseThenLookupChain protector escape on Node.js 26 (GHSA-27g9-p43v-cw3v, critical, vm2 3.11.5 + current main — no fix at advisory time). V8's SetPrototypeProperties updates the patched Promise.prototype.then data property without invalidating vm2's internal protector, so finally() takes a fast path that invokes the original native then, skipping vm2's wrapper and its species reset; attacker-controlled Symbol.species then supplies the reaction, and a calibrated stack overflow yields a raw host RangeError whose constructor chain reaches host Function → 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/VM harness with a synthetic embedder; marker proofs (temp-file canary, process.version read, owned local TLS/HTTP callback with attacker cert). Never run escapes on production plugin hosts, and never load real child_process payloads outside the harness.
  • Config fingerprint first: capture vm2 version, Node/V8 version, require.builtin / require.external shape (including array/empty shapes), nesting, and any custom resolve before 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, null vs undefined, 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.