Sandbox Escapes in vm2 3.11.7: Six Boundary Failures in One Patch
Teams that execute untrusted JavaScript inside a Node.js process often reach for vm2. The library makes one narrow promise: a NodeVM runs untrusted code, and the embedder decides which builtins and packages that code can reach. A group of advisories published on 1 October 2026 shows six separate ways that decision was lost, and all six are fixed in vm2 3.11.7.
The affected ranges differ between reports, and two of them depend on Node.js behaviour that changed inside the 24.x and 26.x lines. That makes the patch level and the runtime version worth checking together rather than separately.
The reports, grouped by mechanism
Three of the six failures come from builtin policy being matched as text.
CVE-2026-92957 concerns negative builtin entries written with a scheme prefix. An embedder that writes builtin: ['*', '-node:child_process'] expects the denial to remove the process-spawning module. The check in lib/builtin.js compares the negative entry against canonical names such as child_process, so -node:child_process matches nothing, and require('child_process') inside the sandbox returns the host module with execSync and spawn attached.
CVE-2026-92958 covers the same policy surface from the other direction. A configuration that denies fs and child_process does not deny fs/promises, because wildcard expansion treats the subpath as a separate name. Sandboxed code can then create and write files on the host through the promise-based filesystem API.
CVE-2026-92948 involves a doubled prefix. On Node.js 24 and newer, module.builtinModules includes the scheme-only key node:test. When an embedder allows node:test explicitly, the generic loader stores a read-only proxy to the host module, and requireImpl() strips one node: prefix before lookup. Sandbox code requesting node:node:test is normalised onto the stored key and receives the proxy. Calls to node:test.run() forward the caller's execArgv to a newly spawned Node process, so --eval= runs JavaScript outside the sandbox. The advisory records that direct sandbox imports of fs, child_process, module and process stayed denied in the proof.
Two others come from state that the read-only wrapper does not isolate.
CVE-2026-92941 allows a sandbox to replace the host thread's default certificate authorities. The tls builtin is delivered through a recursive read-only bridge, which blocks ordinary property assignment but does not make tls.setDefaultCACertificates() sandbox-local. The proof builds a real host-realm array through URLSearchParams.getAll() from the permitted url module, because the native TLS function requires a host array rather than a mapped sandbox value. After the call, subsequent host HTTPS clients accept a certificate signed by the attacker's CA. The API is present in Node.js 22.19.0 and later within 22.x, and 24.5.0 and later within 24.x.
CVE-2026-92944 is the runtime-dependent one. It affects vm2 on Node.js 26, where V8's SetPrototypeProperties path updates the existing Promise.prototype.then and catch data properties without invalidating the PromiseThenLookupChain protector. Promise.prototype.finally() then trusts the stale protector and takes an InvokeThen fast path that calls the original native then, bypassing vm2's wrapper and its resetPromiseSpecies() hardening. An attacker-controlled species constructor supplies the resolve and reject functions, and a calibrated stack overflow at that native reaction boundary returns a host-realm RangeError whose constructor chain reaches the host Function and process. The reporter tested vm2 3.10.2 through 3.11.5 on Node.js 26.7.0 and recorded the same result on each, while Node.js 22.23.2, 24.19.0 and 25.9.0 were reported safe with stock vm2 3.11.5.
CVE-2026-92951 moves from builtins to packages. When NodeVM combines an external allowlist with a custom resolve callback, the requested package name is compared with a non-exact match. An allowlist containing only left-pad therefore accepts evil-left-pad, and a colliding package already present in a resolvable host path is loaded.
What the pattern says
Five of the six failures share a root shape. A policy is written as a string list, and enforcement compares strings while the runtime resolves modules, paths and process-wide state by different rules. Prefix handling, subpath expansion, negative-entry spelling and package-name matching all belong to that gap. CVE-2026-92941 sits slightly apart: the policy was honoured, but the allowed module still reached process-wide state that the read-only wrapper does not mediate.
The practical reading is that an allowlist is only as strong as the normalisation applied to both sides of the comparison.
Defensive implications
The immediate action is to move to vm2 3.11.7 and to check which Node.js line the host runs, because CVE-2026-92944 needs Node.js 26 to be reachable and CVE-2026-92948 needs Node.js 24 or newer.
Where an upgrade cannot ship at once, three reductions are available. Deny dangerous builtins by canonical name and by scheme-prefixed name, and treat fs, fs/promises, child_process, tls, module, process and node:test as a single decision rather than several. Avoid custom resolve callbacks in front of an external allowlist, and use exact package identifiers. Prefer process-level isolation over an in-process sandbox for code from outside the trust boundary.
Verification matters more than configuration review here. A short probe that attempts require('fs/promises'), require('child_process'), require('node:child_process') and a node:node:test lookup inside the sandbox will show whether the policy is enforced, and it is a more reliable signal than reading the option object.
References
- [1] GHSA-qhwx-74w5-xhxq, vm2 NodeVM builtin allowlist bypass via node:test.run() execArgv (CVE-2026-92948). https://github.com/advisories/GHSA-qhwx-74w5-xhxq
- [2] GHSA-8686-vhfx-7r3j, vm2 NodeVM node:-prefixed negative builtin deny bypass exposes child_process (CVE-2026-92957). https://github.com/advisories/GHSA-8686-vhfx-7r3j
- [3] GHSA-6rh5-qq4q-97xh, vm2 NodeVM builtin denylist bypass via fs/promises despite -fs (CVE-2026-92958). https://github.com/advisories/GHSA-6rh5-qq4q-97xh
- [4] GHSA-98xx-8mx4-x7cm, vm2 NodeVM can replace the host process TLS trust store (CVE-2026-92941). https://github.com/advisories/GHSA-98xx-8mx4-x7cm
- [5] GHSA-27g9-p43v-cw3v, vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector (CVE-2026-92944). https://github.com/advisories/GHSA-27g9-p43v-cw3v
- [6] GHSA-c48m-32m9-vx93, vm2 custom module resolver can bypass the external package allowlist (CVE-2026-92951). https://github.com/advisories/GHSA-c48m-32m9-vx93
Top comments (0)