Released June 29, 2026

CVE-2026-43705

CVSS 8.8 severity high CWE-843 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to memory corruption

Researchers: dr3dd

Key insight. Values returned across a JS-iterable boundary (Array Symbol.iterator) were trusted for both count and type: a compiled-out ASSERT plus unchecked dynamicDowncasts let a poisoned iterator substitute arbitrary objects for the readable/writable streams.

Patch Details

diff --git a/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt b/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
new file mode 100644
index 000000000000..fe4368d722cb
--- /dev/null
+++ b/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
@@ -0,0 +1,6 @@
+Tests that constructing a TransformStream does not crash when Array.prototype[Symbol.iterator] has been poisoned to return wrong-type objects.
+WebKit should not crash, and you should see PASS below.
+
+PASS: plain-object substitution threw TypeError
+PASS: object-reference substitution threw TypeError
+PASS: truncated iterator threw TypeError
diff --git a/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html b/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
new file mode 100644
index 000000000000..3c7d91bbcded
--- /dev/null
+++ b/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<body>
+<p>Tests that constructing a TransformStream does not crash when Array.prototype[Symbol.iterator] has been poisoned to return wrong-type objects.<br>
+WebKit should not crash, and you should see PASS below.</p>
+<div id="result"></div>
+<script>
+if (window.testRunner) {
+    testRunner.dumpAsText();
+    testRunner.waitUntilDone();
+}
+
+// createInternalTransformStreamFromTransformer returns a 3-element array
+// that the C++ side iterates with convert<IDLSequence<IDLObject>>. By
+// poisoning Array.prototype[Symbol.iterator] we can substitute arbitrary
+// JS objects for the readable/writable entries. Without a runtime check,
+// the C++ side then treats those objects as JSReadableStream/JSWritableStream
+// and dereferences attacker-controlled memory.
+
+function check(label, fn) {
+    let message;
+    try {
+        fn();
+        message = "FAIL: " + label + " did not throw";
+    } catch (e) {
+        if (e instanceof TypeError)
+            message = "PASS: " + label + " threw TypeError";
+        else
+            message = "FAIL: " + label + " threw " + e;
+    }
+    const div = document.createElement("div");
+    div.textContent = message;
+    document.getElementById("result").appendChild(div);
+}
+
+const originalIterator = Array.prototype[Symbol.iterator];
+
+// 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 2: substitute an object that holds a heap pointer (would survive
+// the initial dereference and trigger the write-increment / vtable hijack).
+let buf = new ArrayBuffer(65536);
+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 = { pad: 0, ptr: buf };
+            i++;
+            return { value: val, done: false };
+        }
+    };
+};
+check("object-reference 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());
+
+Array.prototype[Symbol.iterator] = originalIterator;
+
+if (window.testRunner)
+    testRunner.notifyDone();
+</script>
+</body>
+</html>
diff --git a/Source/WebCore/Modules/streams/TransformStream.cpp b/Source/WebCore/Modules/streams/TransformStream.cpp
index 3098085811fa..3694d6c2be6f 100644
--- a/Source/WebCore/Modules/streams/TransformStream.cpp
+++ b/Source/WebCore/Modules/streams/TransformStream.cpp
@@ -166,9 +166,15 @@ ExceptionOr<CreateInternalTransformStreamResult> createInternalTransformStream(J
         return Exception { ExceptionCode::ExistingExceptionError };
 
     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() };
 }
 
 template<typename Visitor>

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43705
Bugzilla 314528
Component WebCore
Bug Class TypeConfusion
Severity medium
Commit 8fd92b1021d310b2…
Advisory Apple Advisory

Terminology

  • TransformStream internals — WebKit builds a TransformStream’s readable/writable from a JS-created 3-element result read back into C++.
  • IDLSequence conversion — convert<IDLSequence<IDLObject» iterates a JS iterable using Symbol.iterator, which script can override (‘poison’).
  • dynamicDowncast — Returns nullptr when the value is not the expected type; dereferencing the result without a null check is a type-confusion bug.

Root Cause Analysis

This fixes a type confusion in TransformStream construction reachable by poisoning Array iteration. createInternalTransformStream calls into JS (createInternalTransformStreamFromTransformer) which returns a 3-element result that WebKit reads via convert<IDLSequence<IDLObject» — an iteration that goes through Array.prototype[Symbol.iterator] and is therefore attacker-overridable. The pre-patch code ASSERTed results.size() == 3 (compiled out in release) and then built CreateInternalTransformStreamResult with dynamicDowncast<JSReadableStream>(results[1].get())->wrapped() and dynamicDowncast<JSWritableStream>(results[2].get())->wrapped() WITHOUT null-checking the downcasts. By overriding Array.prototype[Symbol.iterator] to yield arbitrary objects (or fewer than three), an attacker substitutes non-stream objects for the readable/writable slots; dynamicDowncast returns nullptr for a wrong type, and calling ->wrapped() on that nullptr, or treating an arbitrary object as a JSReadableStream/JSWritableStream, dereferences attacker-controlled memory — a type confusion leading to memory corruption. The fix replaces the ASSERT with a runtime size check that returns a TypeError when results.size() != 3, and null-checks both dynamicDowncasts (returning TypeError if readable or writable is null) before constructing the result with readable->wrapped()/writable->wrapped(). The restored invariant is that values crossing the JS-iterable boundary are validated for count and type before being treated as internal stream objects. The regression test poisons Symbol.iterator to substitute plain objects, an object holding a heap pointer, and a truncated iterator, expecting TypeError (and no crash) in all three cases.

Attack Path

1. Poison Array iteration

Override Array.prototype[Symbol.iterator] to return attacker-chosen objects for the internal 3-element result.

2. Construct a TransformStream

new TransformStream() triggers createInternalTransformStream, which reads the poisoned results via convert<IDLSequence<IDLObject».

3. Substitute wrong types

Supply a non-stream object for the readable/writable slot so dynamicDowncast yields nullptr / a confused object.

4. Type-confused dereference

->wrapped() on the null/attacker object dereferences controlled memory, corrupting WebContent process state.

Impact Assessment

A type-confusion / memory-corruption primitive in the WebContent process, triggerable from ordinary script by overriding Symbol.iterator so attacker-chosen objects are treated as internal stream objects. The advisory explicitly cites memory corruption; treating a controlled object as a JSReadableStream/JSWritableStream and dereferencing it is a strong route toward arbitrary read/write and code execution.

Audit Directions

Same pattern: unchecked downcasts after iteration

Grep WebCore for dynamicDowncast<…>(…)->… immediately after a convert<IDLSequence<…» / JS-iterable read, without a null check.

ASSERT on JS-controlled shape

Look for ASSERT(results.size()==N) or similar assumptions about the count/type of values returned from built-in JS builtins that a poisoned iterator can violate.

Changed Functions

Function File Change Note
createInternalTransformStream Source/WebCore/Modules/streams/TransformStream.cpp modified Replaces ASSERT(results.size()==3) with a runtime TypeError check and null-checks the JSReadableStream/JSWritableStream downcasts before calling wrapped(), rejecting substituted/wrong-type values.

Files Changed

Source Files

  • Source/WebCore/Modules/streams/TransformStream.cpp

Test Files

  • LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
  • LayoutTests/streams/transform-stream-poisoned-iterator-crash.html

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt b/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
new file mode 100644
index 000000000000..fe4368d722cb
--- /dev/null
+++ b/LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
@@ -0,0 +1,6 @@
+Tests that constructing a TransformStream does not crash when Array.prototype[Symbol.iterator] has been poisoned to return wrong-type objects.
+WebKit should not crash, and you should see PASS below.
+
+PASS: plain-object substitution threw TypeError
+PASS: object-reference substitution threw TypeError
+PASS: truncated iterator threw TypeError
diff --git a/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html b/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
new file mode 100644
index 000000000000..3c7d91bbcded
--- /dev/null
+++ b/LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<body>
+<p>Tests that constructing a TransformStream does not crash when Array.prototype[Symbol.iterator] has been poisoned to return wrong-type objects.<br>
+WebKit should not crash, and you should see PASS below.</p>
+<div id="result"></div>
+<script>
+if (window.testRunner) {
+    testRunner.dumpAsText();
+    testRunner.waitUntilDone();
+}
+
+// createInternalTransformStreamFromTransformer returns a 3-element array
+// that the C++ side iterates with convert<IDLSequence<IDLObject>>. By
+// poisoning Array.prototype[Symbol.iterator] we can substitute arbitrary
+// JS objects for the readable/writable entries. Without a runtime check,
+// the C++ side then treats those objects as JSReadableStream/JSWritableStream
+// and dereferences attacker-controlled memory.
+
+function check(label, fn) {
+    let message;
+    try {
+        fn();
+        message = "FAIL: " + label + " did not throw";
+    } catch (e) {
+        if (e instanceof TypeError)
+            message = "PASS: " + label + " threw TypeError";
+        else
+            message = "FAIL: " + label + " threw " + e;
+    }
+    const div = document.createElement("div");
+    div.textContent = message;
+    document.getElementById("result").appendChild(div);
+}
+
+const originalIterator = Array.prototype[Symbol.iterator];
+
+// 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 2: substitute an object that holds a heap pointer (would survive
+// the initial dereference and trigger the write-increment / vtable hijack).
+let buf = new ArrayBuffer(65536);
+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 = { pad: 0, ptr: buf };
+            i++;
+            return { value: val, done: false };
+        }
+    };
+};
+check("object-reference 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());
+
+Array.prototype[Symbol.iterator] = originalIterator;
+
+if (window.testRunner)
+    testRunner.notifyDone();
+</script>
+</body>
+</html>