Proof/CVEs & advisories/GHSA-qhwx-74w5-xhxq
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.
test.run() + execArgvNodeVM 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:
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:
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:
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.
Explicit allowlist
Embedder approves node:test and nothing else.
Doubled prefix
One strip maps the request onto the stored key.
Read-only proxy
Structure is immutable; run() is untouched.
execArgv
Node launches a child that evaluates code outside vm2.
Denied builtins
The sandbox cannot directly import fs, child_process, module or process. Removing the node:test entry blocks the capability entirely.
3.11.6 on Node v24.18.0
A benign marker proves code ran in the spawned host process.
3.11.7
Same runtime; the import/capability path is denied before host execution.
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.
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.
Capability mapping
Enumerate what every allowlistable builtin exports, prioritizing anything that spawns a process or runtime.
Normalization hypothesis
Test whether the loader and the policy store agree on one canonical module identity.
Runtime construction
Reach the spawned child with a benign marker on an exact package and pinned Node version.
Skeptic gate
Confirm deny-all blocks the route, so the finding is scoped to the allowlisted configuration.
Promotion and disclosure
State the configuration dependency plainly rather than letting the 9.9 stand unqualified.
| Scope | Value | Basis |
|---|---|---|
| Public advisory | ≥ 3.9.6, ≤ 3.11.6 | publisher’s stated range |
| Boundary evidence | 3.9.5 blocked · 3.9.6 affected | loader-architecture change 2353ce60351c50379b8d1daab05812c4db634162 |
| Reproduced vulnerable | 3.11.6 | exact package on Node.js v24.18.0 |
| Reproduced fixed | 3.11.7 | fixing 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.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:HA 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:
- exact allowed builtin names;
- repeated
node:prefixes; - denied builtin controls;
- process-creating methods reachable from allowed modules;
- fixed-version assertions that no child marker is created.
- GHSA-qhwx-74w5-xhxq — vm2 advisory and public PoC
- vm2 repository and release history
- 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.