← All issues

[1] TypedArray TOCTOU heap overflow in toSorted/toReversed/with

Severity: High | Component: JSC TypedArray prototype methods | 4c82252

Rated High because the observable effect is a heap buffer overflow from a TOCTOU race with attacker-controlled overflow size and content, and the regression test demonstrates the race is winnable within thousands of iterations with growable SharedArrayBuffer — a feature reachable from ordinary web content.

The TypedArray.prototype.toSorted, toReversed, and with methods create a new TypedArray and copy from the original. The copying reads the TypedArray's length and then separately acquires a typedSpan(). If the TypedArray is backed by a growable SharedArrayBuffer, the span may have a different length than the copy because the buffer grew in parallel. The fix snapshots the span upfront.

Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h

// toReversed:
- 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();
...
- 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);
 
// toSorted:
- size_t length = thisObject->length();
+ auto originalSpan = const_cast<const ViewClass*>(thisObject)->typedSpan();
+ size_t length = originalSpan.size();
...
- auto from = const_cast<const ViewClass*>(thisObject)->typedSpan();
- ASSERT(from.size() == length);
- WTF::copyElements(to, from);
+ WTF::copyElements(to, originalSpan);
 
// with:
- size_t updatedLength = thisObject->length();
- if (thisLength != updatedLength) [[unlikely]] {
+ auto maybeUpdatedSpan = const_cast<const ViewClass*>(thisObject)->typedSpan();
+ if (thisLength != maybeUpdatedSpan.size()) [[unlikely]] {
...
- auto from = const_cast<const ViewClass*>(thisObject)->typedSpan();
- WTF::copyElements(to, from);
+ WTF::copyElements(to, maybeUpdatedSpan);

JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js

+ $.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) {}
+ }
+ });
+ `);
+ for (let i = 0; i < ITERATIONS_PER_ROUND; i++) {
+ ta.with(0, 0x41414141);
+ ta.toReversed();
+ ta.toSorted();
+ }

The fix modifies all three methods — genericTypedArrayViewProtoFuncToReversed, genericTypedArrayViewProtoFuncToSorted, and genericTypedArrayViewProtoFuncWith — to capture thisObject->typedSpan() once upfront and derive the length from that snapshot. Previously, each method read thisObject->length() first to allocate the result buffer, then called thisObject->typedSpan() later to obtain the source data for copying. The fix ensures length and data pointer are consistent by binding them to a single snapshot.

Before:                                 After:
length = thisObject->length()           originalSpan = thisObject->typedSpan()
    |                                   length = originalSpan.size()
    v                                       |
result = allocate(length)                   v
    |                                   result = allocate(length)
    v    ← SAB grows here                   |
from = thisObject->typedSpan()              v
  from.size() > length!                 WTF::copyElements(to, originalSpan)
    v                                     (originalSpan.size() == length, always)
WTF::copyElements(to, from)
  → OOB WRITE past result

TOCTOU race between length read and data span acquisition on a concurrently growable SharedArrayBuffer-backed TypedArray.

SharedArrayBuffer with the maxByteLength option creates a growable shared memory region. Multiple threads (main thread + Web Workers) can access the same SAB concurrently. The grow() method increases the SAB's byteLength up to maxByteLength and is observable from all threads immediately — there is no synchronization barrier; the new length is visible as soon as the grow completes.

TypedArray.prototype.toReversed(), toSorted(), and with() are copy-producing methods introduced in ES2023. They allocate a new TypedArray, copy data from the source, and return the new array without modifying the original. typedSpan() returns a std::span whose size reflects the TypedArray's current length, which for resizable/growable-SAB-backed views can change between calls as the underlying buffer grows from another thread.

The root cause is a TOCTOU race condition between reading the TypedArray's length and reading its data span. In toReversed and toSorted, the code first called thisObject->length() to determine the allocation size for the result buffer, then later called thisObject->typedSpan() to get the source data for copying. When the TypedArray is backed by a growable SharedArrayBuffer, a concurrent worker thread can call sab.grow() between these two reads. Since growable SABs can increase in size at any time from another thread, typedSpan() could return a span whose .size() exceeds the length used to allocate the destination buffer. WTF::copyElements then copies from the larger source span into the smaller destination span, writing past the end of the destination buffer (if WTF::copyElements copies based on source span size rather than destination span size, as the fix pattern strongly implies).

The with method had a slightly more complex variant: it re-read thisObject->length() to detect resize, but then called typedSpan() again — introducing another TOCTOU window between the length comparison and the span acquisition.

🔒

Explores the heap corruption primitive this race yields, including attacker control over overflow size and content

Subscribe to read more

🔒

Multiple TOCTOU audit patterns identified across TypedArray methods, with concrete search targets for variant discovery

Subscribe to read more