CVE-2026-28857: A TOCTOU in JavaScriptCore’s TypedArray toReversed() and toSorted()
Apple Security Advisory: Safari 26.4
Introduction
This is a time-of-check to time-of-use (TOCTOU) race I found in JavaScriptCore’s TypedArray methods, in Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h. It is the incomplete-patch sibling of an earlier fix. WebKit had already fixed a TOCTOU in TypedArray.prototype.with(), which I wrote up in CVE-2026-28902, but the same unprotected pattern survives in toReversed() and toSorted(), and the patched with() itself still has a racy path in its else branch.
The Vulnerability
The toReversed(), toSorted(), and with() methods are the copying variants of the in-place TypedArray operations. Instead of mutating the array, they allocate a fresh result buffer and copy elements into it. The problem lives in the gap between determining how big the copy needs to be and performing the copy.
Start with genericTypedArrayViewProtoFuncToReversed, which received no protection at all:
size_t length = thisObject->length(); // line 1489: reads N
ViewClass* result = ViewClass::createUninitialized(..., length); // line 1493: allocates an N-element buffer
auto from = const_cast<const ViewClass*>(thisObject)->typedSpan(); // line 1496
// typedSpan() == unsafeMakeSpan(typedVector(), length())
// length() is re-read here -> N+K after a concurrent grow()
// so from.size() == N+K
ASSERT(from.size() == length); // line 1497: ASSERT-only, compiled out in release
auto to = result->typedSpan(); // to.size() == N
WTF::copyElements(to, from); // line 1501: source larger than destination
The whole bug is in the two calls to length(). The first read, at line 1489, gives us N, and we use it to allocate an N-element result buffer. Then, at line 1496, typedSpan() is called on the source array, and typedSpan() is defined as unsafeMakeSpan(typedVector(), length()). It re-reads length().
Between those two reads there is nothing to hold the length still. TypedArrays can be backed by a SharedArrayBuffer, and an auto-length view over a growable SharedArrayBuffer tracks the buffer’s size rather than holding a constant:
const sab = new SharedArrayBuffer(100, { maxByteLength: 1000000 });
const ta = new Int32Array(sab); // auto-length view over the SAB
The buffer can grow from a Worker thread at any instant via sab.grow(). If a Worker calls grow() between line 1489 and line 1496, the second read returns N+K. Now we have a source span of size N+K and a destination span of size N. The ASSERT(from.size() == length) on line 1497 would catch this, except it is an ASSERT, which compiles out entirely in a release build. copyElements is then handed a source larger than the destination.
The identical pattern appears in toSorted() at lines 1635-1646. Same two reads, same unguarded window, same outcome.
Root Cause
The fix for the with() issue, CVE-2026-28902, added a re-check. After allocating the result, it re-reads the length and compares:
size_t updatedLength = thisObject->length(); // line 1961: re-read the length
if (thisLength != updatedLength) [[unlikely]] {
// slow path: element-by-element copy, bounds-checked, safe
} else {
// ---- a Worker can call sab.grow() right here ----
auto from = const_cast<const ViewClass*>(thisObject)->typedSpan(); // line 1981
// typedSpan() re-reads length() -> N+K
ASSERT(from.size() == thisLength); // compiled out
auto to = result->typedSpan(); // to.size() == N
WTF::copyElements(to, from); // line 1984: same crash
}
The intent is right: if the length changed between the original read and updatedLength, take a safe slow path. But the fix reached only with(). toReversed() and toSorted() were left with no re-check at all, and even in with() the else branch is still racy. Having confirmed the length matched at line 1961, it goes right back to calling typedSpan() at line 1981, which re-reads length() for a third time. The check at line 1961 and the use at line 1981 are two different points in time, and a grow() fired in between defeats the check entirely. The window is narrower than the original bug, but it is not closed.
When the source span outsizes the destination, the copy is caught inside WTF::copyElements, which guards itself with:
RELEASE_ASSERT(destinationSpan.size() >= sourceSpan.size());
Unlike the ASSERT upstream, a RELEASE_ASSERT stays in the shipping binary. When destinationSpan.size() (which is N) is smaller than sourceSpan.size() (which is N+K), the assertion fails and the process aborts. Today that turns the race into a controlled crash: a denial of service.
That RELEASE_ASSERT is the only thing standing between a controlled crash and a genuine memory corruption primitive. If it were ever weakened to a plain ASSERT, as its sibling on line 1497 already is, then copyElements would happily write N+K elements into a buffer sized for N. All three functions, toReversed(), toSorted(), and with(), would become heap buffer overflow writes, with the overflow length K controlled by how far the attacker’s Worker managed to grow the buffer during the race.
Reproduction
Conceptually, the trigger mirrors the with() race. A growable SharedArrayBuffer is shared with a Worker that calls grow() in a tight loop while the main thread calls toReversed() in a tight loop. When a grow() lands between the length read at line 1489 and the typedSpan() re-read at line 1496, copyElements is handed a source larger than the destination and the RELEASE_ASSERT aborts the process. The fix is to never re-read length() for the copy, building the source span from typedVector() and the already-cached length instead of a fresh typedSpan() call, which collapses the time-of-check and time-of-use back into a single moment across all three functions.