NewApp Monitoring is liveSign up free
AgentbeamBeta
Open-source package vulnerability · npm

CVE-2026-92956: vm2 sandbox escape via WebAssembly.compileStreaming Promise species bypass

criticalCVSS 3.1 10.0GHSA-wjwh-qqvp-g4p4

vm2 3.10.1 through 3.11.6 lets code in a default `VM` escape the sandbox on Node.js 26 through `WebAssembly.compileStreaming`. A rejected host-realm Promise reaches attacker code via `Promise.prototype.finally` and `Symbol.species`, which yields the host `process`. Upgrade to vm2 3.11.7.

Published Oct 5, 2026 · Analysis Oct 25, 2026

What an attacker can do

vm2 keeps Promises reachable from the sandbox in two safe shapes: sandbox-realm Promises it can harden, or host Promises that pass through its bridge. WebAssembly.compileStreaming and instantiateStreaming add a third: a raw host-realm Promise. An earlier fix, for GHSA-6j2x-vhqr-qr7q, removed WebAssembly.promising and WebAssembly.Suspending for the same reason. This is a bypass of that fix.

The exploit calls WebAssembly.compileStreaming(0). The invalid argument makes the Promise reject with a host-realm TypeError. The attacker puts a constructor accessor and a custom Symbol.species on the returned Promise, then calls .finally(() => {}). That path hands the host error to attacker code. From there hostError.constructor.constructor('return process')() resolves through the host Function constructor and returns the real process.

No NodeVM, require or host object is needed, only the JavaScript string given to vm.run(). The proof of concept first shows process, require and constructor routes are blocked, then reaches the host process, matches its pid, and writes a marker file with host fs. It reproduced on Node.js 26.2.0 but not on 20.20.2, 22.22.3 or 24.15.0. The reporter says it is not remote by default. It becomes remote where an application runs attacker-supplied JavaScript in vm2.

Who is affected

vm2 from 3.10.1 up to and including 3.11.6, running on Node.js 26. The reporter tested 3.11.5 and could not recover the host process on Node 20, 22 or 24. The reporter still advises treating the fix as version-independent. Check both versions:

npm ls vm2
node --version

vm2

Ecosystem
npm
Affected
>= 3.10.1, <= 3.11.6
Fixed in
3.11.7
Check
npm ls vm2

How to fix it

Upgrade to vm2 3.11.7.

npm install [email protected]

The advisory's suggested fix deletes WebAssembly.compileStreaming and WebAssembly.instantiateStreaming from the sandbox in the existing WebAssembly hardening block. It also recommends hardening Promise.prototype.finally as defense in depth. No other workaround is listed. For untrusted code, keep a separate process or container boundary around vm2 and move off Node 26 only if you cannot patch at once.

How to tell if you were hit

Search sandboxed scripts for WebAssembly.compileStreaming or instantiateStreaming, particularly with a Symbol.species override and a .finally( call. On the host, look for unexpected modules loaded by the Node process, new files such as the PoC's marker in the temp directory, and child processes started by the service. Check which Node version runs the service. Node 26 hosts are the confirmed case.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

Summary

There is a sandbox escape in vm2 3.11.5 / current HEAD when it is used on Node.js 26. The issue is reachable from a default new VM() sandbox. No NodeVM, require permission, host object injection, or intentionally unsafe configuration is required.

The escape is a patch-bypass of the same security invariant addressed by GHSA-6j2x-vhqr-qr7q. The earlier fix removed the JSPI entry points WebAssembly.promising and WebAssembly.Suspending, because those APIs exposed a Promise path whose host-realm Promise.prototype was not intercepted by vm2's Promise hardening or bridge layer.

The same unsafe class remains reachable through WebAssembly.compileStreaming and WebAssembly.instantiateStreaming. On Node 26, these streaming APIs can produce a raw host-realm Promise path that rejects with a host-realm error. By controlling Symbol.species through Promise.prototype.finally, sandbox code can receive that raw host error object, walk from the host error constructor to the host Function constructor, and recover the real host process object.

The proof of concept demonstrates this by first showing that direct access to process, require, and constructor-based escapes are blocked in the same default VM. It then reaches the host process, verifies that the recovered pid matches the parent Node process, and writes a harmless marker file through host fs.

Impact

This is a sandbox escape. In applications that expose vm2 execution to attacker-controlled JavaScript, the issue can become host code execution in the context of the Node.js process running the sandbox.

This is not remote code execution by default. The remote aspect depends on the embedding application. The accurate framing is:

An attacker who can supply JavaScript to a vm2 VM sandbox on Node 26 can break out of the vm2 boundary and obtain host Node.js capabilities.

This matters for services that use vm2 as a security boundary for untrusted JavaScript, including plugin runners, workflow engines, automation platforms, online code runners, browser-automation sandboxes, and AI-agent or user-script execution environments.

The PoC only writes a marker file under the OS temporary directory. That marker is intentionally harmless. The security impact is not the marker itself; the security impact is that sandbox-controlled code obtains the real host process and host modules after the control case proves those capabilities are normally blocked.

Tested versions and configuration

Tested vm2 3.11.5 at commit 7a1f5100b96f48d34e0fe104ab37c0acc5944f92.

The escape reproduced on Node.js 26.2.0. I also tested the same PoC on Node.js 20.20.2, 22.22.3, and 24.15.0; those versions did not recover the host process through this path.

The test case uses the default VM boundary only:

const { VM } = require('./lib/main');
const vm = new VM({ timeout: 8000 });

vm.run(attackerControlledJavaScript);

No NodeVM was used. The sandbox was not given require, process, fs, child_process, host callbacks, or host objects. The PoC only controls the JavaScript string passed to vm.run().

The version dependency appears to be in the final delivery step: on Node 26 the host-realm rejection from WebAssembly.compileStreaming reaches the attacker-controlled finally/Symbol.species capability, while the same path did not become exploitable in my Node 20/22/24 tests. I would still treat the fix as version-independent: sandbox code should not be able to receive this raw host-Promise path from the WebAssembly streaming APIs at all.

Root cause

The security boundary relies on vm2 keeping Promise objects that are reachable from sandbox code in one of two safe shapes:

  1. Sandbox-realm Promise. The Promise uses the sandbox's Promise prototype chain, so vm2's Promise hardening can pin species and sanitize callbacks.
  2. Bridge-proxied host Promise. The Promise is a host object crossing through vm2's membrane, so bridge traps and callback sanitizers apply.

WebAssembly.compileStreaming and WebAssembly.instantiateStreaming introduce a third shape: a raw host-realm Promise path that is directly reachable from sandbox code and is not bridge-proxied. This is the same class of object that made the JSPI issue dangerous.

The exploitability depends on two facts being true at the same time:

  • the streaming WebAssembly API returns a Promise path whose relevant Promise machinery is host-realm rather than sandbox-realm; and
  • the rejection generated by passing an invalid streaming source is a host-realm TypeError from Node's WebAssembly streaming implementation.

Once that host error is delivered to attacker-controlled code, the usual host-realm constructor walk becomes possible:

hostError.constructor.constructor('return process')()

That expression resolves through the host Function constructor, not the sandbox one, because the error object is host-realm.

Exploit flow

The exploit flow is small, but the realm boundary is the important part:

  1. Sandbox code calls WebAssembly.compileStreaming(0).
  2. The argument is not a valid Response or Promise resolving to a Response, so the returned Promise rejects.
  3. On Node 26, the rejection value is a host-realm error object.
  4. The attacker installs a controlled constructor accessor on the returned Promise and provides a custom Symbol.species constructor.
  5. Calling p.finally(() => {}) reaches the species path used by Promise.prototype.finally.
  6. NewPromiseCapability(F) invokes the attacker-controlled constructor F and exposes the result capability functions.
  7. When the Promise rejects, the host-realm error is delivered to the attacker-controlled rejection path.
  8. The attacker uses the host error's constructor chain to recover host process.
  9. The PoC loads host fs through process.mainModule.require('fs') and writes a harmless marker file.

Promise.prototype.finally is important because it is the remaining species-sensitive Promise combinator that is not pinned the same way as vm2's hardened then, catch, and static Promise helpers. However, simply wrapping the sandbox's Promise.prototype.finally is not sufficient for this bug: the dangerous Promise path is raw host-side Promise machinery, so the sandbox's own finally wrapper is not the method that protects this call. The dangerous source needs to be removed or safely wrapped.

Relationship to GHSA-6j2x-vhqr-qr7q

This is not the original JSPI path. Current HEAD removes WebAssembly.promising and WebAssembly.Suspending, and the old PoC is blocked.

The bypass here reaches the same unsafe Promise shape through a different source: WebAssembly.compileStreaming / WebAssembly.instantiateStreaming. That is why I am reporting it as an incomplete fix for the GHSA-6j2x class rather than as a duplicate of the already-fixed JSPI issue.

Proof of concept

The PoC is provided separately as:

escape-poc.js

The PoC uses a default new VM() with no privileged objects exposed; the marker file is only a harmless proof that the sandboxed code recovered host-side capability.

The PoC performs four control checks first:

process access: blocked
require access: blocked
constructor process access: blocked
constructor require(fs): blocked

Then it runs the exploit path and verifies that the recovered process is the actual host process by comparing the recovered pid with the parent process pid.

Expected vulnerable output on Node 26.2.0:

[control] process access             : blocked
[control] require access             : blocked
[control] constructor process access : blocked
[control] constructor require(fs)    : blocked
[exploit] host process reached       : yes
[exploit] host pid                   : <pid>
[exploit] host pid matches parent pid: yes
[exploit] host execPath              : <node executable>
[exploit] host version               : v26.2.0
[exploit] marker file                : created
[result] VULNERABLE

Expected output on Node 24.15.0:

[control] process access             : blocked
[control] require access             : blocked
[control] constructor process access : blocked
[control] constructor require(fs)    : blocked
[exploit] host process reached       : no
[exploit] marker file                : not created
[result] not reproduced on this Node version

The marker file contains only a proof string, the Node version, the pid, and a timestamp. As a sanity check, I reproduced the Node 26 result from a fresh public clone at commit 7a1f5100b96f48d34e0fe104ab37c0acc5944f92; lib/ was unmodified, and only the PoC file was copied in.

Suggested fix

The minimal fix is to remove the remaining streaming WebAssembly APIs from the sandbox in the same WebAssembly hardening block that already removes the JSPI APIs.

if (typeof WebAssembly.compileStreaming !== 'undefined') {
    localReflectDeleteProperty(WebAssembly, 'compileStreaming');
}
if (typeof WebAssembly.instantiateStreaming !== 'undefined') {
    localReflectDeleteProperty(WebAssembly, 'instantiateStreaming');
}

This patch works against the PoC on Node 26.2.0 and Node 24.15.0. With the patch applied, the streaming APIs are unavailable inside the sandbox, the PoC does not recover the host process, and the marker file is not written.

Two additional defense-in-depth change recommendations:

  1. Add a Promise.prototype.finally hardening path for consistency with the existing Promise hardening. This is not sufficient by itself for this bug, but it removes a known species-sensitive gap for sandbox-realm Promises.
  2. Add a regression/invariant test that checks sandbox-reachable Promise-producing intrinsics do not expose raw host-Promise paths outside the bridge.

The non-streaming WebAssembly Promise APIs was also checked. The end-to-end escape reproduced through compileStreaming and instantiateStreaming, but not through compile or instantiate in my tests. The minimal patch therefore removes the two confirmed exploitable streaming APIs, while the broader invariant remains that sandbox code should not receive raw host-Promise paths.

Regression tests

The regression test should cover both the direct fix and the end-to-end invariant:

  1. WebAssembly.compileStreaming is unavailable or safely wrapped inside new VM().
  2. WebAssembly.instantiateStreaming is unavailable or safely wrapped inside new VM().
  3. The PoC cannot recover host process on Node 26.
  4. Direct access to process, require, and constructor-based process access remains blocked.
  5. The finally + species primitive does not reach host process through any WebAssembly Promise-returning source.

Credited to thesmartshadow, zolbooo.

Questions

Is vm2 on Node 24 affected by CVE-2026-92956?

The reporter could not recover the host process on Node 20, 22 or 24, and reproduced only on 26.2.0. The reporter still advises patching, since the raw host-Promise path should not exist at all.

Does it need NodeVM or require access?

No. A default new VM() with no host objects is enough. The PoC controls only the string passed to vm.run().

How does it relate to the earlier JSPI fix?

It is a bypass of the fix for GHSA-6j2x-vhqr-qr7q. That fix removed WebAssembly.promising and Suspending, but the streaming APIs gave the same unsafe Promise shape.

Is it exploited in the wild?

No source says so, and it is not in CISA's KEV catalog. A proof of concept is linked from the advisory.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More npm vulnerabilities

All npm entries