Skip to content

Commit cc15af4

Browse files
committed
fix(GHSA-947f-4v7f-x2v8): deny host-passthrough builtins from NodeVM allowlist
Six Node builtins expose primitives whose primary capability is "reach host code regardless of the sandbox boundary": - module: Module._load loads any builtin/external ignoring allowlist - worker_threads: new Worker(src, {eval:true}) runs unsandboxed JS - cluster: cluster.fork() spawns host child process - vm: vm.runInThisContext evaluates in host realm - repl: repl.start() = interactive host evaluator - inspector: inspector.open() attaches debugger to host process Users wrote `builtin: ['*', '-child_process']` expecting "everything except child_process", but `*` expanded to include `module`, and ReadOnlyHandler's apply trap forwarded Module._load straight to the host — bypassing the exclusion. Two-layer denylist in lib/builtin.js: - DANGEROUS_BUILTINS Set excluded from BUILTIN_MODULES (closes the `*` wildcard path). - addDefaultBuiltin rejects DANGEROUS_BUILTINS even for explicit `builtin: ['module']` configs and the lower-level makeBuiltins API used by custom resolvers. SPECIAL_MODULES, mocks, and overrides escape hatches are preserved. Non-dangerous builtins (fs, events, path, buffer, util, etc.) continue to load normally.
1 parent a17d5cc commit cc15af4

3 files changed

Lines changed: 448 additions & 1 deletion

File tree

docs/ATTACKS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,6 +1458,72 @@ The protected set is captured *before* any sandbox code runs, and is keyed on ra
14581458
14591459
---
14601460
1461+
## Attack Category 21: NodeVM Builtin Allowlist Bypass via Host-Passthrough Builtins
1462+
1463+
### Description
1464+
1465+
NodeVM's `require.builtin` allowlist defends sandbox code from reaching dangerous Node modules (`child_process`, `fs`, etc.). The allowlist is enforced by `lib/builtin.js` — when sandbox code calls `require(name)`, the resolver consults the allowlist and only loads modules the user opted in to. **However**, several Node builtins themselves expose primitives whose primary capability is "reach host code regardless of the sandbox boundary". When such a builtin is on the allowlist (or, more commonly, included by the `'*'` wildcard), it becomes a single-line allowlist bypass:
1466+
1467+
- `module` exposes `Module._load(name)`, `Module._resolveFilename`, `Module._cache`, `createRequire` — all of which load any host builtin or external module ignoring vm2's allowlist.
1468+
- `worker_threads` exposes `new Worker(src, {eval: true})` — runs arbitrary JS in a fresh thread that has no vm2 sandbox at all.
1469+
- `cluster` exposes `cluster.fork()` — spawns a host child process running attacker-controlled code.
1470+
- `vm` exposes `vm.runInThisContext` — evaluates code directly in the host realm, bypassing every bridge proxy.
1471+
- `repl` exposes `repl.start({eval, input, output})` — constructs an interactive evaluator attached to host streams.
1472+
- `inspector` exposes the inspector protocol — attaches a debugger to the host process.
1473+
1474+
### Attack Flow
1475+
1476+
1. **Allowlist includes a host-passthrough builtin** (most commonly because the user wrote `builtin: ['*', '-child_process']` and `'*'` expanded to include `'module'`).
1477+
2. **Sandbox calls `require('module')`**. NodeVM's resolver finds `'module'` in `BUILTIN_MODULES`, calls `addDefaultBuiltin` which loads it via `vm.readonly(hostRequire('module'))`. The `ReadOnlyHandler` proxy blocks mutation traps but *not* `apply`/`get` — calling methods on the proxy still forwards them to the host realm.
1478+
3. **Sandbox calls `Module._load('child_process')`**. The bridge `apply` trap forwards to host `Module._load`, which loads `child_process` natively in the host with no vm2 check.
1479+
4. **`child_process.execSync(...)`** → host RCE.
1480+
1481+
### Canonical Example
1482+
1483+
```javascript
1484+
// (advisory GHSA-947f-4v7f-x2v8)
1485+
const vm = new NodeVM({
1486+
require: { builtin: ['*', '-child_process'], external: false }
1487+
});
1488+
vm.run(`
1489+
const Module = require('module');
1490+
const cp = Module._load('child_process'); // bypasses '-child_process' exclusion
1491+
module.exports = cp.execSync('id').toString();
1492+
`, 'poc.js');
1493+
```
1494+
1495+
### Why It Works
1496+
1497+
The user's mental model of `['*', '-child_process']` is "every builtin except `child_process`". That model assumes every builtin is either fully sandboxed or fully blocked — but `module` (and its peers above) are neither. They're *meta-builtins* that load other builtins by name. The generic `vm.readonly()` wrapper cannot make them safe because the sandbox-bypass primitive is the very thing the user is calling.
1498+
1499+
### Mitigation
1500+
1501+
Two-layer denylist enforcement in `lib/builtin.js`:
1502+
1503+
1. **`DANGEROUS_BUILTINS` Set** at module load — `['module', 'worker_threads', 'cluster', 'vm', 'repl', 'inspector']`.
1504+
2. **Filter from `BUILTIN_MODULES`** — closes the `'*'` wildcard expansion path. `'*'` will never auto-allow these names regardless of the user's exclusion list.
1505+
3. **Reject in `addDefaultBuiltin`** — closes the explicit-allowlist path (`builtin: ['module']`) and the lower-level `makeBuiltins(['module'])` API used by custom resolvers. The `SPECIAL_MODULES` escape hatch is preserved: a future safe wrapper (e.g., a `module` shim that exposes only `builtinModules` metadata) can be registered there if a real consumer needs it.
1506+
1507+
The fix does not affect the `mocks` / `overrides` escape hatches — users who genuinely need a stub for one of these names can register a sandbox-safe replacement.
1508+
1509+
### Detection Rules
1510+
1511+
- **`builtin: ['*']` or `['*', '-X']`** in NodeVM config — historically allowed `module`/`worker_threads`/`cluster`/`vm`/`repl`/`inspector`, now safely filtered.
1512+
- **`require('module')._load(...)`** — the canonical bypass primitive.
1513+
- **`new Worker(src, {eval:true})`** — out-of-band code execution.
1514+
- **`cluster.fork()`** — host process spawn.
1515+
- **`vm.runInThisContext(...)`** — host-realm `eval`.
1516+
- **`repl.start({eval, ...})`** — host-realm REPL evaluator.
1517+
- **`inspector.open()`** — debugger attachment to host process.
1518+
1519+
### Considered Attack Surfaces
1520+
1521+
- **`async_hooks`** exposes context tracing but not host-code-loading primitives. Allowed under `'*'`.
1522+
- **`child_process`** is the canonical "user knows to exclude this" builtin — not on the auto-denylist because users may legitimately want it for trusted scripts. The `'*'` wildcard with no exclusion still allows it.
1523+
- **`fs`** is allowed under `'*'` because file-system access can be a legitimate sandbox capability for many use cases (e.g., user-script template engines reading templates). Users who want filesystem isolation use `VMFileSystem` or exclude `fs` explicitly.
1524+
1525+
---
1526+
14611527
## Considered Attack Surfaces
14621528
14631529
These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them.

lib/builtin.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,42 @@ function defaultBuiltinLoaderUtil(vm) {
3737
return vm.readonly(copy);
3838
}
3939

40-
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives'))).filter(s=>!s.startsWith('internal/'));
40+
// SECURITY (GHSA-947f-4v7f-x2v8): Some Node builtins are sandbox-bypass primitives
41+
// by design -- their primary capability is to reach host code regardless of the
42+
// vm2 builtin allowlist. They must NEVER be reachable from the sandbox, even when
43+
// the user requests `'*'` or explicitly names them in `builtin`.
44+
//
45+
// - module : exposes `Module._load`, `Module._resolveFilename`,
46+
// `Module._cache`, `createRequire` -- loads ANY host
47+
// builtin or external module bypassing the allowlist.
48+
// - worker_threads : `new Worker(code, {eval: true})` runs arbitrary JS in
49+
// a fresh thread that has no vm2 sandbox at all.
50+
// - cluster : `cluster.fork()` spawns a host child process running
51+
// attacker-controlled code.
52+
// - vm : `vm.runInThisContext` evaluates code in the host realm,
53+
// bypassing every bridge proxy.
54+
// - repl : `repl.start()` constructs an interactive evaluator
55+
// attached to host streams; low utility for sandboxed
56+
// code, high host-RCE potential.
57+
// - inspector : the inspector protocol can attach a debugger to the
58+
// host process, exposing arbitrary host state.
59+
//
60+
// This denylist is enforced at the `BUILTIN_MODULES` source (so the `'*'`
61+
// wildcard never expands to them) AND inside `addDefaultBuiltin` (so explicit
62+
// `builtin: ['module']` / `makeBuiltins(['module'])` requests are rejected).
63+
// `SPECIAL_MODULES` and `overrides` can still register safe replacements under
64+
// these names if a user genuinely needs one.
65+
const DANGEROUS_BUILTINS = new Set([
66+
'module',
67+
'worker_threads',
68+
'cluster',
69+
'vm',
70+
'repl',
71+
'inspector'
72+
]);
73+
74+
const BUILTIN_MODULES = (nmod.builtinModules || Object.getOwnPropertyNames(process.binding('natives')))
75+
.filter(s=>!s.startsWith('internal/') && !DANGEROUS_BUILTINS.has(s));
4176

4277
let EventEmitterReferencingAsyncResourceClass = null;
4378
if (EventEmitter.EventEmitterAsyncResource) {
@@ -86,6 +121,17 @@ const SPECIAL_MODULES = {
86121
function addDefaultBuiltin(builtins, key, hostRequire) {
87122
if (builtins.has(key)) return;
88123
const special = SPECIAL_MODULES[key];
124+
// SECURITY (GHSA-947f-4v7f-x2v8): Defense-in-depth. Reject sandbox-bypass
125+
// primitives even when the caller explicitly names them (e.g.
126+
// `builtin: ['module']` or `makeBuiltins(['worker_threads'])`). A non-special
127+
// dangerous builtin would otherwise be wrapped in a readonly proxy whose
128+
// `apply` trap forwards every method call to the host realm -- handing the
129+
// sandbox a primitive that loads ANY other builtin (`Module._load`),
130+
// spawns processes (`cluster.fork`), runs unsandboxed code
131+
// (`new Worker(src, {eval:true})`), or evaluates host-realm code
132+
// (`vm.runInThisContext`). The `SPECIAL_MODULES` escape hatch above is
133+
// still honoured -- a future safe wrapper can be registered there.
134+
if (!special && DANGEROUS_BUILTINS.has(key)) return;
89135
builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
90136
}
91137

0 commit comments

Comments
 (0)