← WebKit Silent-Fix Report — 2026-W23

96ec73a1e57d67618055237272f866849103669c  Type confusion in ReadableStream when cancel returns a fake Promise object

severity high class TypeConfusion confidence 0.90 WebCore Streams exploitable-grade
Ryosuke Niwa Fri Jun 5 22:59:05 2026 -0700 full: 96ec73a1e57d67618055237272f866849103669c bug report ↗ view on GitHub ↗
Primitive: Type confusion in ReadableStream when cancel returns a fake Promise object
Triage note: cancelReadableStream() used an unchecked downcast<JSC::JSPromise>(value) on a value an attacker can control by overriding Promise[Symbol.species] in a cancel callback (returning FakePromise producing a non-JSPromise like 0). Fix switches to dynamicDowncast with a null check, preventing jsCast/performPromiseThen on an attacker-shaped object.
Contents

The bug at a glance

Reachable entirely from unprivileged web content: any page can construct a ReadableStream, override Promise[Symbol.species] inside a cancel callback, and drive the plumbing via pipeTo(), so no prior compromise is required. The bug turns an attacker-shaped JSValue (e.g. the JS number 0) into a JSC::JSPromise* via an unchecked static downcast, after which performPromiseThen executes against a non-promise object, giving a type-confusion primitive suitable for RCE-grade corruption in the WebContent process. Web-reachable + memory-safety-violating type confusion in a JIT-adjacent object model justifies the 8.8 (High) rating.

This is a lovely reminder that WebKit’s C++ downcast<>() is a promise, not a check: it assumes the JSValue already is a JSPromise and, in release builds, blindly reinterprets whatever bits it is handed. The Streams spec forces the engine to call user-controllable machinery (promiseResolve, and therefore Promise[Symbol.species]) while unwinding a pipeTo, and the attacker exploits the fact that a species-hijacked constructor can hand back a fabricated ‘promise’ whose value is the integer 0. cancelReadableStream() then does downcast<JSC::JSPromise>(0), producing a bogus JSPromise* that closingMustBePropagatedBackward() feeds straight into performPromiseThen. It’s a textbook ’the spec made me call into JS at the worst possible moment’ type confusion, and the one-line fix (downcast -> dynamicDowncast + null check) is exactly the tell.

Root cause

cancelReadableStream() in Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp receives a JSValue produced by invoking the stream’s cancel algorithm and normalizing it through the Streams promise machinery. Before the patch it did auto* promise = downcast<JSC::JSPromise>(value); with only a preceding if (!value) guard. downcast<> in WTF is an unconditional, assertion-only cast: in release builds it performs no runtime type check and simply reinterprets the pointer/JSCell bits as a JSPromise*. The value handed to it, however, is not guaranteed to be a genuine JSPromise, because the surrounding code path can call back into author script.

The attacker controls that value by overriding Promise[Symbol.species]. When the cancel callback runs, the page defines a getter on Promise[Symbol.species] that returns a FakePromise constructor exactly once (gated by a didCall flag so only the sensitive internal .then/promiseResolve step is hijacked, while every other species lookup falls back to the real %Promise%). FakePromise satisfies the resolve/reject callability checks by invoking the executor, then return 0; — so construct() yields the JS number 0 rather than a promise object. This poisoned value flows back to the C++ side as the ‘promise’ to be observed.

The unsafe use then happens in StreamPipeToState::closingMustBePropagatedBackward(), which took the same downcast<JSC::JSPromise>(value) result and, on the non-null path, drove it through jsCast/performPromiseThen — i.e. it treated the integer 0 (or any attacker-shaped cell) as a fully-formed JSPromise, reading its internal fields and installing reactions on it. The PoC engineers the synchronous route to this code by pre-arming the WritableStream: writer.close() sets @closeRequest so closeQueuedOrInFlight() is true while the state is still ‘writable’, and writer.releaseLock() unlocks the destination, so pipeTo() takes the closing-propagation branch immediately.

The fix replaces every such unchecked cast with dynamicDowncast<JSC::JSPromise>(value), which performs a real runtime type check and returns nullptr on mismatch, followed by an explicit if (!promise) bail-out (return nullptr in cancelReadableStream/errorsMustBePropagatedForward, and in closingMustBePropagatedBackward it now rejects the deferred via deferred->rejectWithCallback(..., RejectAsHandled::Yes) instead of dereferencing). The same defensive change is applied to StreamPipeToState::globalObject(), swapping downcast<JSDOMGlobalObject> for dynamicDowncast<JSDOMGlobalObject> so a non-JSDOMGlobalObject context yields nullptr rather than a confused cast. Net effect: an attacker-shaped object can no longer be laundered into a JSPromise* and reach performPromiseThen.

Key code

Unchecked static downcast on an author-controllable value, replaced with a checked dynamicDowncast

// StreamPipeToUtilities.cpp, cancelReadableStream()
 if (!value)
     return nullptr;

-    auto* promise = downcast<JSC::JSPromise>(value);        // release: no check; reinterprets bits
+    auto* promise = dynamicDowncast<JSC::JSPromise>(value);  // real runtime type check
     if (!promise)
         return nullptr;

// closingMustBePropagatedBackward(): 0 (from FakePromise) reached performPromiseThen
-    auto* promise = downcast<JSC::JSPromise>(value);
+    auto* promise = dynamicDowncast<JSC::JSPromise>(value);
     if (!promise)
         deferred->rejectWithCallback(WTF::move(getError2), RejectAsHandled::Yes);
     else { /* ... performPromiseThen on genuine JSPromise ... */ }

Patch walkthrough

  • Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp — The core fix. In cancelReadableStream(), errorsMustBePropagatedForward()’s abort handler, and closingMustBePropagatedBackward(), the unchecked downcast<JSC::JSPromise>(value) is replaced with dynamicDowncast<JSC::JSPromise>(value) plus a null check, so an attacker-controlled non-promise value bails out (returning nullptr, or rejecting the deferred with rejectWithCallback) instead of being reinterpreted and passed to performPromiseThen. StreamPipeToState::globalObject() similarly switches to dynamicDowncast<JSDOMGlobalObject> for defense in depth against a non-standard global object.
  • LayoutTests/streams/readable-stream-fake-promise-crash.html — Regression test that reproduces the crash: it builds a ReadableStream whose cancel() hijacks Promise[Symbol.species] once to return a FakePromise constructor that returns the number 0, pre-arms a WritableStream (close() then releaseLock()) to force the synchronous closing-propagation path, and calls pipeTo(). Before the fix this drove performPromiseThen on 0; after the fix it must complete without an ASAN crash or assertion and print PASS.
  • LayoutTests/streams/readable-stream-fake-promise-crash-expected.txt — Expected output for the layout test, containing the descriptive banner and PASS, encoding that the corrected code path exits cleanly.

Background

downcast<> vs dynamicDowncast<> (WTF) — downcast<T>(x) is an unconditional cast that only asserts the type in debug builds; in release it reinterprets the bits with no runtime check, so a wrong type is a silent type confusion. dynamicDowncast<T>(x) performs a real runtime type test and returns nullptr on mismatch, which is why swapping one for the other is the canonical WebKit fix for this bug class.

Promise[Symbol.species] — A well-known symbol the ECMAScript promise machinery consults to decide which constructor to use when deriving a new promise (e.g. in .then/promiseResolve). Because it is an author-defined getter, script can hand the engine an arbitrary constructor at a moment of its choosing — here, only once, precisely during the internal step that produces the value later downcast to JSPromise.

ReadableStream cancel / pipeTo propagation — During pipeTo(), if the destination is closing or errored, WebKit must cancel the source and observe the returned promise. cancelReadableStream() normalizes the cancel algorithm’s result and closingMustBePropagatedBackward() installs reactions on it via performPromiseThen — a path that necessarily runs author-supplied JS (the cancel callback and species machinery) before the C++ code trusts the resulting object’s type.

Vulnerability window

  1. Setup — Page creates a ReadableStream with a cancel callback and a WritableStream; writer.close() sets @closeRequest (closeQueuedOrInFlight() true, state still ‘writable’) and writer.releaseLock() unlocks the destination, arming the synchronous closing-propagation branch.
  2. Trigger — readableStream.pipeTo(writableStream) runs; the closing condition invokes the source’s cancel algorithm, entering author JS at the exact point the engine will normalize a promise.
  3. Species hijack — Inside cancel(), Object.defineProperty on Promise[Symbol.species] installs a one-shot getter returning FakePromise, which invokes the executor to pass callability checks and then returns the number 0.
  4. Type confusion — cancelReadableStream()/closingMustBePropagatedBackward() call downcast<JSC::JSPromise>(0), producing a bogus JSPromise* with no runtime check.
  5. Use — performPromiseThen (via jsCast) reads promise internals and installs reactions on the non-promise value, dereferencing attacker-shaped memory — the exploitable type confusion.

Proof of concept

This is the shipped regression test verbatim in structure. It exercises the closing-propagation path: the one-shot species getter ensures only the internal promise-normalization step gets FakePromise (returning 0), while the cancel callback itself returns a real pending promise so earlier .then handling is unperturbed. On a vulnerable build, downcast<JSC::JSPromise>(0) followed by performPromiseThen dereferences the integer 0 / attacker-shaped cell; on the patched build dynamicDowncast returns nullptr and the deferred is rejected cleanly.

<script>
function FakePromise(executor) {
  executor(() => {}, () => {}); // pass resolve/reject callability checks
  return 0;                     // construct() yields the number 0, not a promise
}
const rs = new ReadableStream({
  cancel(reason) {
    let didCall = false;
    Object.defineProperty(Promise, Symbol.species, {
      configurable: true,
      get() {
        if (!didCall) { didCall = true; return FakePromise; }
        return undefined; // every other species lookup falls back to %Promise%
      }
    });
    return new Promise(() => {}); // a genuine pending promise passes promiseResolve unchanged
  }
});
const ws = new WritableStream();
const w = ws.getWriter();
w.close();        // @closeRequest set => closeQueuedOrInFlight(), state still 'writable'
w.releaseLock();  // destination.locked() == false
rs.pipeTo(ws).catch(() => {}); // synchronous path: closingMustBePropagatedBackward -> downcast(0)
</script>

Exploitation

  1. Reach the sink — Craft the WritableStream state (close() + releaseLock()) so pipeTo takes the synchronous closing-propagation branch, guaranteeing the confused value flows into performPromiseThen rather than being dropped.
  2. Shape the confused object — Instead of returning the immediate 0, return a JSObject whose in-memory layout overlaps the fields JSPromise/performPromiseThen reads (internal fields, reaction lists). A heap-groomed object of a chosen JSCell type lets the attacker control the pointers that performPromiseThen treats as promise internals.
  3. Turn confusion into a primitive — By controlling the aliased fields, coerce performPromiseThen’s reads/writes into an out-of-bounds or fake-object access, escalating toward an addrof/fakeobj-style primitive; difficulty is moderate because the attacker fully controls the confused object’s contents and the trigger is deterministic and synchronous.

Detection & hunting

For defenders and SOC / detection engineers:

  • ASan crash in WebContent — SEGV or heap-type-confusion reports whose stack passes through WebCore::StreamPipeToState::closingMustBePropagatedBackward / cancelReadableStream into JSC::performPromiseThen / jsCast<JSPromise> indicate this bug or a sibling.
  • Species tampering + Streams — Fuzzers and telemetry can flag pages that redefine Promise[Symbol.species] with a getter and simultaneously use ReadableStream.pipeTo / cancel; the combination of a custom species constructor returning a non-object is highly anomalous.
  • Constructor returning non-object into stream internals — Instrumenting promiseResolve/species construction to assert the result is a JSPromise (or logging when it is not) catches the exact laundering step this exploit relies on.

Audit directions

  • Other downcast<JSC::JSPromise> sites — Grep WebCore Modules/streams and the wider bindings for downcast<JSC::JSPromise>/downcast on values that originate from author-callable algorithms (abort, close, cancel, custom then); any lacking a dynamicDowncast + null check is a candidate for the same confusion.
  • Species / then hijack reachability — Audit every C++ path that assumes a promise-returning JS operation actually returns a JSPromise while Promise[Symbol.species] or Promise.prototype.then can be user-overridden — WritableStream, TransformStream, and fetch/body plumbing follow similar patterns.
  • globalObject() casts — The patch also hardened StreamPipeToState::globalObject() to dynamicDowncast<JSDOMGlobalObject>; look for other downcast<JSDOMGlobalObject>(context->globalObject()) sites that could observe a non-standard or detached global.

Before / after

Loading diff…