NodeVM builtin denylist bypass via process and inspector/promises allows host code execution
Description
Summary
NodeVM blocks several dangerous Node.js builtins such as module, worker_threads, cluster, vm, repl, and inspector.
However, the denylist misses process and inspector/promises. Both can be used from sandboxed code to reach host-side execution primitives.
This allows sandboxed code to bypass the intended builtin restrictions and execute code in the host process.
Details
The dangerous builtin denylist is defined in lib/builtin.js. This list does not include:
process
inspector/promises
Non-denied builtins are exposed to the sandbox through:
builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
Because of this, sandboxed code can bypass the expected restrictions in two ways:
require('process').getBuiltinModule('child_process')reloadschild_process, even whenchild_processis excluded.require('inspector/promises')exposes the Inspector protocol and can callRuntime.evaluatein the host process.
PoC
Tested on:
vm2: 3.11.2
Node.js: v25.9.0
Run from the vm2 repository root:
node poc/dangerous-builtin-denylist-rce.js
dangerous-builtin-denylist-rce.js
The PoC first confirms the intended restrictions work:
require("inspector"): BLOCKED
require("child_process"): BLOCKED
Then it bypasses them:
require("process").getBuiltinModule("child_process").execFileSync(...)
This spawns a host child process. It also confirms:
require("inspector/promises").Session().post("Runtime.evaluate", ...)
This evaluates JavaScript in the host process.
Impact
An attacker who can run untrusted JavaScript inside NodeVM with affected builtin settings can escape the sandbox and execute arbitrary code in the host process.
This can lead to full compromise of the application process, including reading files, writing files, spawning processes, and accessing host environment secrets.
(This is not reachable with the default NodeVM configuration where require is disabled or no affected builtins are allowed. It affects applications that allow process, inspector/promises, or the wildcard "*" in require.builtin.)
Suggested fix
Add process and inspector/promises to the dangerous builtin blocklist.
Also consider blocking dangerous builtin families by prefix, for example blocking both:
inspector
inspector/*
instead of only exact module names.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
NodeVM sandbox fails to block `process` and `inspector/promises` builtins, allowing sandboxed code to execute arbitrary code in the host process.
Vulnerability
The NodeVM sandbox in vm2 maintains a denylist of dangerous Node.js builtins (e.g., module, worker_threads, cluster, vm, repl, inspector) defined in lib/builtin.js. However, the denylist omits process and inspector/promises. Because non-denied builtins are exposed to the sandbox via builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key))), sandboxed code can require these two modules. This affects vm2 versions up to and including 3.11.2; the issue is fixed in version 3.11.4 [1][2][3][4].
Exploitation
An attacker who can execute untrusted JavaScript inside a NodeVM instance with require enabled and a builtin configuration that includes process, inspector/promises, or the wildcard "*" can bypass the intended restrictions. Two concrete exploitation paths exist: (1) require('process').getBuiltinModule('child_process') reloads the child_process module even when it is explicitly excluded, allowing the attacker to spawn host processes via execFileSync; (2) require('inspector/promises').Session().post('Runtime.evaluate', ...) exposes the Inspector protocol and evaluates arbitrary JavaScript in the host process. A public proof-of-concept demonstrates both techniques [3][4].
Impact
Successful exploitation results in a full sandbox escape, granting the attacker arbitrary code execution in the host Node.js process. This enables reading and writing files, spawning child processes, accessing environment secrets, and potentially compromising the entire application. The attacker operates at the privilege level of the host process [3][4].
Mitigation
The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [2]. The fix adds process and inspector/promises to the dangerous builtin denylist [1]. As a workaround, ensure that require.builtin does not include process, inspector/promises, or the wildcard "*"; the default configuration (where require is disabled or no affected builtins are allowed) is not vulnerable [3][4].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <= 3.11.3
Patches
1a1ed47a98d1cfix(GHSA-rp36-8xq3-r6c4): close NodeVM builtin denylist bypass via process/inspector subpaths
4 files changed · +270 −26
CHANGELOG.md+2 −1 modified@@ -2,7 +2,7 @@ ## [3.11.4] -Seven advisories closed. Patch release — no API changes for valid configurations. +Eight advisories closed. Patch release — no API changes for valid configurations. ### Security fixes @@ -13,6 +13,7 @@ Seven advisories closed. Patch release — no API changes for valid configuratio - **GHSA-76w7-j9cq-rx2j** — Promise species hijack in the `localPromise` swallow tail. The swallow-tail `apply(globalPromisePrototypeThen, this, [...])` call inside `localPromise`'s constructor invoked the cached host `Promise.prototype.then` without first calling `resetPromiseSpecies(this)`, so a sandbox subclass overriding `[Symbol.species]` could redirect the downstream child constructor to a user function and capture V8's internal `(resolve, reject)` capability — delivering a raw host-realm error (RangeError from deep recursion + `e.stack`) to a sandbox collector and reaching the host `Function` constructor via `.constructor.constructor`. One-line fix in `lib/setup-sandbox.js` adds the missing `resetPromiseSpecies(this)` before the swallow-tail call, matching the pattern already used by the `.then`/`.catch`/`Reflect.apply` overrides. See ATTACKS.md Category 31 and `test/ghsa/GHSA-76w7-j9cq-rx2j/`. - **GHSA-m4wx-m65x-ghrr** — patch bypass of GHSA-8hg8-63c5-gwmx. The original check `options.require === false` only rejected the literal `require: false` shape; omitting `require` entirely left `options.require === undefined`, the check fell through, and the destructuring default `require: requireOpts = false` a few lines down produced the same `requireOpts = false` the original patch existed to block — inner NodeVM construction with attacker-chosen `require` config → `child_process` RCE. Structural fix in `lib/nodevm.js`: destructure first, then check `nesting === true && !requireOpts`, so every falsy/omitted shape collapses to the same construction-time `VMError`. The explicit-`require`-config escape hatch is preserved. Supersedes GHSA-8hg8-63c5-gwmx. See ATTACKS.md Category 25 and `test/ghsa/GHSA-m4wx-m65x-ghrr/`. - **GHSA-6j2x-vhqr-qr7q** — sandbox escape via WebAssembly JSPI (Node 24 behind `--experimental-wasm-jspi`, Node 26+ default). `WebAssembly.promising` returns Promise objects whose `[[Prototype]]` chain points directly at the host realm's `Promise.prototype` with no bridge proxy in between, so `p.finally()` reaches host `Promise.prototype.finally`, V8's `SpeciesConstructor` reads an attacker-controlled `p.constructor` getter, and the eventual host-realm rejection is dispatched through the attacker's class with no bridge wrapping — `e.constructor.constructor('return process')()` then evaluates in the host realm. Structural fix in `lib/setup-sandbox.js`: delete `WebAssembly.promising` and `WebAssembly.Suspending` at sandbox bootstrap, mirroring the existing `WebAssembly.JSTag` removal. Adds Defense Invariant #12 (no sandbox-visible object may have a host-realm prototype chain without bridge interposition). See ATTACKS.md Category 33 and `test/ghsa/GHSA-6j2x-vhqr-qr7q/`. +- **GHSA-rp36-8xq3-r6c4** — NodeVM builtin denylist bypass via `process` and `inspector/promises`. The exact-match denylist in `lib/builtin.js` missed two host-passthrough families: `process` (whose `getBuiltinModule(name)` reloads any core module regardless of the embedder's allow/deny configuration) and `inspector/promises` (whose `Session().post('Runtime.evaluate', ...)` evaluates attacker JS in the host realm). Structural fix promotes the check to family-prefix via `isDangerousBuiltin(key)`, strips the `node:` URL prefix, and adds `process` to the dangerous set — enforced at both `BUILTIN_MODULES` source and `addDefaultBuiltin`. Supersedes GHSA-947f-4v7f-x2v8. Adds Defense Invariant #13. See ATTACKS.md Category 21 (extended) and `test/ghsa/GHSA-rp36-8xq3-r6c4/`. ### Upgrade notes
docs/ATTACKS.md+41 −8 modified@@ -72,6 +72,8 @@ These are the cross-cutting properties the sandbox must preserve. A fix that clo 12. **No sandbox-visible object has a host-realm prototype chain without bridge interposition.** Every Promise (and, by extension, every spec-defined async dispatch target) reachable from sandbox code is either (a) sandbox-realm with `globalPromise.prototype` in its `[[Prototype]]` chain — so the sandbox-side `.then`/`.catch` overrides apply — or (b) a bridge proxy of a host-realm Promise — so the bridge `apply`-trap interception applies. A third shape (sandbox-realm allocation with a host-realm prototype, with no proxy in between) bypasses both layers: `p.then`/`.catch`/`.finally` lookup walks across realms to host native methods directly, `Object.defineProperty(p, 'constructor', ...)` writes onto the raw object, and V8's host-realm `SpeciesConstructor` dispatches the rejection through attacker-controlled species without ever invoking a sandbox-visible chokepoint. Any V8/Node primitive that produces such an object — WebAssembly JSPI is the first known one — must be neutralized at sandbox bootstrap. See [Category 33](#attack-category-33-webassembly-jspi-cross-realm-promise-prototype). +13. **The NodeVM builtin allowlist is a closed system.** No Node builtin whose own API can reload, evaluate, debug, spawn, or otherwise re-enter host code (`module`, `worker_threads`, `cluster`, `vm`, `repl`, `inspector`, `process`, `trace_events`, `wasi`) is reachable from the sandbox, regardless of how the embedder writes `builtin` — wildcard, explicit name, object syntax, low-level `makeBuiltins`. The check is family-prefix and `node:`-normalised, so subpath builtins (`inspector/promises`) and URL-style spellings (`node:process`) share fate with their canonical name. The only way to re-expose any of these names is to register a sandbox-safe wrapper through `SPECIAL_MODULES`, `mocks`, or `overrides` — i.e. the embedder must consciously opt into a stub that is not the raw host module. + The [Security Checklist for Bridge Changes](#security-checklist-for-bridge-changes) at the end of this document gives the verification questions for each invariant. --- @@ -1549,7 +1551,8 @@ NodeVM's `require.builtin` allowlist defends sandbox code from reaching dangerou - `cluster` exposes `cluster.fork()` — spawns a host child process running attacker-controlled code. - `vm` exposes `vm.runInThisContext` — evaluates code directly in the host realm, bypassing every bridge proxy. - `repl` exposes `repl.start({eval, input, output})` — constructs an interactive evaluator attached to host streams. -- `inspector` exposes the inspector protocol — attaches a debugger to the host process. +- `inspector` (and `inspector/promises`, subpath family) exposes the inspector protocol — attaches a debugger to the host process and runs `Session().post('Runtime.evaluate', { expression })` against host JS. +- `process` exposes `process.getBuiltinModule(name)` (Node 22+) — reloads ANY core module regardless of the embedder's allow/deny list — plus `process.binding(...)`, `process.dlopen(...)`, `process._linkedBinding(...)`, and the raw host `process.env`. The sandbox global `process` is a sanitized shim defined in `setup-node-sandbox.js`; the raw host module is never safe to expose. ### Attack Flow @@ -1558,10 +1561,10 @@ NodeVM's `require.builtin` allowlist defends sandbox code from reaching dangerou 3. **Sandbox calls `Module._load('child_process')`**. The bridge `apply` trap forwards to host `Module._load`, which loads `child_process` natively in the host with no vm2 check. 4. **`child_process.execSync(...)`** → host RCE. -### Canonical Example +### Canonical Examples ```javascript -// (advisory GHSA-947f-4v7f-x2v8) +// (advisory GHSA-947f-4v7f-x2v8) — `module` bypass const vm = new NodeVM({ require: { builtin: ['*', '-child_process'], external: false } }); @@ -1572,17 +1575,42 @@ vm.run(` `, 'poc.js'); ``` +```javascript +// (advisory GHSA-rp36-8xq3-r6c4) — `process.getBuiltinModule` bypass +const vm = new NodeVM({ + require: { builtin: ['*', '-child_process', '-inspector'], external: false } +}); +vm.run(` + const cp = require('process').getBuiltinModule('child_process'); + module.exports = cp.execFileSync('/bin/sh', ['-c', 'id']).toString(); +`, 'poc.js'); +``` + +```javascript +// (advisory GHSA-rp36-8xq3-r6c4) — `inspector/promises` subpath bypass +const vm = new NodeVM({ + require: { builtin: ['*', '-child_process', '-inspector'], external: false } +}); +vm.run(` + const { Session } = require('inspector/promises'); + const s = new Session(); + s.connect(); + s.post('Runtime.evaluate', { expression: '/* runs in host realm */' }); +`, 'poc.js'); +``` + ### Why It Works The user's mental model of `['*', '-child_process']` is "every builtin except `child_process`". That model assumes every builtin is either fully sandboxed or fully blocked — but `module` (and its peers above) are neither. They're *meta-builtins* that load other builtins by name. The generic `vm.readonly()` wrapper cannot make them safe because the sandbox-bypass primitive is the very thing the user is calling. ### Mitigation -Two-layer denylist enforcement in `lib/builtin.js`: +Three-layer denylist enforcement in `lib/builtin.js` (restores **[Invariant 13 — The NodeVM builtin allowlist is a closed system](#defense-invariants)**): -1. **`DANGEROUS_BUILTINS` Set** at module load — `['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', 'trace_events', 'wasi']`. -2. **Filter from `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `'*'` will never auto-allow these names regardless of the user's exclusion list. -3. **Reject in `addDefaultBuiltin`** — closes the explicit-allowlist path (`builtin: ['module']`) and the lower-level `makeBuiltins(['module'])` API used by custom resolvers. The `SPECIAL_MODULES` escape hatch is preserved: a future safe wrapper (e.g., a `module` shim that exposes only `builtinModules` metadata) can be registered there if a real consumer needs it. +1. **`DANGEROUS_BUILTINS` Set** at module load — `['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', 'process', 'trace_events', 'wasi']`. +2. **Family-prefix check** via `isDangerousBuiltin(key)` — any `<family>/...` whose family is in the denylist is also blocked (e.g. `inspector/promises`, future `inspector/foo`, hypothetical `process/foo`, `module/foo`). The check also strips the optional `node:` URL-style prefix so `node:process` and `node:inspector/promises` are caught. +3. **Filter from `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `'*'` will never auto-allow these names regardless of the user's exclusion list. +4. **Reject in `addDefaultBuiltin`** — closes the explicit-allowlist path (`builtin: ['module']`, `builtin: ['process']`, `builtin: ['inspector/promises']`) and the lower-level `makeBuiltins([...])` API used by custom resolvers. The `SPECIAL_MODULES` escape hatch is preserved: a future safe wrapper (e.g. a `module` shim that exposes only `builtinModules` metadata) can be registered there if a real consumer needs it. The fix does not affect the `mocks` / `overrides` escape hatches — users who genuinely need a stub for one of these names can register a sandbox-safe replacement. @@ -1591,6 +1619,8 @@ The fix does not affect the `mocks` / `overrides` escape hatches — users who g - **`trace_events.createTracing({categories: [...]})`** asserts `args[0]->IsArray()` in V8 C++. The array crosses the bridge as a Proxy, the `IsArray()` check fails, and the entire host process aborts. Reachable as ~150 bytes from sandbox under `builtin: ['*']` — not RCE, but a host-process-DoS primitive of the same severity class as Category 22. - **`wasi`** exposes the WebAssembly System Interface preview1 syscall surface (filesystem `preopens`, host clock/random, network if preopened). The API is experimental and broad; even a misconfigured `preopens: {}` exposes the host CWD when sandbox code constructs a WASI module. +**Supersedes**: the previous GHSA-947f-4v7f-x2v8 mitigation, which used an exact-match denylist and missed `process` and subpath builtins such as `inspector/promises`. The family-prefix check subsumes the prior fix and forecloses every same-shape variant. + ### Detection Rules - **`builtin: ['*']` or `['*', '-X']`** in NodeVM config — historically allowed `module`/`worker_threads`/`cluster`/`vm`/`repl`/`inspector`/`trace_events`/`wasi`, now safely filtered. **Note: `'*'` still allows `child_process`, `fs`, `dgram`, `net`, `http`, `dns`, etc. — it is NOT a sandbox-safe default for untrusted code.** @@ -1599,7 +1629,9 @@ The fix does not affect the `mocks` / `overrides` escape hatches — users who g - **`cluster.fork()`** — host process spawn. - **`vm.runInThisContext(...)`** — host-realm `eval`. - **`repl.start({eval, ...})`** — host-realm REPL evaluator. -- **`inspector.open()`** — debugger attachment to host process. +- **`inspector.open()`** or **`new (require('inspector/promises').Session)()`** — debugger attachment / `Runtime.evaluate` host-realm code execution. +- **`require('process').getBuiltinModule(name)`** — reloads any core module bypassing the allow/deny list. +- **`require('process').binding('spawn_sync')` / `.dlopen(module, path)`** — raw C++ binding surface and native add-on loader. - **`trace_events.createTracing({categories: [...]})`** — host process abort via C++ assertion failure. - **`new (require('wasi').WASI)({...})`** — preview1 syscall surface. @@ -2889,6 +2921,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Bridge-internal container via `Array.prototype[N]` setter (Category 28: GHSA-9qj6-qjgg-37qq Variant A + GHSA-q3fm-4wcw-g57x Variant B) | Variant A — `neutralizeArraySpeciesBatch` in `lib/bridge.js` writes saved entries via `thisReflectDefineProperty`; appended slot is an own data property and no sandbox-installed setter is invoked while the bridge holds raw saved state. Variant B — `defaultSandboxPrepareStackTrace` in `lib/setup-sandbox.js` accumulates frames in a string via primitive concatenation rather than an array, removing every reachable `Array.prototype` slot (index setter, getter, and `.join`); `makeCallSiteGetters` installs entries via `localReflectDefineProperty` for symmetry | | Host prototype mutation via apply trap (GHSA-v6mx-mf47-r5wg) | Apply trap caches the host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `__defineSetter__`, `__defineGetter__`) in `dangerousHostProtoMutators` and refuses any invocation reaching them — direct or via one-layer indirection through `Function.prototype.{call,apply,bind}` / `Reflect.{apply,construct}`. Read-side defense-in-depth in `thisEnsureThis` cache-checks `mappingOtherToThis` before the proto-walk so any previously-bridged host value returns the existing proxy even when its prototype chain has been tampered with by some other route. | | Bridge `set` trap ignores spec `Receiver` (GHSA-c4cf-2hgv-2qv6) | `BaseHandler.set` gates host-write forwarding on `receiver === mappingOtherToThis.get(object)`; non-canonical receivers (inherited-receiver writes via `Object.create(proxy)`, forged-receiver `Reflect.set` calls, `Object.assign(child, src)` loops) install on `receiver` via `Reflect.defineProperty`, mirroring `ReadOnlyHandler.set` | +| NodeVM builtin denylist bypass via `process` / `inspector/promises` (GHSA-rp36-8xq3-r6c4) | `DANGEROUS_BUILTINS` extended to include `process`; matching promoted to family-prefix via `isDangerousBuiltin(key)` so subpath builtins (`inspector/promises`, future `inspector/*`, `process/*`, `module/*`) share fate with their canonical name. `node:` URL prefix stripped before lookup. Enforced at both `BUILTIN_MODULES` source and `addDefaultBuiltin`. Supersedes the GHSA-947f-4v7f-x2v8 exact-match mitigation. | ### Key Security Invariant: Promise Species Resolution Timing
lib/builtin.js+52 −17 modified@@ -37,10 +37,11 @@ function defaultBuiltinLoaderUtil(vm) { return vm.readonly(copy); } -// SECURITY (GHSA-947f-4v7f-x2v8): Some Node builtins are sandbox-bypass primitives -// by design -- their primary capability is to reach host code regardless of the -// vm2 builtin allowlist. They must NEVER be reachable from the sandbox, even when -// the user requests `'*'` or explicitly names them in `builtin`. +// SECURITY (GHSA-947f-4v7f-x2v8, GHSA-rp36-8xq3-r6c4): Some Node builtins are +// sandbox-bypass primitives by design -- their primary capability is to reach +// host code regardless of the vm2 builtin allowlist. They must NEVER be +// reachable from the sandbox, even when the user requests `'*'` or explicitly +// names them in `builtin`. // // - module : exposes `Module._load`, `Module._resolveFilename`, // `Module._cache`, `createRequire` -- loads ANY host @@ -55,20 +56,38 @@ function defaultBuiltinLoaderUtil(vm) { // attached to host streams; low utility for sandboxed // code, high host-RCE potential. // - inspector : the inspector protocol can attach a debugger to the -// host process, exposing arbitrary host state. +// host process, exposing arbitrary host state. Covers +// the subpath family `inspector/promises` as well. +// - process : `process.getBuiltinModule(name)` (Node 22+) reloads +// ANY core module regardless of the embedder's +// allow/deny configuration. `process.binding`, +// `process.dlopen`, `process._linkedBinding`, and the +// raw host `process.env` are equally fatal. The +// sandbox global `process` is a sanitized shim defined +// in `setup-node-sandbox.js`; `require('process')` +// returns the raw host module and is never safe. // // This denylist is enforced at the `BUILTIN_MODULES` source (so the `'*'` // wildcard never expands to them) AND inside `addDefaultBuiltin` (so explicit // `builtin: ['module']` / `makeBuiltins(['module'])` requests are rejected). // `SPECIAL_MODULES` and `overrides` can still register safe replacements under // these names if a user genuinely needs one. +// +// Matching is family-based: any builtin whose path is `<family>/...` where +// `<family>` is listed below is also blocked. This covers +// `inspector/promises` today and any future subpath such as +// `inspector/foo`, `process/foo`, `module/foo`. The `node:` URL-style +// prefix is stripped before matching so neither `require('node:process')` +// nor `require('node:inspector/promises')` can bypass via the alternative +// spelling. const DANGEROUS_BUILTINS = new Set([ 'module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector', + 'process', // Host-process abort DoS: `trace_events.createTracing({categories: [...]})` // asserts `args[0]->IsArray()` in C++; the array crosses the bridge as a // Proxy, which fails the assertion and aborts the entire host process. @@ -83,8 +102,21 @@ const DANGEROUS_BUILTINS = new Set([ 'wasi' ]); +// SECURITY (GHSA-rp36-8xq3-r6c4): Family-prefix denylist check. `inspector` and +// `inspector/promises` must share fate; same for any future subpath under a +// dangerous family. Also strips the `node:` URL-style prefix so +// `node:process` and `node:inspector/promises` cannot bypass via spelling. +function isDangerousBuiltin(key) { + if (typeof key !== 'string') return false; + if (key.startsWith('node:')) key = key.slice(5); + if (DANGEROUS_BUILTINS.has(key)) return true; + const slash = key.indexOf('/'); + if (slash > 0 && DANGEROUS_BUILTINS.has(key.slice(0, slash))) return true; + return false; +} + const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))) - .filter(s=>!s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s)); + .filter(s=>!s.startsWith('internal/') && !isDangerousBuiltin(s)); let EventEmitterReferencingAsyncResourceClass = null; if (EventEmitter.EventEmitterAsyncResource) { @@ -133,17 +165,20 @@ const SPECIAL_MODULES = { function addDefaultBuiltin(builtins, key, hostRequire) { if (builtins.has(key)) return; const special = SPECIAL_MODULES[key]; - // SECURITY (GHSA-947f-4v7f-x2v8): Defense-in-depth. Reject sandbox-bypass - // primitives even when the caller explicitly names them (e.g. - // `builtin: ['module']` or `makeBuiltins(['worker_threads'])`). A non-special - // dangerous builtin would otherwise be wrapped in a readonly proxy whose - // `apply` trap forwards every method call to the host realm -- handing the - // sandbox a primitive that loads ANY other builtin (`Module._load`), - // spawns processes (`cluster.fork`), runs unsandboxed code - // (`new Worker(src, {eval:true})`), or evaluates host-realm code - // (`vm.runInThisContext`). The `SPECIAL_MODULES` escape hatch above is - // still honoured -- a future safe wrapper can be registered there. - if (!special && DANGEROUS_BUILTINS.has(key)) return; + // SECURITY (GHSA-947f-4v7f-x2v8, GHSA-rp36-8xq3-r6c4): Defense-in-depth. + // Reject sandbox-bypass primitives even when the caller explicitly names + // them (e.g. `builtin: ['module']`, `builtin: ['process']`, + // `makeBuiltins(['inspector/promises'])`). A non-special dangerous builtin + // would otherwise be wrapped in a readonly proxy whose `apply` trap + // forwards every method call to the host realm -- handing the sandbox a + // primitive that loads ANY other builtin (`Module._load`, + // `process.getBuiltinModule`), spawns processes (`cluster.fork`), runs + // unsandboxed code (`new Worker(src, {eval:true})`, + // `inspector/promises Session.post('Runtime.evaluate')`), or evaluates + // host-realm code (`vm.runInThisContext`). The `SPECIAL_MODULES` escape + // hatch above is still honoured -- a future safe wrapper can be + // registered there. + if (!special && isDangerousBuiltin(key)) return; builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key))); }
test/ghsa/GHSA-rp36-8xq3-r6c4/repro.js+175 −0 added@@ -0,0 +1,175 @@ +'use strict'; + +/** + * GHSA-rp36-8xq3-r6c4 — NodeVM builtin denylist bypass via `process` and `inspector/promises` + * + * ## Vulnerability + * + * The DANGEROUS_BUILTINS denylist in `lib/builtin.js` blocks `inspector`, + * `module`, `worker_threads`, `cluster`, `vm`, `repl`, `trace_events`, `wasi`. + * + * Two families are missed: + * - `process` — exposes `process.getBuiltinModule(name)`, which + * reloads ANY core module (including ones the + * embedder explicitly excluded with `-name`), + * plus `process.binding(...)`, `process.dlopen(...)`, + * and the host `process.env`. + * - `inspector/promises` — exact-match denylist on `inspector` does not + * cover subpath builtins; `Session().post( + * 'Runtime.evaluate', { expression })` evaluates + * attacker JS in the host realm. + * + * Both are reachable as soon as the embedder allows them — typically via + * `builtin: ['*']` or an explicit allow-list that does not enumerate the + * subpath variant. + * + * ## Fix + * + * 1. Add `process` to DANGEROUS_BUILTINS (host process module is never safe + * to expose; it carries `getBuiltinModule`, `binding`, `dlopen`, etc.). + * 2. Treat the denylist as a *family* check: any key whose `<family>/...` + * prefix names a dangerous builtin is also blocked. This covers + * `inspector/promises`, future `inspector/*` subpaths, and any future + * subpaths under other dangerous families (`module/*`, `vm/*`, ...). + * 3. Normalize the optional `node:` prefix before checking, so + * `require('node:process')` and `require('node:inspector/promises')` + * cannot bypass via the alternative spelling. + */ + +const assert = require('assert'); +const {NodeVM} = require('../../../'); + +describe('GHSA-rp36-8xq3-r6c4 — NodeVM builtin denylist bypass', () => { + + function makeVm() { + return new NodeVM({ + require: { + external: false, + // The advisory's threat model: embedder allows the broad builtin + // surface and tries to surgically subtract the obviously-dangerous + // pieces. The denylist must hold even under '*'. + builtin: ['*'] + } + }); + } + + function expectBlocked(vm, expr, label) { + const result = vm.run(` + try { + const m = ${expr}; + module.exports = { ok: false, type: typeof m }; + } catch (e) { + module.exports = { ok: true, message: e && e.message }; + } + `); + assert.strictEqual(result.ok, true, + `${label}: expected require to be blocked, but got module of type ${result.type}`); + } + + it('require("process") is blocked', () => { + expectBlocked(makeVm(), "require('process')", "require('process')"); + }); + + it('require("node:process") is blocked', () => { + expectBlocked(makeVm(), "require('node:process')", "require('node:process')"); + }); + + it('require("inspector/promises") is blocked', () => { + expectBlocked(makeVm(), "require('inspector/promises')", "require('inspector/promises')"); + }); + + it('require("node:inspector/promises") is blocked', () => { + expectBlocked(makeVm(), "require('node:inspector/promises')", "require('node:inspector/promises')"); + }); + + it('require("inspector") remains blocked (regression guard)', () => { + expectBlocked(makeVm(), "require('inspector')", "require('inspector')"); + }); + + it('process.getBuiltinModule bypass to child_process does not return a usable module', () => { + const vm = new NodeVM({ + require: { + external: false, + builtin: ['*', '-child_process', '-inspector'] + } + }); + const result = vm.run(` + let processBlocked = false; + let bypassReached = false; + let execSyncReachable = false; + try { + const p = require('process'); + try { + const cp = p.getBuiltinModule('child_process'); + bypassReached = true; + execSyncReachable = typeof cp.execSync === 'function'; + } catch (e) {} + } catch (e) { + processBlocked = true; + } + module.exports = { processBlocked, bypassReached, execSyncReachable }; + `); + assert.strictEqual(result.processBlocked, true, + "require('process') must be blocked so the getBuiltinModule pivot cannot run"); + assert.strictEqual(result.bypassReached, false, + "sandbox code must not reach process.getBuiltinModule()"); + assert.strictEqual(result.execSyncReachable, false, + "sandbox code must not obtain a callable host child_process.execSync"); + }); + + it('inspector/promises Session().post("Runtime.evaluate") is not reachable', () => { + const vm = new NodeVM({ + require: { + external: false, + builtin: ['*', '-child_process', '-inspector'] + } + }); + const result = vm.run(` + let inspectorBlocked = false; + let sessionType = null; + try { + const ip = require('inspector/promises'); + sessionType = typeof ip.Session; + } catch (e) { + inspectorBlocked = true; + } + module.exports = { inspectorBlocked, sessionType }; + `); + assert.strictEqual(result.inspectorBlocked, true, + "require('inspector/promises') must be blocked"); + assert.strictEqual(result.sessionType, null, + "sandbox must not obtain inspector/promises Session"); + }); + + it('explicit allow-list naming a dangerous builtin is rejected', () => { + // makeBuiltins([...]) path: even if the embedder writes + // builtin: ['process'] explicitly, the denylist must still hold. + const vm = new NodeVM({ + require: { + external: false, + builtin: ['process', 'inspector/promises'] + } + }); + expectBlocked(vm, "require('process')", "explicit process allowlist"); + expectBlocked(vm, "require('inspector/promises')", "explicit inspector/promises allowlist"); + }); + + it('safe builtins still load under "*"', () => { + // Regression guard: the family-prefix check must not break sibling + // builtins like fs/promises, dns/promises, stream/promises, etc. + const vm = makeVm(); + const result = vm.run(` + const fsp = require('fs/promises'); + const dnsp = require('dns/promises'); + const sp = require('stream/promises'); + module.exports = { + fsp: typeof fsp.readFile, + dnsp: typeof dnsp.lookup, + sp: typeof sp.pipeline + }; + `); + assert.strictEqual(result.fsp, 'function'); + assert.strictEqual(result.dnsp, 'function'); + assert.strictEqual(result.sp, 'function'); + }); +});
Vulnerability mechanics
Root cause
"The `DANGEROUS_BUILTINS` denylist in `lib/builtin.js` used exact-match checks and omitted `process` and `inspector/promises`, allowing sandboxed code to require these modules and reach host-side execution primitives."
Attack vector
An attacker who can run untrusted JavaScript inside a `NodeVM` instance where `require.builtin` includes `'*'` or explicitly allows `process` or `inspector/promises` can escape the sandbox [ref_id=2]. Two bypass paths exist: `require('process').getBuiltinModule('child_process')` reloads any core module regardless of the embedder's allow/deny configuration, and `require('inspector/promises').Session().post('Runtime.evaluate', ...)` evaluates attacker JavaScript in the host realm [ref_id=1]. Both reach host RCE under configurations such as `builtin: ['*', '-child_process']` [patch_id=3104224].
Affected code
The vulnerability is in `lib/builtin.js` where the `DANGEROUS_BUILTINS` denylist used exact-match checks and omitted `process` and `inspector/promises`. The `addDefaultBuiltin` function and `BUILTIN_MODULES` filter both relied on this incomplete set, allowing sandboxed code to `require()` these modules and reach host-side execution primitives.
What the fix does
The patch adds `process` to the `DANGEROUS_BUILTINS` set and introduces a new `isDangerousBuiltin(key)` function that performs family-prefix matching — any builtin whose path is `<family>/...` where `<family>` is in the denylist is also blocked, covering `inspector/promises` and future subpath variants [patch_id=3104224]. The check also strips the optional `node:` URL-style prefix before matching so `node:process` and `node:inspector/promises` cannot bypass via alternative spelling. This enforcement is applied at both the `BUILTIN_MODULES` source (closing the `'*'` wildcard expansion path) and inside `addDefaultBuiltin` (closing explicit allowlist paths) [ref_id=1].
Preconditions
- configThe NodeVM configuration must have require.builtin set to '*' or explicitly include 'process' or 'inspector/promises'.
- inputThe attacker must be able to supply and execute arbitrary JavaScript code inside the NodeVM sandbox.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.