← All reports

[WebCore] TransformStream type confusion when Array.prototype[Symbol.iterator] is overridden

HighWebCore StreamsTypeConfusion

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

8fd92b1 | Bugzilla 314528

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

auto results = resultsConversionResult.releaseReturnValue();
- ASSERT(results.size() == 3);
+ if (results.size() != 3) [[unlikely]]
+ return Exception { ExceptionCode::TypeError, "Internal TransformStream creation returned an unexpected number of values"_s };
 
- return CreateInternalTransformStreamResult { results[0].get(), dynamicDowncast<JSReadableStream>(results[1].get())->wrapped(), dynamicDowncast<JSWritableStream>(results[2].get())->wrapped() };
+ auto* readable = dynamicDowncast<JSReadableStream>(results[1].get());
+ auto* writable = dynamicDowncast<JSWritableStream>(results[2].get());
+ if (!readable || !writable) [[unlikely]]
+ return Exception { ExceptionCode::TypeError, "Internal TransformStream creation returned values of unexpected types"_s };
+
+ return CreateInternalTransformStreamResult { results[0].get(), readable->wrapped(), writable->wrapped() };

LayoutTests/streams/transform-stream-poisoned-iterator-crash.html

+// Variant 1: substitute plain objects for the readable/writable slots.
+Array.prototype[Symbol.iterator] = function() {
+ const arr = this;
+ let i = 0;
+ return {
+ next() {
+ if (i >= arr.length)
+ return { done: true };
+ let val = arr[i];
+ if (arr.length === 3 && i >= 1)
+ val = { fake: true };
+ i++;
+ return { value: val, done: false };
+ }
+ };
+};
+check("plain-object substitution", () => new TransformStream());
+
+// Variant 3: truncated iterator returning fewer than 3 entries.
+Array.prototype[Symbol.iterator] = function() {
+ return { next() { return { done: true }; } };
+};
+check("truncated iterator", () => new TransformStream());

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.

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.

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.

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.