Coordinated disclosureGHSA-qhwx-74w5-xhxqvm2 · npm9.9 Critical

Proof/CVEs & advisories/GHSA-qhwx-74w5-xhxq

GHSA.06 — Advisory credit

Read-only is not capability-safe.

vm2 wrapped the explicitly allowlisted node:test module in a read-only proxy. An immutable reference to a privileged host API is still a privileged host API — and this one starts processes.

AdvisoryGHSA-qhwx-74w5-xhxq
Packagevm2
Ecosystemnpm · Node.js
Affected≥3.9.6 ≤3.11.6
Fixed3.11.7
WeaknessCWE-693
Severity9.9 Critical
ReporterCharles Vosburgh
An allowlisted module must not expose a capability that launches an unrestricted runtime, and equivalent builtin spellings must normalize to one canonical policy identity before lookup.
Weakness
CWE-693
Trigger
test.run() + execArgv
Control
deny-all builtins blocks it
Outcome
process outside the sandbox
Configuration dependency, stated up front.This is not a default NodeVM configuration. The embedder must explicitly allow node:test, and the demonstrated route depends on a Node.js runtime whose builtin inventory exposes the relevant scheme-qualified entry. That does not remove the sandbox failure — it is part of realistic reachability and belongs in any severity discussion.

A common NodeVM security model permits only a small set of builtins:

Embedder configurationthe expected security model
require: {
  builtin: ['node:test'],
  external: false
}

The expected property is that sandbox JavaScript can use only that approved capability and remains unable to obtain unrestricted process execution.

An allowlisted module must not expose a capability that launches an unrestricted runtime, and equivalent builtin spellings must normalize to one canonical policy identity before lookup.

Two separate design lessons are entangled here: module-level allowlisting is insufficient when a module exports boundary-crossing capabilities, and names used for policy decisions need one canonical representation.

In vm2 3.11.6, lib/builtin.js maintains a hard denylist for especially dangerous builtins. The test family was not denied, so an explicitly approved node:test entry could be registered through the generic host-module path:

Generic host-module registrationvm2 3.11.6
builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));

Wrapping a module in a read-only proxy protects its object structure. It does not change what privileged methods on the host module can do. On supporting Node.js versions, host node:test exposes run() with options that launch test isolation in a separate process and accept execArgv. Supplying a Node argument containing --eval= causes the spawned runtime to evaluate JavaScript outside the vm2 sandbox.

The sandbox therefore never needs direct access to child_process, fs, process or module. The privileged behaviour is already encapsulated in the explicitly allowlisted host API.

The published route also exposes a module-name normalization problem. In lib/setup-node-sandbox.js, vm2 strips one leading node: prefix before builtin lookup:

Single-strip normalizationsetup-node-sandbox.js
if (localStringPrototypeStartsWith(filename, 'node:')) {
  id = localStringPrototypeSlice(filename, 5);
  nmod = loadBuiltinModule(id);
}

If the stored allowlist key is itself node:test, then a request for node:node:test is reduced once to node:test and reaches the host builtin.

01 · Allow

Explicit allowlist

Embedder approves node:test and nothing else.

02 · Resolve

Doubled prefix

One strip maps the request onto the stored key.

03 · Wrap

Read-only proxy

Structure is immutable; run() is untouched.

04 · Escape

execArgv

Node launches a child that evaluates code outside vm2.

Negative controls

Denied builtins

The sandbox cannot directly import fs, child_process, module or process. Removing the node:test entry blocks the capability entirely.

Vulnerable path

3.11.6 on Node v24.18.0

A benign marker proves code ran in the spawned host process.

Fixed control

3.11.7

Same runtime; the import/capability path is denied before host execution.

CONTROL direct import of fs / child_process / module / process: denied CONTROL deny-all builtin config: route blocked 3.11.6 node:node:test resolves to the allowlisted builtin 3.11.6 test.run({ execArgv: [...] }) launches a child runtime CHILD marker created outside the vm2 context 3.11.7 import/capability path denied, no child marker: PASS RUNTIME Node.js v24.18.0 — builtin inventory is version-dependent

Public validation also showed the exact path was not available on every tested Node.js version, so runtime-specific builtin inventory matters to reachability.

The security decision was made at the module-name level: if node:test was allowlisted, the entire host module was considered acceptable. But that module contains a method capable of creating a new runtime with caller-controlled Node arguments, and a read-only wrapper does not make that capability safe.

At the same time, builtin identities were not fully canonicalized before policy evaluation, leaving doubled node: spellings as another path through the loader.

Read-only is not capability-safe. An immutable reference to a privileged host API can still be enough to cross a sandbox boundary.
The SecHive research loop

Inventory capabilities, not module names.

An allowlist reads like a security boundary. The question that actually matters is what each approved module can do — and whether the policy engine agrees with itself about what each module is called.

SK.01

Capability mapping

Enumerate what every allowlistable builtin exports, prioritizing anything that spawns a process or runtime.

SK.02

Normalization hypothesis

Test whether the loader and the policy store agree on one canonical module identity.

SK.03

Runtime construction

Reach the spawned child with a benign marker on an exact package and pinned Node version.

SK.04

Skeptic gate

Confirm deny-all blocks the route, so the finding is scoped to the allowlisted configuration.

HUMAN

Promotion and disclosure

State the configuration dependency plainly rather than letting the 9.9 stand unqualified.

ScopeValueBasis
Public advisory≥ 3.9.6, ≤ 3.11.6publisher’s stated range
Boundary evidence3.9.5 blocked · 3.9.6 affectedloader-architecture change 2353ce60351c50379b8d1daab05812c4db634162
Reproduced vulnerable3.11.6exact package on Node.js v24.18.0
Reproduced fixed3.11.7fixing commit 415339f698f0d52d3c5ad358b12b79c8072d5b4b

The 3.11.7 source hardens the builtin/capability boundary so the public import path is denied before the dangerous test-runner capability can be used.

Range note. The advisory description originally referenced reproduction through 3.11.5, while the structured range extends through 3.11.6. The local 3.11.6 run confirms that upper endpoint.
Critical
Published advisory severity
9.9
CVSS 3.1 · scope changed
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

A successful escape launches JavaScript in a separate unrestricted Node.js process with the operating-system authority of the application hosting vm2. The child is no longer constrained by NodeVM’s module policy, so files, credentials, environment variables, outbound network access and any other resource available to the host account are exposed.

Upgrade to vm2 3.11.7 or later and reassess every builtin module exposed to untrusted NodeVM code.

For sandbox designs generally, treat a builtin allowlist as a capability inventory rather than a package-name list. APIs that launch processes, create workers or runtimes, load native code, or accept interpreter arguments deserve explicit policy treatment even when the surrounding module object is read-only. Canonicalize module identifiers before allow/deny evaluation and reject ambiguous or repeated scheme prefixes.

Regression tests should include:

  1. exact allowed builtin names;
  2. repeated node: prefixes;
  3. denied builtin controls;
  4. process-creating methods reachable from allowed modules;
  5. fixed-version assertions that no child marker is created.
2026-08-24
GHSA-qhwx-74w5-xhxq and the public PoC published.
AUGUST 2026
vm2 3.11.7 published with the fix.
2026-08-26
Exact 3.11.6 and 3.11.7 local controls revalidated on Node.js v24.18.0.
  1. GHSA-qhwx-74w5-xhxq — vm2 advisory and public PoC
  2. vm2 repository and release history
  3. Node.js test runner API — run() and execArgv

Credit: Discovered and reported by Charles Vosburgh. Research was AI-assisted through source mapping, hypothesis generation, release comparison and evidence organization; final validation, disclosure coordination, severity calibration and publication review remained human.