Medium CVSS 8.8 webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to memory corruption
ComponentWebCore
Sub-componentWebCore Streams
Bug ClassType Confusion
Tracker314528
Fix commit8fd92b1021d3 (WebKit/WebKit) +102/-2
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Crediteddr3dd
Disclosed2026-06-29

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
if
LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
modified
next
LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
modified

Files Changed

  • LayoutTests/streams/transform-stream-poisoned-iterator-crash-expected.txt
  • LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
  • Source/WebCore/Modules/streams/TransformStream.cpp

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.
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>
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.