← WebKit Silent-Fix Report — 2026-W22

a271abef22  [JSC] Fix vector caching in TypedArray.prototype.forEach

severity high class UAF confidence 0.72 JSC TypedArray prototype exploitable-grade
Shu-yu Guo Wed May 27 06:08:00 2026 -0700 full: a271abef225ed90f7f5f917fae417077eb0855aa bug report ↗ view on GitHub ↗
Primitive: stale typed array vector pointer after backing-store transition
Triage note: forEach cached typedVector() but the vector can move (non-wasteful→wasteful, Wasm memory realloc), so cached reads hit freed/stale memory.
Contents

The bug at a glance

TypedArray.prototype.forEach cached the backing vector pointer for the common fixed-length non-shared case, but that vector can move during iteration — a non-wasteful typed array materializing its ArrayBuffer (transition to wasteful, new backing store) or a BoundsChecking Wasm memory reallocating — so the user callback can cause a relocation and subsequent element reads use a stale, freed pointer. That is an attacker-reachable use-after-free / OOB read from plain JS. High is appropriate for a heap UAF in a core TypedArray builtin.

forEach assumed that !isResizableNonShared implies a stable vector and cached array = thisObject->typedVector() once, but two cases move the vector even for fixed-length non-shared arrays: lazy ArrayBuffer materialization (non-wasteful->wasteful backing-store change) and BoundsChecking Wasm memory reallocation, both triggerable from inside the forEach callback via ta.buffer.

Root cause

typedArrayViewForEachImpl implements the fast iteration for TypedArray.prototype.forEach. The original code took the !thisObject->isResizableNonShared() fast path and cached the data pointer once: auto* array = thisObject->typedVector();, then in each iteration read nativeValue = array[index]; guarded only by !thisObject->isDetached(). The assumption was that a fixed-length, non-shared typed array’s storage never moves, so the cached pointer stays valid for the whole loop and only detachment needs re-checking.

That assumption is false in two documented situations. First, a “non-wasteful” typed array — one created without yet allocating a standalone ArrayBuffer — keeps its data inline/in a fast allocation. If script observes its .buffer (as the repro does with ta.buffer;), JSC must materialize a real ArrayBuffer, transitioning the typed array to “wasteful” and moving the backing store to a new allocation; typedVector() then points somewhere new and the old pointer is stale/freed. Second, a BoundsChecking Wasm memory can reallocate its backing store (grow), again relocating the vector. Both of these can be triggered from inside the user callback passed to forEach, because the callback runs arbitrary JS between element reads. So after the first callback invocation moves the vector, subsequent array[index] reads dereference the stale pointer — a use-after-free read of freed memory (and potentially an OOB read if the new allocation is smaller/elsewhere), and the values returned to script are attacker-influenced stale/freed heap contents.

The fix keeps the fast path but only caches the pointer when the vector is provably stable: const bool hasStableVector = thisObject->hasArrayBuffer() && !thisObject->possiblySharedBuffer()->isWasmMemory();. If the typed array already has a (non-Wasm) ArrayBuffer, its vector will not move, so array = thisObject->typedVector() is cached. Otherwise array is nullptr and each iteration reloads the pointer fresh: nativeValue = (hasStableVector ? array : thisObject->typedVector())[index];. This reloads the vector on every element for the not-yet-materialized and Wasm-memory cases, so a relocation triggered by the callback is picked up on the next read instead of dereferencing the stale pointer. The loop bodies for forward/reverse are refactored into a shared forEachLoop lambda so both the fast and resizable paths reuse the same iteration/exception logic.

Key code

JSGenericTypedArrayViewPrototypeFunctions.h: reload vector when it can move

    if (!thisObject->isResizableNonShared()) [[likely]] {
        // Fixed-length TypedArrays backed by non-shared ArrayBuffers cannot shrink and go out of
        // bounds, and only need to check for detachment.
        //
        // But they may have their vectors move:
        // - Non-wasteful TypedArrays that don't have a backing ArrayBuffer yet can transition to
        //   being wasteful, and having an ArrayBuffer and change backing stores.
        // - BoundsChecking Wasm memories can reallocate.
        const bool hasStableVector = thisObject->hasArrayBuffer() && !thisObject->possiblySharedBuffer()->isWasmMemory();
        auto* array = hasStableVector ? thisObject->typedVector() : nullptr;

        forEachLoop([&](size_t index) ALWAYS_INLINE_LAMBDA -> IterationStatus {
            JSValue element = jsUndefined();
            auto nativeValue = ViewClass::Adaptor::toNativeFromUndefined();
            if (!thisObject->isDetached()) [[likely]] {
                nativeValue = (hasStableVector ? array : thisObject->typedVector())[index];
                element = ViewClass::Adaptor::toJSValue(globalObject, nativeValue);
                RETURN_IF_EXCEPTION_WITH_TRAPS_DEFERRED(scope, { });
            }

            return functor(element, index, nativeValue);
        });

        return;
    }

Patch walkthrough

  • Source/JavaScriptCore/runtime/JSGenericTypedArrayViewPrototypeFunctions.h — typedArrayViewForEachImpl is restructured. The forward/reverse iteration is extracted into a shared forEachLoop lambda. In the !isResizableNonShared fast path, instead of unconditionally caching array = thisObject->typedVector(), it computes hasStableVector = thisObject->hasArrayBuffer() && !thisObject->possiblySharedBuffer()->isWasmMemory(); caches the pointer only when stable, and otherwise reloads thisObject->typedVector() on each element access via (hasStableVector ? array : thisObject->typedVector())[index]. This prevents reads through a stale pointer after the backing store moves (ArrayBuffer materialization or Wasm memory realloc) mid-iteration.

Background

typedVector() — Returns the raw pointer to a typed array’s backing element storage. Valid only as long as the backing store does not move; caching it across arbitrary JS is unsafe if the vector can be relocated.

Non-wasteful vs wasteful typed array — A non-wasteful typed array has no standalone ArrayBuffer yet; observing .buffer materializes one (wasteful), reallocating the backing store and moving the vector.

isResizableNonShared — Distinguishes resizable/growable-shared arrays (which have their own bounds handling path) from fixed-length non-shared ones; the bug lived in the assumed-stable fixed-length path.

BoundsChecking Wasm memory — A Wasm memory mode whose backing store can be reallocated on growth; a typed array over such memory can have its vector moved during iteration.

Detachment vs relocation — The old code only re-checked isDetached() each iteration, but relocation (vector moves while still attached) is a distinct hazard that detachment checks do not cover.

Vulnerability window

  1. Enter forEach — forEach on a fixed-length non-shared Int32Array takes the fast path; pre-fix it caches array = typedVector().
  2. Callback side effect — On index 0 the user callback reads ta.buffer, materializing the ArrayBuffer and moving the backing store to a new allocation.
  3. Stale read — For index 1..n the loop reads array[index] through the now-freed old pointer.
  4. UAF / stale values — The returned values come from freed/reallocated memory; the repro overwrites via ta[j]=0xDEAD to show the reads are stale (see 0xDEAD vs stale).
  5. Fixed — With hasStableVector false for the not-yet-materialized case, each read reloads typedVector() and sees the moved store.

Proof of concept

On the first element the callback touches ta.buffer, forcing the non-wasteful typed array to materialize an ArrayBuffer and move its backing store, then rewrites all elements to 0xDEAD in the new store. On a vulnerable build forEach keeps reading via the cached stale pointer and observes the old values (not 0xDEAD), throwing; the fix reloads the vector so subsequent reads see 0xDEAD. It doubles as a demonstration that reads occur through freed memory.

let ta = new Int32Array(100);
for (let i = 0; i < 100; i++) ta[i] = i;

ta.forEach((v, i) => {
    if (i === 0) {
        ta.buffer;

        for (let j = 1; j < 100; j++) {
            ta[j] = 0xDEAD;
        }
    } else {
        if (v !== 0xDEAD) {
            throw new Error("read stale value at index ", i);
        }
    }
});

Exploitation

  1. Prepare — Create a non-wasteful typed array (or one over BoundsChecking Wasm memory) and call forEach with a controlled callback.
  2. Relocate — Inside the callback, force materialization via .buffer (or grow the Wasm memory) so the backing store moves and the old allocation is freed.
  3. Groom — Reoccupy the freed old allocation with attacker-controlled objects/data.
  4. Leak/confuse — Let forEach read through the stale pointer to disclose reallocated heap contents, or drive an OOB read for an info leak feeding further exploitation.

Detection & hunting

For defenders and SOC / detection engineers:

  • Stale-value assertions
  • ASAN in forEach
  • Materialization during iteration

Audit directions

  • Cached vector pointers
  • Materialization triggers
  • hasStableVector predicate
  • Reverse iteration

Before / after

Loading diff…