[WebCore] TransformStream type confusion when Array.prototype[Symbol.iterator] is overridden
CVE: CVE-2026-43705 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: A type confusion issue was addressed with improved checks. Credit: dr3dd
High. new TransformStream() — no permissions, no gestures, one line of script — reaches an unchecked downcast whose only guard compiled away in release. The null-base crash is unconditional; promoting it to a counterfeit stream object needs heap grooming this diff doesn't settle, but the type check that would have stopped it simply wasn't there.
Parts of WebKit's Streams implementation aren't C++ at all — they're JavaScript, compiled into the engine and invoked from native code through private names. That split forces a handoff: a builtin computes the internal transform-stream triple, and C++ has to pull the readable and writable halves back out as native objects. The handoff doesn't go directly, though. It goes through the WebIDL bindings layer, and convert<IDLSequence<IDLObject>> is specified to walk the value with the JS iteration protocol — a protocol the page owns.
The angle: Any page can override array iteration and hand the browser engine plain JavaScript objects where it expects two specific stream types, turning a one-line constructor call into a controlled type confusion in the renderer.
Source/WebCore/Modules/streams/TransformStream.cpp
LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
Patch Details
The change is confined to WebCore::createInternalTransformStream and adds two guards where there was previously one-and-a-half.
The first guard replaces ASSERT(results.size() == 3) with a release-visible if (results.size() != 3) [[unlikely]] return Exception { ExceptionCode::TypeError, ... }. Same predicate, but now it exists in the shipped binary. A sequence carrying fewer than three entries — or more — no longer falls through to the subscripts below it.
The second guard is entirely new. The two dynamicDowncast calls are hoisted out of the CreateInternalTransformStreamResult initializer into named locals, then null-checked together before either is dereferenced. Previously the cast and the dereference lived in the same expression: dynamicDowncast<JSReadableStream>(results[1].get())->wrapped(). dynamicDowncast is a checked cast — it returns nullptr on a type mismatch — but nothing between the call and the arrow ever looked at the result.
Before: After:
results (size unvalidated) results
└─► results[1] ├─► size() != 3 ──► throw TypeError
└─► dynamicDowncast<JSRS> └─► results[1]
└─► ->wrapped() └─► dynamicDowncast<JSRS>
(no null test) └─► !readable ──► throw TypeError
└─► ->wrapped()
Both failures surface to script as a TypeError, so the poisoned-iterator page gets an exception instead of a crash. The commit also lands LayoutTests/streams/transform-stream-poisoned-iterator-crash.html, which exercises three variants of the same override: substituting plain objects into slots 1 and 2, substituting an object that holds an ArrayBuffer reference, and returning a zero-length iteration. All three now assert TypeError.
Background
The Streams API and its split implementation. TransformStream is a Streams API object exposed to page script that pairs a writable side with a readable side; anything written to one end emerges, transformed, from the other. new TransformStream() is callable from any web page with no permissions and no user gesture. WebKit implements Streams partly in C++ and partly in JavaScript: significant chunks of the machinery are written as JS builtins, JavaScript source compiled into the engine and invoked from native code through private names invisible to page script. createInternalTransformStreamFromTransformer is one such builtin. It runs in the page's own global object and realm.
The C++ side of the handoff. Source/WebCore/Modules/streams/TransformStream.cpp is where the two halves meet. createInternalTransformStream invokes the builtin, receives a JS value back, converts it into a native vector, and unwraps the entries into the Ref<ReadableStream> and Ref<WritableStream> members the TransformStream object holds.
WebIDL sequence conversion. convert<IDLSequence<IDLObject>>(globalObject, value) is the bindings-layer helper that turns a JS value into a WTF::Vector of native entries. Per the WebIDL specification, sequence conversion uses the JS iteration protocol: it looks up Symbol.iterator on the value and repeatedly calls the returned iterator's next(). Both the lookup and each next() call execute script in the page's realm. IDLObject entries materialize as strong GC handles, so results[i].get() yields a JSObject*.
Checked casts and wrappers. dynamicDowncast<T> (equivalently jsDynamicCast<T>) is the checked cast for JSC cells: it reads the cell's structure and ClassInfo and returns nullptr when the runtime type doesn't match, exactly like C++ dynamic_cast. JS wrapper classes such as JSReadableStream hold a reference to their underlying WebCore object, and JSDOMWrapper::wrapped() returns that reference so C++ callers can get at the native ReadableStream. Ref<T> is WebKit's non-null refcounted smart pointer; constructing one from a T& increments that object's reference count — which is a write into the referenced object.
Two checks that aren't checks. ASSERT is enabled in debug builds only; under release it expands to nothing, so an ASSERT-guarded condition receives no runtime enforcement in any shipping browser. WTF::Vector::operator[] is likewise bounds-checked by assertion only, so in release builds indexing past size() is a raw pointer computation and a load.
Analysis
This is a trust-boundary erasure: data the C++ author believed came straight from an engine builtin actually passed through a conversion step the page controls, and the code validated it as if it hadn't.
builtin realm bindings layer C++ consumer
───────────── ────────────── ────────────
createInternal... convert<IDLSequence< results[1] ──► dynamicDowncast
Transformer() ──[JS]──► IDLObject>> ──────► results[2] ──► dynamicDowncast
returns [a,b,c] │ (no guard survives NDEBUG)
└── @@iterator lookup + next() calls
▲ PAGE-OVERRIDABLE ── trust ends here
Follow the arrows: the array leaves the builtin with the shape the builtin intended — three entries, entry 1 a JSReadableStream, entry 2 a JSWritableStream. It does not arrive at the consumer that way. The middle column runs the JS iteration protocol, and because the array is created in the page's own realm, an assignment to Array.prototype[Symbol.iterator] puts the page's own next() method between producer and consumer. The page decides how many entries the conversion observes and what each one is. By the time results reaches the right-hand column it is attacker-shaped data wearing the builtin's reputation.
The pre-fix code encoded the builtin's guarantees in exactly two places, both of which failed differently.
On the type axis there was no guard at all. The dynamicDowncast calls were correct — they do check ClassInfo and do return null on mismatch — but the result was dereferenced in the same expression that produced it. Test variant 1 is the minimal trigger: the poisoned iterator yields three entries and swaps entries 1 and 2 for { fake: true }. The cast returns nullptr, ->wrapped() reads the wrapper's m_wrapped field off a null base, and the renderer dies. That path is unconditional and needs no setup:
Array.prototype[Symbol.iterator] = function() { /* yield {fake:true} at i>=1 */ };
new TransformStream(); // null-base deref in createInternalTransformStream
On the length axis there was a guard, but only in builds nobody ships. ASSERT(results.size() == 3) reads, at the call site, as documentation of an internal contract — and as documentation it's accurate. As enforcement it evaporates under NDEBUG. Test variant 3 exploits precisely that: the iterator reports done on its first next(), so results.size() is 0, and results[1] / results[2] index past the logical end of a WTF::Vector whose operator[] also carries only a debug assertion.
Where those subscripts land depends on the size the attacker chose. At size 0 the vector may have no allocation at all, so the read is off the buffer entirely. At sizes 1 or 2 the read lands inside allocated-but-uninitialized spare capacity. Either way, whatever pointer value occupies those bytes is handed to dynamicDowncast, which dereferences it to read a cell header and its ClassInfo. That is already an out-of-bounds or uninitialized pointer read. And if the ClassInfo comparison happens to succeed — the escalation the exploitability picture turns on — the pre-fix code would treat the fabricated object as a genuine JSReadableStream, take wrapped() from it, and store the resulting Ref<ReadableStream> into TransformStream::m_readable. Constructing that Ref performs a refcount increment: a write at an attacker-influenced address. Subsequent script touching .readable or .writable then dispatches virtually through the stored reference.
That escalation depends on heap grooming preconditions the commit context doesn't settle — controlling what sits in the vector's spare capacity, and getting a value there whose ClassInfo pointer survives the cast. The unconditional floor is the null-base crash from variant 1: a reliable, attacker-triggered renderer DoS from a few lines of page script. Everything here stays inside the WebContent process; TransformStream.cpp is WebCore code running in the renderer, so no sandbox boundary is crossed and a separate escape would still be required to reach the system.
The fix restores the invariant on both axes at once, which is why it needed two checks rather than one. The length test is a release-visible restatement of what the assertion always meant. The null test covers a type invariant that had never been written down anywhere — not in an assertion, not in a comment, only in the author's model of what the builtin returns.
A WebIDL sequence conversion runs the page's @@iterator, so the builtin's three-element [result, readable, writable] array arrives in C++ with attacker-chosen length and attacker-chosen element types.
Insight
The trust boundary here is invisible at the call site. Nothing in createInternalTransformStream looks like input handling: it calls an engine builtin, converts the result, and unwraps it. The conversion is where trusted data silently becomes untrusted, and the bindings layer performs that downgrade without any syntactic marker. Any C++ that round-trips a builtin-produced JS value through a WebIDL conversion consulting a user-overridable hook — @@iterator, valueOf, toString, @@toPrimitive, or a Proxy trap — inherits the same downgrade, and the Streams implementation is built almost entirely out of such round-trips.
Audit directions
-
Debug-only assertions standing in as the sole validator across a trust boundary. The invariant: a check that vanishes under NDEBUG never validated anything an attacker can influence. Narrow — grep
Source/WebCore/Modules/streams/andSource/WebCore/bindings/js/forASSERT(in the same block as ajsDynamicCast/dynamicDowncast/jsCaston a value derived from a builtin invocation;ReadableStream.cpp,WritableStream.cpp,StreamTransferUtilities.cpp, andReadableStreamDefaultControllerare the closest neighbours of this exact call shape, and the tell is anASSERTon shape followed by a cast whose result is dereferenced in the same expression with noif (!x)between them. Wider — the class recurs wherever any release-elided check guards lower-trust data:ASSERT_UNUSED, decorativeASSERT_WITH_SECURITY_IMPLICATION, or aRELEASE_ASSERTthat checks a different field than the one subsequently dereferenced; the code-search tell is a validation predicate and a dereference that do not name the same variable. Widest — the principle is codebase-independent: audit any project where debug-only predicates (DCHECK,debug_assert!, Javaassert, Pythonassertunder-O) sit between a parser, deserializer, or IPC layer and its consumer. If the value can differ between debug and release runs, the debug check is not a security control. -
Host code assuming a value it produced retains its shape after a conversion that consults user-overridable protocol hooks. The invariant: any conversion that runs script re-enters the attacker's realm, so the producer's guarantees expire at the conversion, not at the return. Narrow — enumerate every
convert<IDLSequence<...>>andtoNativeArraycall site inSource/WebCorewhose argument came from a builtin call rather than a bindings-generated parameter; the streams internals are the obvious cluster, and the tell is a call to a...PrivateName()builtin whose result feeds a sequence conversion in the same function. Wider — broaden past@@iteratorto every user-overridable hook a WebIDL conversion can trigger:valueOf/toString/@@toPrimitiveonIDLLongandIDLDOMStringconversions, Proxyget/ownKeystraps on dictionary conversion. The shape to look for is C++ reads field A, converts, then reads field B and assumes A and B are consistent. Widest — this is the general "host serializes host data using a script-overridable protocol" class, and it recurs in Python C extensions callingPyObject_GetIteron values they created, Lua C API code that respects__indexmetatables, and .NET/COM interop round-tripping through a user-implementable interface. The question to carry: which steps of this conversion can the untrusted side implement? -
Constant-index access into a container whose length came from untrusted input, in a language whose release builds drop the bounds check. The invariant: the length must be checked in the same build configuration in which the index is used. Narrow — search WebCore for a
convert<IDLSequence<...>>result immediately indexed with literal subscripts (results[1],results[2]) or destructured positionally, and confirm each has a release-visiblesize()comparison dominating it; the tell is a literal subscript with nosize()test above it. Wider — the same shape covers anyWTF::Vector/Span/FixedVectorindexed with a value derived from JS-visible state, and the subtler variant where the size is checked but the check and the use are separated by a call that can run script and re-enter: asize()test, then any function call, then a subscript, deserves a second read. Widest — the principle applies to every language where indexed access is unchecked or optionally checked: C++std::vector::operator[]versus.at(), Rustget_unchecked, Go slice reslicing on parsed lengths. A length that arrived from outside must be validated by code that exists in the shipped binary, and revalidated after anything that could resize the container.