← All reports

[3] Type confusion in ReadableStream when cancel returns a fake Promise

HighWebCore StreamsTypeConfusion

Streams' pipeTo checked for a fake promise with a cast that never fails

96ec73a

High. Plain web content reaches this with a short deterministic PoC, and native code ends up operating on an attacker-shaped plain object as if it were a promise. The step that gates it below Critical is whether the attacker can populate the forged object's slots with chosen values — that turns a reliable crash into a fake-cell primitive.

WebKit implements the Streams specification partly in privileged JavaScript builtins and partly in C++, with values crossing back and forth between the two. Symbol.species is a well-known symbol that built-in machinery reads from a constructor when it needs to create a derived object "of the same kind" — for promises, chaining operations consult Promise[Symbol.species] to decide which constructor builds the derived promise, and script can define its own accessor there. JSPromise is a JSC internal-field object: its status flags and its reactions/result live in fixed slots read directly by the promise machinery without a per-access type check. The assumption on the C++ side is that anything the builtins hand back as a promise really is a JSPromise cell.

The angle: any page can define a Promise[Symbol.species] getter that hands native pipeTo code a plain object, which is then read as a promise's internal fields and fed to the promise-then machinery.

Use jsDynamicCast in place of jsCast to avoid type confusion.

Test: streams/readable-stream-fake-promise-crash.html

Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp

static RefPtr<DOMPromise> cancelReadableStream(JSDOMGlobalObject& globalObject, ...)
if (!value)
return nullptr;
 
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise)
return nullptr;
...
void StreamPipeToState::errorsMustBePropagatedForward(JSDOMGlobalObject& globalObject)
auto value = internalWritableStream->abort(*globalObject, error.get());
if (!value)
return nullptr;
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise) {
auto [result, deferred] = createPromiseAndWrapper(*globalObject);
deferred->resolve();
...
void StreamPipeToState::closingMustBePropagatedBackward()
auto [result, deferred] = createPromiseAndWrapper(*globalObject);
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise)
deferred->rejectWithCallback(WTF::move(getError2), RejectAsHandled::Yes);
else {
...
JSDOMGlobalObject* StreamPipeToState::globalObject()
RefPtr context = scriptExecutionContext();
- return context ? downcast<JSDOMGlobalObject>(context->globalObject()) : nullptr;
+ return context ? dynamicDowncast<JSDOMGlobalObject>(context->globalObject()) : nullptr;

LayoutTests/streams/readable-stream-fake-promise-crash.html

+function FakePromise(executor) {
+ executor(() => {}, () => {}); // satisfy resolve/reject callability checks
+ return 0; // construct() returns this object
+}
+const readableStream = new ReadableStream({
+ cancel(reason) {
+ // Fire m_promiseSpeciesWatchpointSet *here*, after all setup .@then calls are done
+ let didCall = false;
+ Object.defineProperty(Promise, Symbol.species, {
+ configurable: true,
+ get() {
+ if (!didCall) { didCall = true; return FakePromise; }
+ return undefined; // fall back to %Promise% for every other .then
+ }
+ });
+ return new Promise(() => {}); // genuine pending JSPromise
+ }
+});
+const writableStream = new WritableStream();
+const writer = writableStream.getWriter();
+writer.close(); // @closeRequest set => closeQueuedOrInFlight() == true
+writer.releaseLock(); // destination.locked() == false
+readableStream.pipeTo(writableStream).catch(() => {}); // -> closingMustBePropagatedBackward -> jsCast

Three unchecked downcasts become checked ones. In cancelReadableStream(), the result of InternalReadableStream::cancel() was cast with downcast<JSC::JSPromise>(value) and now uses dynamicDowncast<JSC::JSPromise>(value). The same substitution is applied in StreamPipeToState::errorsMustBePropagatedForward() (the result of internalWritableStream->abort(...)) and in StreamPipeToState::closingMustBePropagatedBackward() (the value returned by the shutdown action, which is then handed to the promise-then machinery).

All three call sites already had if (!promise) fallback branches. With downcast those branches were unreachable for any non-null input — in debug builds the cast asserts, in release it blindly reinterprets — so the fix is what makes the existing error handling live: cancelReadableStream returns nullptr, errorsMustBePropagatedForward substitutes a freshly created resolved promise, and closingMustBePropagatedBackward rejects the deferred via rejectWithCallback(getError2, RejectAsHandled::Yes). A fourth, defensive change makes StreamPipeToState::globalObject() use dynamicDowncast<JSDOMGlobalObject>(context->globalObject()). Collateral: the new layout test plus its expectation file, which is a working PoC.

Unchecked downcast of a script-influenced value to a concrete type, where the guard branch written for a failed cast could never fire because the cast operator was the non-failing variant.

The two cast families. downcast<T>(v) in WebKit is the non-failing, checked-only-in-debug cast (the jsCast family for JSC types): it asserts the type in debug builds and reinterprets the pointer in release. dynamicDowncast<T>(v) (the jsDynamicCast family) performs a runtime type test and returns null on mismatch.

Symbol.species and its watchpoint. Symbol.species is read from a constructor when built-in machinery needs to create a derived object of the same kind; for promises, then/catch and internal promise-chaining consult Promise[Symbol.species] to pick the constructor. JSC installs a watchpoint on that property so that, while it holds the default value, promise-chaining fast paths skip the species lookup entirely. Defining an own accessor for the property fires (invalidates) the watchpoint and moves subsequent chaining onto the generic path that actually calls the getter. The watchpoint exists purely as an optimization — the lookup is on every chaining step, and almost no real code overrides species.

[[Construct]] and primitive returns. In JavaScript, a construct call whose constructor body returns a primitive evaluates to the newly created this object. So new F() where F returns 0 produces an ordinary JSFinalObject.

JSPromise internal fields. JSPromise is a JSC internal-field object: its status flags and its reactions/result live in fixed internal-field slots read directly by performPromiseThen, without a per-access type check. Internal fields are C++-only slots — script cannot read or write them by name — which is exactly why a forged object standing in for a JSPromise is dangerous: the machinery reads slots it believes are engine-private.

The pipeTo split. WebKit implements Streams partly in privileged JS builtins (InternalReadableStream, InternalWritableStream) and partly in C++. readableStreamPipeTo() creates a StreamPipeToState which, at construction time, synchronously wires up four propagation paths — errorsMustBePropagatedForward, errorsMustBePropagatedBackward, closingMustBePropagatedForward, closingMustBePropagatedBackward — before entering its read/write loop. closingMustBePropagatedBackward handles the case where the destination writable stream is already closed or has a close queued: it shuts the pipe down, which cancels the source ReadableStream, invoking the author-supplied cancel() callback of the underlying source. DeferredPromise/DOMPromise are the WebCore refcounted wrappers used to bridge those JSValues into C++ continuations.

The root cause is that the native pipeTo implementation treats every JSValue coming back from the JS-builtin streams layer as necessarily a JSPromise, and expresses that assumption with an unchecked downcast. The missing invariant: the object identity of a promise produced by builtin stream code is not under WebKit's control, because promise construction inside the builtins goes through Promise[Symbol.species], which is script-observable and script-replaceable.

  script                builtin JS streams          native C++
  ──────                ──────────────────          ──────────
  define Promise
    [Symbol.species]
    getter (fires WP)
                        chaining step needs
                        a derived promise
                          ─► species lookup
                             (slow path now)
  getter -> FakePromise ◄──┘
                        new FakePromise(exec)
                          body returns 0
                          => plain JSFinalObject
                                                  ─► downcast<JSPromise>
                                                     reinterprets it
                                                  ─► performPromiseThen
                                                     reads slots 0/1 as
                                                     flags + reactions

The confusion is a plain type confusion, but the interesting half is the timing. JSC's species watchpoint means the lookup is normally elided; the PoC fires the watchpoint inside the cancel() callback, i.e. after all setup @then calls have already been made, so exactly the subsequent species lookup takes the slow path and calls the attacker's getter. The getter returns FakePromise once and undefined thereafter, so every other chaining step falls back to %Promise% and the rest of the machinery keeps working. FakePromise is an ordinary function whose body returns the primitive 0, so [[Construct]] yields the freshly created this — a plain JSFinalObject — which propagates back into C++ and through the unchecked cast.

The already-present if (!promise) branches at each site show the authors intended a fallible cast. Only the cast operator was wrong.

The PoC is plain script with no special privileges. Following readable-stream-fake-promise-crash.html:

  1. Create a ReadableStream whose underlying source defines a cancel(reason) callback.
  2. Create a WritableStream, take a writer, call writer.close() so the internal @closeRequest is set — closeQueuedOrInFlight() becomes true while the state is still writable — then writer.releaseLock() so destination.locked() is false and pipeTo can acquire its own writer.
  3. Call readableStream.pipeTo(writableStream). StreamPipeToState::create runs the four propagation setups synchronously; closingMustBePropagatedBackward() observes the queued close and starts shutdown-with-action, which cancels the source and therefore calls the author's cancel() synchronously.
  4. Inside cancel(), Object.defineProperty(Promise, Symbol.species, {get}) fires the species watchpoint at precisely the moment when all earlier setup @then calls have completed, so only the next species lookup consults the getter.
  5. cancel() returns a genuine pending promise so it survives promiseResolve unchanged, but the builtin chaining step that follows constructs its derived promise via species — new FakePromise(executor) runs the executor to satisfy the resolve/reject callability checks and returns 0, so [[Construct]] evaluates to the plain this object. The PoC's own comments identify the chaining step that consults species; the streams builtins pin the exact construction site, and the fix does not depend on which one it is.
  6. That plain object reaches closingMustBePropagatedBackward's downcast<JSC::JSPromise>(value), which reinterprets it, and the result is fed to performPromiseThen.

Escalation beyond the ASan/assertion crash the test targets depends on two things this change does not pin down. First, whether the attacker can populate the confused object's storage slots with chosen JSValues — FakePromise's body runs before the object escapes, so assigning own properties there would place attacker-chosen values into the inline slots that JSPromise reads as its status flags and reactions/result fields. Second, whether the species hook can be steered to return a non-cell JSValue; the PoC's return 0 is neutralized by [[Construct]] semantics, but the same slow path is reachable from .then-style chaining where a primitive can flow through. If the first holds, the promise-then path would read an attacker-chosen JSValue as the reactions/result internal field and a second as the status flags, which could give a fake-object primitive when the reactions field is subsequently treated as a cell — the classic route from species-forged internal-field objects to controlled pointer dereference. If the second holds instead, the confused pointer would not even be a cell, and the immediate effect would be a wild dereference at an attacker-influenced address.

Discovery looks like pattern auditing of newly written native streams code. StreamPipeToUtilities.cpp is a recent C++ reimplementation of pipeTo, and the natural audit question for such a file is which JSValues cross back from the builtins and how they are narrowed — downcast<JSC::JSPromise> sitting directly above a dead if (!promise) branch is a strong visual tell. The species-watchpoint timing detail in the PoC indicates the finder understood JSC's promise fast paths well enough to construct the trigger deliberately, which fits hand-auditing or variant analysis of earlier Symbol.species fake-object bugs rather than fuzzing; a grammar fuzzer would be unlikely to hit the required writer.close() + releaseLock() + species-override-inside-cancel sequence by chance.

This vulnerability weakens the type-safety boundary between the JS-builtin streams layer and native WebCore code inside the WebContent process. The security-model assumption is that any value the streams builtins hand back as a "promise" really is a JSPromise cell — an assumption script can falsify simply by defining a Promise[Symbol.species] getter, because the builtins construct derived promises through species. An attacker who wins the confusion has native code operating on a plain, attacker-shaped object as if it were a JSPromise, so the plausible gain is a controlled type-confusion primitive inside the renderer — misinterpreted internal fields, and on non-cell values a wild pointer dereference — meaning memory disclosure or corruption in WebContent, not a sandbox escape by itself.

Insight: JSC's species watchpoints make this class worse in a subtle way — they create a timing-selectable switch. Because the fast path is taken while the watchpoint holds, an attacker can let all the setup chaining complete under the safe path and then fire the watchpoint from inside a callback so that exactly one downstream species lookup goes slow. That converts "can script override species?" into "can script override species at one chosen instant?", which defeats reasoning of the form "we already established the promise type earlier in this function."