CVE-2026-43705
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.txtLayoutTests/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>