← WebKit — 2026-W37

ae97aa471a12f25db388f1e95cc99f03a290d295  [JSC] Use fast parh of `Array#copyWithIn` even if an array having a hole and indexed as `ArrayWithInt32`, `ArrayWithDouble`, and `ArrayWithContiguous`

severity high class OOB-write confidence 0.78 signal score 7.67 Source/JavaScriptCore/runtime patch-now AI review PLAUSIBLE
Primitive: Out-of-bounds read/write on JSArray butterfly in fastCopyWithin when vectorLength < length
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.
Structural signals: security_assert (+4.87), msg_feature (-1.0), area:Source/JavaScriptCore/runtime (+2.5), surgical (+1.3)

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

  1. Setup — Script creates an array in an Int32/Contiguous/Double shape and arranges for its public length to exceed the butterfly’s vectorLength.
  2. Trigger — Script calls arr.copyWithin(target, start, end) selecting to/from/count that extend toward or past vectorLength.
  3. Span build — Pre-patch: fastCopyWithin forms source/destination spans sized from length without a vectorLength check.
  4. Corruption — The copy reads and writes butterfly slots past the allocation -> OOB read and OOB write of controlled JSValues/doubles.
  5. 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

  1. Reach — Array.prototype.copyWithin is directly callable from web JS; fastCopyWithin runs in the renderer’s JSC.
  2. Trigger — Drive copyWithin on an array whose vectorLength is smaller than its length so the spans extend past allocated storage.
  3. Primitive — Adjacent-slot OOB read plus an OOB write of WriteBarrier (JSValue) or double words past the butterfly - a controlled relative heap write.
  4. 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.
  5. 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.

Before / after

Loading diff…