The Calysteon Corner

A Guide to the Cyber Galaxy

Home

CVE-2026-28902: A TOCTOU Heap Buffer Overflow in JavaScriptCore’s TypedArray.prototype.with()

Apple Security Advisory: Safari 26.5


Introduction

This is a Time-Of-Check to Time-Of-Use (TOCTOU) heap buffer overflow I found in WebKit’s JavaScriptCore, the JavaScript engine used by Safari. The bug lives in Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h, inside the templated function genericTypedArrayViewProtoFuncWith, which implements TypedArray.prototype.with().

with() is one of the “change array by copy” methods: given an index and a value, it returns a new typed array identical to the original except at that one index, mutating nothing in place. Making that copy involves two sizes that must agree, the size of the source and the size of the destination. If those are read at different moments and something can change the source in between, the copy can outrun the destination. That “something” is a growable SharedArrayBuffer. Because these buffers are shared across threads, a Worker can call sab.grow() and enlarge the backing store while the main thread is executing inside the builtin, so the length with() reads is not a constant but a live value another thread controls. The same shape appears in the sibling “change by copy” methods toReversed() and toSorted(), tracked separately as CVE-2026-28857.

The Vulnerability

The function reads the array’s length multiple times and trusts each reading to still be valid:

size_t thisLength = length.value();                                    // read #1: N
ViewClass* result = ViewClass::createUninitialized(..., thisLength);   // allocate an N-element buffer

size_t updatedLength = thisObject->length();                           // read #2: still N (pre-grow)
if (thisLength != updatedLength) [[unlikely]] {
    // slow path - not taken, because both reads say N
} else {
    // ──── Worker calls sab.grow() right here: N becomes N+K ────
    auto from = const_cast<const ViewClass*>(thisObject)->typedSpan(); // read #3: length() again → N+K
    auto to   = result->typedSpan();                                   // to.size() = N
    WTF::copyElements(to, from);                                       // OVERFLOW: copies (N+K) into N
}

It caches the length as thisLength, allocates a result buffer of exactly that size, then re-reads the length into updatedLength and compares. If the two disagree it takes a slow path. If they agree it proceeds to copy.

The problem is where the copy gets its source size. It does not use the cached, validated thisLength. Instead it calls typedSpan(), defined in JSGenericTypedArrayView.h:

std::span<...> typedSpan() const { return unsafeMakeSpan(typedVector(), length()); }

typedSpan() calls length() fresh every time. For an array backed by a growable SharedArrayBuffer, length() is not a cached constant. It reflects the current buffer size, read with sequentially-consistent ordering from integerIndexedObjectLength (see JSArrayBufferView.h), so it reports whatever the buffer size is at the exact instant it is called.

That opens the window. Read #2 validates the length as N and the slow path is skipped. If a Worker calls sab.grow() between that check and the typedSpan() call, read #3 observes the grown length. The source span becomes N+K elements while the destination span, built from the buffer allocated for thisLength, is still N. The check and the use are two separate reads of the same value, and a Worker can change that value between them.

Root Cause

The root cause is that the copy source size comes from a fresh length() read rather than the cached, already-validated length. The function read and validated updatedLength, then discarded it and let typedSpan() read length() again, so the source span can observe a size the code never checked against its allocation.

There is a bounds check before the copy, but it does not run in shipping builds. WTF::copyElements in StringCommon.h guards its memcpy like this:

ASSERT(destinationSpan.size() >= sourceSpan.size());   // debug-only
std::memcpy(destination, source, sourceSpan.size_bytes());   // unconditional

ASSERT compiles to nothing in release builds. In a release or ASan build the assertion is removed and the memcpy runs unconditionally, copying sourceSpan.size_bytes() worth of data, which by now is (N+K) * elementSize, into a destination sized for only N * elementSize. The destination span says N, the source span says N+K, and memcpy writes past the end of the freshly allocated result buffer, spilling K elements of attacker-influenced data into whatever the heap allocator placed next.

Reproduction

Conceptually, the trigger races a Worker against the main thread. On the main thread, call .with() in a tight loop. On a Worker, call sab.grow() in a tight loop against the shared buffer. When a grow() lands in the window between read #2 and read #3, the source span reports N+K, the destination is N, and the overflow fires.

When the race lands, the instrumented memcpy inside copyElements writes past the end of the result buffer and the WebContent process reports a heap-buffer-overflow WRITE:

=================================================================
==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xADDR
WRITE of size NNN at 0xADDR thread T0
    #0 __asan_memcpy                              (libclang_rt.asan_osx_dynamic.dylib)
    #1 void WTF::copyElements<int>(std::span<int>, std::span<int const>)
                                                   Source/WTF/wtf/text/StringCommon.h:1109
    #2 JSC::genericTypedArrayViewProtoFuncWith<JSC::JSInt32Array>(...)
                                                   ...JSGenericTypedArrayViewPrototypeFunctions.h:1982
    ...

0xADDR is located 0 bytes after N-byte region [0xBASE, 0xBASE+N)
allocated by thread T0 here:
    #0 __asan_malloc                               (libclang_rt.asan_osx_dynamic.dylib)
    #1 ...                                         (bmalloc allocator)
    #2 JSC::JSGenericTypedArrayView<...>::createUninitialized(...)
                                                   JSGenericTypedArrayViewPrototypeFunctions.h:1956
=================================================================

The allocation trace points at createUninitialized on line 1956, the N-element result buffer; the write trace points at copyElements on line 1982, the memcpy that ran off the end of it, located 0 bytes after the region.

The window is narrow: in production Safari, non-ASan and fully optimized, only ~20-50 instructions separate read #2 from read #3, and hitting it consistently is difficult. ASan’s instrumentation adds 2-10x overhead that widens the race proportionally and makes the crash reproducible on demand, but the narrowness is a property of timing, not correctness, and the bug is real at the source level regardless. The fix is to build the source span from the cached, already-validated length rather than re-reading it through typedSpan().