[3] Type confusion in ReadableStream when cancel returns a fake Promise
Streams' pipeTo checked for a fake promise with a cast that never fails
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
jsDynamicCastin place ofjsCastto avoid type confusion.Test:
streams/readable-stream-fake-promise-crash.html
Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp
LayoutTests/streams/readable-stream-fake-promise-crash.html
Patch Details
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.
Background
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.
Analysis
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:
- Create a
ReadableStreamwhose underlying source defines acancel(reason)callback. - Create a
WritableStream, take a writer, callwriter.close()so the internal@closeRequestis set —closeQueuedOrInFlight()becomes true while the state is stillwritable— thenwriter.releaseLock()sodestination.locked()is false andpipeTocan acquire its own writer. - Call
readableStream.pipeTo(writableStream).StreamPipeToState::createruns the four propagation setups synchronously;closingMustBePropagatedBackward()observes the queued close and starts shutdown-with-action, which cancels the source and therefore calls the author'scancel()synchronously. - Inside
cancel(),Object.defineProperty(Promise, Symbol.species, {get})fires the species watchpoint at precisely the moment when all earlier setup@thencalls have completed, so only the next species lookup consults the getter. cancel()returns a genuine pending promise so it survivespromiseResolveunchanged, 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 returns0, so[[Construct]]evaluates to the plainthisobject. 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.- That plain object reaches
closingMustBePropagatedBackward'sdowncast<JSC::JSPromise>(value), which reinterprets it, and the result is fed toperformPromiseThen.
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."
Audit directions
-
A fallible-looking cast written with the non-failing operator, so the error branch beneath it is dead code. The invariant is if a call site has an
if (!x)recovery path, the cast above it must be the checked variant. This shape is self-documenting and greppable because the dead branch is right there. Narrow: grepSource/WebCore/Modules/streamsand the widerSource/WebCore/bindingstree fordowncast</jsCast<whose result is immediately tested for null — in code review, adowncast<T>(...)assignment followed within a few lines byif (!ptr)or aptr ? ... : ...ternary is unreachable-by-construction and deserves a second look. Wider: every non-failing narrowing primitive paired with a failure handler —static_caston a base pointer followed by a null test,ASSERT-only validatedreinterpret_cast, Objective-Cid-typed values assigned into a typed variable with arespondsToSelector:fallback. Widest: a recovery path is evidence the author believed the operation could fail — verify the operation actually can report failure, transferring to Rustunwrap()/expect()beside anif let Nonearm and to Java unchecked casts guarded by a provably unreachablecatch (ClassCastException). -
Native code trusting the concrete type of an object that a builtin constructed through a script-observable species/constructor hook. The invariant is anything reachable via
Symbol.species, aconstructorproperty lookup, or a subclassable builtin is script-typed, no matter how privileged the code that built it. Narrow: audit the other C++ consumers of the streams builtins —InternalReadableStream,InternalWritableStream,InternalWritableStreamWriter,ReadableStreamDefaultReader, andcreatePromiseAndWrappercallers — for any place a JSValue crossing back from builtin JS is downcast toJSPromise,JSArray, or a concreteJSDOMWrapper; the tell is a JSValue that originated from a builtin call being narrowed to a concrete JSC type withoutdynamicDowncast. Wider: wherever WebKit builtins construct derived objects for native consumers —Array[Symbol.species]in builtin array helpers,RegExp[Symbol.species]inString.prototype.split-style paths,ArrayBuffer[Symbol.species]inslice, and any@Constructor-based construction inside.jsbuiltin files underSource/JavaScriptCore/builtinsandSource/WebCore/Modules/*/*.js; the code-search shape is a builtin calling@speciesConstructorornew @Constructor(...)on a value that then flows out to C++. Widest: a language-level extension hook that lets user code choose the concrete type of an internally constructed object erases every type guarantee downstream of that hook — applying to V8/SpiderMonkey species handling, to .NET/Java factory-method injection and deserialization gadget surfaces, and to any plugin registry where the host picks an implementation class from user-supplied configuration. -
A watchpoint or memoized invariant used to elide a check, where script can invalidate it at a moment of its choosing from inside a callback. The invariant is check elision keyed on a global watchpoint is only sound if no attacker-controlled code can run between the elision decision and every use that depends on it. Narrow: examine JSC's species and prototype-chain watchpoints (
m_promiseSpeciesWatchpointSetand its array/regexp/typed-array siblings inJSGlobalObject) and enumerate which fast paths consult them; the tell is a fast path that reads a watchpoint's state once and then performs several dependent operations, at least one of which can call into user JS. Wider: any cached-validity token invalidated by re-entrant script — DOM style/layout validity flags checked before a callback and relied upon after, inline-cache/structure-validity checks straddling a user-code call, and memoized "is default" flags fortoString/valueOf/Symbol.toPrimitive; the shape to notice is a validity read, then a call that can reach author code, then a use of the earlier read. Widest: cache-validity checks and their dependent uses must not be separated by a re-entrancy point, holding in any JIT with feedback-vector or type-barrier elision, in any GUI toolkit caching layout validity across event dispatch, and in any TOCTOU-shaped optimization. Verification here is genuinely nontrivial — proving a watchpoint cannot be fired mid-sequence usually needs a debug build with watchpoint-firing traces rather than static inspection.