c8525868de [JSC] Use span's length in genericTypedArrayViewProtoFuncSortImpl
Triage note: Snapshots typedSpan and uses its size for the sort length, preventing OOB when a growable/shared buffer resizes concurrently.
Contents
The bug at a glance
High. genericTypedArrayViewProtoFuncSortImpl operates directly on the backing store of a TypedArray view; if the length it copies with can exceed the store’s true element count at the moment of the copy, the sort reads (and writes back) out of the bounds of the ArrayBuffer’s allocation. Because the trigger is a growable SharedArrayBuffer (GSAB) grown concurrently on another thread, this is a data race that yields a controlled OOB read/write on the JS heap, a strong exploitation primitive. Backed by an rdar and a landed stress test, hence high confidence.
The bug is a TOCTOU between two independent reads of the view’s length. length() is read once to size the temporary vector and to bound the sort; typedSpan() is constructed separately and re-reads the length from the (shared) backing store. A parallel grow on a GSAB between those two reads lets the two lengths disagree, so the sort iterates using the stale/larger length over a span whose real size differs.
Root cause
genericTypedArrayViewProtoFuncSortImpl implements the TypedArray.prototype.sort fast path. In the pre-patch code it first calls thisObject->length() to obtain the element count, uses that to allocate a scratch Vector (sized length*2), and later separately calls thisObject->typedSpan() to obtain a std::span over the live backing store. Both length() and typedSpan() independently re-read the current byte length of the underlying ArrayBuffer and divide by the element size.
When the TypedArray is backed by a growable SharedArrayBuffer, the buffer’s byteLength is shared mutable state that another agent (a Worker holding the same SAB) can enlarge at any instant via SharedArrayBuffer.prototype.grow. Growth only ever increases the length, but the hazard is the disagreement between the two snapshots. The cached size_t length taken from the first read and the size of originalSpan taken from the second read are not guaranteed equal: the two reads straddle a memory-ordering window in which the length observed can change. The sort logic then trusts length while indexing into originalSpan (and its copies), so a length larger than the span’s true extent drives reads and comparator-fed writes past the end of the mapped region.
The fix reorders and unifies the two reads: it constructs originalSpan first via thisObject->typedSpan(), then derives length from originalSpan.size(). Now the single span object is the sole source of truth; the count used to allocate the vector, to gate the length < 2 early-out, and to bound every subsequent copy is exactly the size of the span the code will actually touch. Any concurrent grow that happens after the span is materialized is simply not observed by this invocation, and the sort stays within the span it captured.
The residual race is thereby made benign: with GSABs, grow can still occur, but because length and the span are now derived from one atomic-enough read of the store, the sort can never index beyond the extent it snapshotted. This mirrors the general JSC discipline of snapshotting a resizable view’s span once and operating only within it.
Key code
Span captured first; length derived from the span (JSGenericTypedArrayViewPrototypeFunctions.h)
- size_t length = thisObject->length();
+ auto originalSpan = thisObject->typedSpan();
+ size_t length = originalSpan.size();
+
if (length < 2)
return JSValue::encode(thisObject);
- auto originalSpan = thisObject->typedSpan();
-
Vector<typename ViewClass::ElementType, 256> vector;
auto totalSize = CheckedSize { length } * 2U;
Patch walkthrough
Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h— In genericTypedArrayViewProtoFuncSortImpl the order of operations is changed so the span is captured before the length is computed. The line ‘size_t length = thisObject->length();’ is deleted and replaced by capturing ‘auto originalSpan = thisObject->typedSpan();’ and then ‘size_t length = originalSpan.size();’. The later, now-redundant second call to thisObject->typedSpan() is removed. The length < 2 early-out and the CheckedSize allocation math are unchanged but now consume the span-derived length, eliminating the two-read disagreement.JSTests/stress/growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js— Adds ’ta.sort((a, b) => a - b);’ to the loop that already exercised with/toReversed/toSorted on a TypedArray over a GSAB while another agent grows the buffer, extending regression coverage to the sort fast path.
Background
Growable SharedArrayBuffer (GSAB) — A SharedArrayBuffer created with a maxByteLength option can grow via grow(). Its byteLength is shared mutable memory visible across agents, so any thread reading it can observe a size change between two reads.
typedSpan() — Returns a std::span over the view’s live backing store, re-reading the current byte length of the ArrayBuffer and dividing by element size to compute span extent.
length() vs span.size() — Both derive from the same underlying resizable byteLength, but each call is an independent read. Using two separate calls creates a time-of-check/time-of-use window.
TypedArray.prototype.sort fast path — For plain views JSC copies elements into a scratch Vector, sorts (optionally with a JS comparator), then writes back into the backing store, bounded by the length value.
Vulnerability window
- Setup — Attacker allocates a growable SharedArrayBuffer and a TypedArray view over it, sharing the SAB with a Worker.
- Concurrency — The Worker spins calling SharedArrayBuffer.prototype.grow while the main thread repeatedly calls sort with a comparator.
- First read — sortImpl reads thisObject->length() and sizes its scratch vector and loop bound to that value.
- Grow — The Worker grows the buffer; the store pointer/length observed changes between the two reads.
- Second read — typedSpan() re-reads a different length, so length and the span extent disagree.
- OOB — The sort indexes the span using the stale length, reading/writing past the mapped store region.
- Fix — Span is captured once and length derived from span.size(), collapsing the two reads into one source of truth.
Proof of concept
The only added line is ’ta.sort((a, b) => a - b);’ inside the existing stress test growable-sharedarraybuffer-parallel-grow-during-prototype-methods.js. The harness runs a TypedArray view over a growable SharedArrayBuffer while a second agent grows the buffer in parallel; adding a comparator-driven sort exercises the two-read race in the sort fast path. It is a regression/ASAN test, not a weaponized exploit.
ta.with(0, 0x41414141);
ta.toReversed();
ta.toSorted();
ta.sort((a, b) => a - b);
Exploitation
- Race window widening — A user comparator (a, b) => … yields control during the sort, lengthening the interval in which a parallel grow can land between the length and span reads.
- Heap groom — Choosing a small initial GSAB length and a large maxByteLength maximizes the delta between the stale and grown lengths, enlarging the OOB window.
- Primitive — The OOB spans adjacent JS heap; write-back of sorted elements gives a relative write, read side gives a relative read of neighboring cells for infoleak.
- Reliability — Attacker tunes grow timing against the comparator callback to hit the window deterministically across many rounds.
Detection & hunting
For defenders and SOC / detection engineers:
- ASAN heap-buffer-overflow in genericTypedArrayViewProtoFuncSortImpl —
- Two length reads of a resizable view —
Audit directions
- Other TypedArray prototype fast paths —
- GSAB length re-reads —
- Comparator/callback reentrancy —