ae97aa471a12f25db388f1e95cc99f03a290d295 [JSC] Use fast parh of `Array#copyWithIn` even if an array having a hole and indexed as `ArrayWithInt32`, `ArrayWithDouble`, and `ArrayWithContiguous`
Triage note: JSArray::fastCopyWithin replaces the containsHole(data, length) guard with RELEASE_ASSERT(butterfly->vectorLength() >= length) before building spans {data+to,count}/{data+from,count}, and switches the Int32 case to contiguousInt32(). The new vectorLength assertion closes a case where the copy spans could run past the allocated butterfly storage, a classic JSC array copyWithin memory-safety bug reachable from script.
The bug at a glance
JavaScriptCore Array.prototype.copyWithin fast path (JSArray::fastCopyWithin) in the renderer. For ArrayWithInt32/ArrayWithContiguous/ArrayWithDouble the code built source/destination spans {data+from,count} and {data+to,count} over the butterfly’s contiguous storage after only a containsHole() check, never asserting the butterfly’s vectorLength covers length. When vectorLength() < length the spans run past the allocated butterfly, giving an out-of-bounds read and write of butterfly slots directly from script. The patch’s fix is a RELEASE_ASSERT(butterfly->vectorLength() >= length), i.e. it converts the condition into a controlled crash rather than silent corruption, which both confirms the memory-safety defect and bounds the residual to a reachable crash if the pre-condition is violated. High is defensible for a script-reachable JSC array OOB read/write.
copyWithin’s fast path trusted length (the public array length) to be covered by the butterfly’s vectorLength, but the two can diverge, so the span arithmetic indexes storage that was never allocated.
Root cause
The invariant is that a copy over contiguous butterfly storage must stay within vectorLength() (the number of allocated slots), yet fastCopyWithin sized its spans from count derived from length (the public length) and only guarded with containsHole(data, length). containsHole detects empty slots but says nothing about whether the slots exist at all; if vectorLength() < length the array has a public length larger than its allocated vector.
Pre-patch sequence (ArrayWithInt32/ArrayWithContiguous case): 1. data = butterfly()->contiguous().data() (a raw pointer into allocated storage). 2. if (containsHole(data, length)) return false - bails only on holes, which also dereferences up to length. 3. std::span<WriteBarrier> destination { data + to, count } and source { data + from, count } are formed, where to/from/count are bounded by length, not vectorLength. 4. The subsequent copy reads source and writes destination; when length exceeds vectorLength, both from+count and to+count index past the allocated butterfly -> OOB read of adjacent heap and OOB write of WriteBarrier slots (controlled JS values) past the array. The ArrayWithDouble case is identical with double storage. Because WriteBarrier holds JSValues, the OOB write lets script place controlled 64-bit patterns beyond the butterfly - the classic copyWithin butterfly corruption.
The fix replaces the containsHole guard with RELEASE_ASSERT(butterfly->vectorLength() >= length), re-reads the butterfly into a local Butterfly*, and (Int32 case) switches the accessor to contiguousInt32().data(). The RELEASE_ASSERT is the load-bearing change: it enforces the allocation covers length before any span is built, so a violation now deterministically crashes instead of corrupting memory. Note this is a defensive assert rather than a graceful fallback (the old containsHole path returned false to a slow path); the vendor chose to treat vectorLength < length as a must-not-happen condition and crash-guard it, which both proves the prior OOB and bounds the fix’s behavior to a controlled abort.
Key code
containsHole guard replaced with a vectorLength RELEASE_ASSERT before span construction
case ArrayWithInt32:
case ArrayWithContiguous: {
Butterfly* butterfly = this->butterfly();
RELEASE_ASSERT(butterfly->vectorLength() >= length);
auto data = butterfly->contiguousInt32().data();
std::span<WriteBarrier<Unknown>> destination { data + to, count };
std::span<const WriteBarrier<Unknown>> source { data + from, count };
...
case ArrayWithDouble: {
Butterfly* butterfly = this->butterfly();
RELEASE_ASSERT(butterfly->vectorLength() >= length);
auto data = butterfly->contiguousDouble().data();
std::span<double> destination { data + to, count };
std::span<double> source { data + from, count };
Fix approach
The patch stops relying on containsHole(data, length) as the only precondition and instead asserts the structural invariant directly: RELEASE_ASSERT(butterfly->vectorLength() >= length) before computing any pointer or span. It takes a fresh Butterfly* local (so the pointer and the vectorLength come from the same snapshot) and, for the Int32/contiguous case, reads through contiguousInt32().data(). The semantic change is that a divergence between public length and allocated vectorLength is now a fatal, deterministic RELEASE_ASSERT rather than a silent out-of-bounds copy; the hot path for well-formed arrays is unchanged except for dropping the hole scan. It does not re-introduce a graceful slow-path fallback - violating the invariant crashes by design.
Patch walkthrough
Source/JavaScriptCore/runtime/JSArray.cpp— In JSArray::fastCopyWithin, for both the Int32/Contiguous and Double cases, removes the containsHole(data, length) early-return and inserts RELEASE_ASSERT(butterfly->vectorLength() >= length) before building the source/destination spans; also snapshots the butterfly into a local and uses contiguousInt32().data() in the integer case.
Background
Butterfly — JSC’s backing store for an object’s indexed (and named) properties; indexed elements live in contiguous slots whose count is vectorLength().
vectorLength vs public length — vectorLength() is how many element slots are actually allocated; an array’s public length can exceed vectorLength (e.g. after length assignment) so the two must not be conflated when bounding a copy.
Indexing types (ArrayWithInt32/Contiguous/Double) — Specialized storage shapes; contiguous()/contiguousInt32() expose JSValue (WriteBarrier) slots, contiguousDouble() exposes raw doubles - all of fixed, vectorLength-bounded size.
containsHole — Scans [data, data+length) for empty/hole entries; it detects holes but does not validate that those slots are within the allocated vector, so it is not a bounds check.
RELEASE_ASSERT — An assertion compiled into release builds that aborts the process if the condition fails; used here as a hard safety gate converting an OOB into a deterministic crash.
Vulnerability window
- Setup — Script creates an array in an Int32/Contiguous/Double shape and arranges for its public length to exceed the butterfly’s vectorLength.
- Trigger — Script calls arr.copyWithin(target, start, end) selecting to/from/count that extend toward or past vectorLength.
- Span build — Pre-patch: fastCopyWithin forms source/destination spans sized from length without a vectorLength check.
- Corruption — The copy reads and writes butterfly slots past the allocation -> OOB read and OOB write of controlled JSValues/doubles.
- Mitigated — Post-patch: RELEASE_ASSERT(vectorLength >= length) aborts before any OOB access.
Proof of concept
No regression test ships in the diff. Conceptual trigger: in script, obtain an array whose public length exceeds its butterfly vectorLength while keeping it in a fast indexing shape (Int32/Contiguous/Double), then call Array.prototype.copyWithin with target/start/end that make count large enough that from+count or to+count exceeds vectorLength. Pre-patch this reads and writes butterfly slots out of bounds with script-controlled values. Precisely constructing length > vectorLength in a fast shape is the gating step and depends on the exact allocation/transition path, which the diff does not enumerate (that path is inferred); post-patch the RELEASE_ASSERT turns the attempt into a crash.
Exploitation
- Reach — Array.prototype.copyWithin is directly callable from web JS; fastCopyWithin runs in the renderer’s JSC.
- Trigger — Drive copyWithin on an array whose vectorLength is smaller than its length so the spans extend past allocated storage.
- Primitive — Adjacent-slot OOB read plus an OOB write of WriteBarrier (JSValue) or double words past the butterfly - a controlled relative heap write.
- Shaping — Classic JSC escalation path (inferred): place a victim object’s butterfly/length or a typed-array backing adjacent to the corrupted array to turn the relative write into a length-overwrite or fakeobj/addrof primitive.
- Gate — Escalation beyond a crash depends on reliably achieving length > vectorLength in a fast shape and on heap-grooming the adjacency; the RELEASE_ASSERT means any residual trigger now aborts rather than corrupts.
Detection & hunting
For defenders and detection engineers:
- RELEASE_ASSERT aborts in fastCopyWithin —
- copyWithin on length>vectorLength arrays —
- Butterfly-adjacent corruption —
Remaining risk in the patch
- Crash-not-fallback — The fix is a RELEASE_ASSERT, so if any legitimate path can still present length > vectorLength the result is a reachable denial-of-service abort rather than graceful handling; whether such a benign path exists is not shown in the diff.
- Int32 accessor change — The Int32/Contiguous case now reads through contiguousInt32().data() for both indexing types; the diff does not show that this returns identical storage for ArrayWithContiguous, so the equivalence is assumed (inferred).
- count/to/from derivation — The assert bounds length, but to/from/count are computed earlier (not in the diff); the fix is only complete if those remain <= length so that vectorLength >= length also covers from+count and to+count.
Audit directions
- Other fast array copies — Audit fastSlice, splice, fill, and sort fast paths for the same length-vs-vectorLength conflation before span/pointer arithmetic.
- containsHole misuse — Find other call sites that treat containsHole() as a bounds check rather than a hole scan.
- Butterfly snapshot discipline — Verify every fast path reads butterfly() once and derives both pointer and vectorLength from the same snapshot to avoid TOCTOU across reallocation.