GHSA-947f-4v7f-x2v8: vm2 has a NodeVM builtin allowlist bypass via `module` builtin's `Module._load` that allows sandbox escape
SummaryNodeVM'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 hostAffected Component`lib/builtin.js` — `makeBuiltinsFromLegacyOptions()` (lines 109-117) — includes `module` in `'*'` expansion`lib/builtin.js` — `addDefaultBuiltin()` (lines 86-90) — loads `module` with generic readonly wrapper`lib/builtin.js` — `SPECIAL_MODULES` (line 61) — does NOT include `module`CWE**CWE-863**: Incorrect AuthorizationDescriptionRoot Cause: The `module` builtin provides unrestricted host module loadingWhen `builtin: ['*', '-child_process']` is configured, `makeBuiltinsFromLegacyOptions` iterates over `BUILTIN_MODULES` and adds all modules not explicitly excluded: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: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: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 notThe 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 versionBut `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 chainHost configures `NodeVM` with `builtin: ['*', '-child_process']``makeBuiltinsFromLegacyOptions` adds `'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's `Module` classSandbox reads `Module._load` → `BaseHandler.get()` returns proxied functionSandbox calls `Module._load('child_process')` → `BaseHandler.apply()` forwards to hostHost's `Module._load` loads `child_process` natively (no vm2 check involved)`child_process` module proxied back to sandboxSandbox calls `child_process.execSync('id')` → executes on host → RCEProof of ConceptImpact**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 RemediationOption 1: Exclude `module` from `BUILTIN_MODULES` entirely (Preferred)The `module` builtin provides unrestricted host module loading and should never be exposed to the sandbox: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 wrapperIf `module` must be accessible, provide a sandbox-safe version that only exposes safe APIs:**Tradeoff**: Breaks sandbox code that legitimately uses `Module` APIs, but those APIs are inherently unsafe in a sandbox context.CreditThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).
CVSS v3.1: 9.9