← All reports

[JSC] Use-after-free after growing a resizable buffer on a WebAssembly memory

CVE: CVE-2026-20664 · Safari 26.4 · Released March 24, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process crash Apple's description: The issue was addressed with improved memory handling. Credit: Yeonghyeon Choi, Daniel Rhea, Söhnke Benedikt Fischedick (Tripton), Emrovsky & Switch3301, Yevhen Pervushyn

Severity: High | Component: JSC runtime — ArrayBuffer / JSArrayBufferView | 6b357f3 | Bugzilla 306136

High. The relocation hook updated the buffer's length but not its base — so the post-grow typed array is not detached, not out-of-bounds, just pointed at a released mapping and believing it is now bigger. That is the shape of a read/write primitive, not a crash.

A typed array is a view, but only in the bookkeeping sense: the length is looked up through the buffer, while the data pointer is a private copy baked into the view at construction. That arrangement is safe exactly as long as an ArrayBuffer's storage never moves, which was true of every JS-visible buffer until WebAssembly linear memory started being exposed as one. Once WebAssembly.Memory.prototype.toResizableBuffer() handed script a buffer whose backing mapping can be swapped wholesale on growth, every cached derivation of that base — the buffer's own m_data, and each view's m_vector — became a pointer that has to be told when to move.

The angle: A page that grows a WebAssembly memory it exposed as a resizable buffer gets back a fully valid-looking typed array whose reads and writes land in freed, reclaimable memory.

Source/JavaScriptCore/runtime/ArrayBuffer.cpp

void ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory* memory)
{
ASSERT(isWasmMemory());
+
+ void* oldData = m_contents.data();
m_contents.refreshAfterWasmMemoryGrow(memory);
+ void* newData = m_contents.data();
+ if (newData == oldData)
+ return;
+
+ // JSArrayBufferViews (typed arrays) effectively cache their buffer's data pointer.
+ for (size_t i = numberOfIncomingReferences(); i--;) {
+ JSCell* cell = incomingReferenceAt(i);
+ auto* view = dynamicDowncast<JSArrayBufferView>(cell);
+ if (view)
+ view->refreshVector(newData);
+ }
}
void ArrayBufferContents::refreshAfterWasmMemoryGrow(Wasm::Memory* memory)
{
ASSERT(isResizableNonShared());
// If the memory is BoundChecking, the memory's handle is replaced with a different one when it grows.
m_memoryHandle = memory->handle();
+ m_data = memory->basePointer();
m_sizeInBytes = m_memoryHandle->size();

Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h

+WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
+
+inline void JSArrayBufferView::refreshVector(void* newData)
+{
+ // We ensure that the vector is really there because these notifications are delivered to
+ // incoming references of a buffer, and an incoming reference from a view to a buffer remains in
+ // place even after a view detaches.
+ if (hasVector()) {
+ void* newVectorPtr = static_cast<uint8_t*>(newData) + byteOffsetRaw();
+ m_vector.setWithoutBarrier(newVectorPtr);
+ }
+}
+
+WTF_ALLOW_UNSAFE_BUFFER_USAGE_END

JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js

+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+const memories = [];
+for (let i = 0; i < 500; i++) {
+ try {
+ memories.push(new WebAssembly.Memory({ initial: 1, maximum: 100 }));
+ } catch (e) { break; }
+}
+const memory = new WebAssembly.Memory({ initial: 1, maximum: 10 });
+const buffer = memory.toResizableBuffer();
+const ta = new Uint32Array(buffer);
+ta[0] = 0xCAFEBABE;
+memory.grow(1);
+for (let i = 0; i < 100; i++) {
+ const arr = new ArrayBuffer(65536);
+ new Uint32Array(arr).fill(0xDEADBEEF);
+}
+assert.eq(ta[0], 0xCAFEBABE);

Two production changes and one regression test, at two different levels of the same caching chain.

At the contents level, ArrayBufferContents::refreshAfterWasmMemoryGrow(Wasm::Memory*) previously did two things: rebind m_memoryHandle to memory->handle() and recompute m_sizeInBytes from that new handle. The patch inserts a single line between them — m_data = memory->basePointer() — so that the descriptor's own direct pointer to the memory base follows the handle it was rebound to. Note the ordering: size was already being read from the new handle, which is what made the resulting state internally inconsistent rather than merely stale.

At the buffer level, ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory*) gains a before/after comparison around the delegation. It snapshots m_contents.data(), calls down into the contents refresh, reads the pointer back, and returns early when the base did not move — signaling-mode memories that commit pages in place take this path and pay nothing. When the base did move, it walks the buffer's incoming-reference list backwards via numberOfIncomingReferences() / incomingReferenceAt(i), dynamicDowncast<JSArrayBufferView>s each cell, and calls the new refreshVector(newData) on every hit.

JSArrayBufferView::refreshVector(void*) is declared in JSArrayBufferView.h and implemented inline in JSArrayBufferViewInlines.h, wrapped in WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END for the raw pointer arithmetic. It is guarded by hasVector() — the comment explains why that guard is load-bearing: the notification is delivered over a list whose membership survives detachment, so a view that has already dropped its storage will still be visited and must be skipped rather than handed a freshly computed pointer. For views that do have storage, the new vector is newData + byteOffsetRaw(), stored with m_vector.setWithoutBarrier(...) since the target is raw memory and not a GC-visible cell.

The regression test is worth reading as a triggering recipe in its own right. It first drains the signaling/fast wasm-memory pool with up to 500 WebAssembly.Memory allocations so that the memory it actually cares about is forced into BoundsChecking mode; only then does it build the one-page memory, expose it with toResizableBuffer(), lay a Uint32Array over it, write 0xCAFEBABE at index 0, grow by a page, spray 100 × 64 KiB buffers filled with 0xDEADBEEF, and assert the sentinel is still readable through the view.

WebAssembly linear memory and growth. A WebAssembly.Memory owns a linear memory region that script can enlarge in 64 KiB pages by calling memory.grow(n). JSC backs that region in one of two modes. In signaling (fast) mode, the engine takes a large virtual reservation up front and growth commits additional pages inside it, so the base address is stable for the memory's lifetime. In BoundsChecking mode, the mapping is sized to the current need and growth obtains a different mapping. Signaling-mode memories are expensive in address space, so a process can only hold a limited number of them at once; once that budget is exhausted, subsequent memories fall back to bounds-checking mode. Which mode a given memory lands in is therefore a function of what else the page has already allocated.

BufferMemoryHandle. Each mapping is owned by a refcounted BufferMemoryHandle; tryAllocateResizableMemory constructs one with adoptRef(*new BufferMemoryHandle(...)). ArrayBufferContents::m_memoryHandle holds a reference to the handle for memory-backed buffers, which is what keeps the mapping alive from the ArrayBuffer side.

toResizableBuffer() and ArrayBufferContents. WebAssembly.Memory.prototype.toResizableBuffer() exposes a wasm memory to JavaScript as a resizable non-shared ArrayBuffer; the surrounding code gates this API family on Options::useWasmMemoryToBufferAPIs(). ArrayBufferContents is the storage descriptor behind an ArrayBuffer — it holds m_data (a direct pointer to the base), m_sizeInBytes, m_maxByteLength, and, for memory-backed buffers, m_memoryHandle. m_data is set once at construction: ArrayBufferContents::ArrayBufferContents(void*, size_t, size_t, Ref<BufferMemoryHandle>&&) initializes m_data(data).

JSArrayBufferView::m_vector and view modes. Every typed array and DataView carries its own CagedBarrierPtr<Gigacage::Primitive, void> pointing at the first byte the view covers: the buffer base plus byteOffsetRaw(), computed at construction. Indexed element access reads and writes through m_vector directly rather than re-deriving the address from the buffer on each access. hasVector() distinguishes a view that currently has storage from one that has been detached. A view constructed over an ArrayBuffer uses one of the WastefulTypedArray / DataViewMode families — here ResizableNonSharedAutoLengthWastefulTypedArray — in which the buffer owns the storage and the view does not, and an auto-length view derives its length from the buffer's current byte length at access time. isArrayBufferViewOutOfBounds computes that length from view->possiblySharedBuffer().

Incoming references. An ArrayBuffer tracks the JS cells that reference it, enumerable with numberOfIncomingReferences() and incomingReferenceAt(i). This list exists for garbage-collection purposes — its membership rule is reachability, not subscription.

This is a use-after-free through a stale cached base pointer that survived a backing-store relocation, made worse by the fact that the length half of the same description was refreshed correctly.

  BoundsChecking wasm memory, memory.grow(1):

   handle A (64 KiB)                 handle B (128 KiB)
   base 0x1000_0000  ──released──►   base 0x2000_0000
        ▲                                 ▲
        │ m_vector  (stale)               │ m_memoryHandle  (refreshed)
        │ m_data    (stale)               │ m_sizeInBytes = 131072 (refreshed)
        │                                 │
   ┌────┴──────────┐               ┌──────┴────────┐
   │ Uint32Array   │──── length ──►│ ArrayBuffer   │
   │ (not detached)│   131072      │ (not detached)│
   └───────────────┘               └───────────────┘

Read the diagram top-down: growth on a bounds-checking memory does not extend handle A, it produces handle B at a new address. ArrayBufferContents::refreshAfterWasmMemoryGrow was written as the notification hook for exactly that event, and it did rebind m_memoryHandle to B and recompute m_sizeInBytes from B — but it left m_data pointing at A's base, the value it had received once at construction and never revisited. So the right-hand column of the diagram moved forward and the left-hand column did not.

The rebinding is also what makes the left column dangling rather than merely stale. Assigning the new handle to m_memoryHandle drops the contents' reference to A. When that reference was the last one, A's mapping is released — and now m_data, along with every view's m_vector, addresses freed memory. Nothing in the object graph records this. The buffer is not detached. The views are not detached; hasVector() still reports storage. And the out-of-bounds check does not help either, because isArrayBufferViewOutOfBounds consults the buffer's byte length, which the refresh did update — to B's larger size:

template<typename Getter>
bool isArrayBufferViewOutOfBounds(JSArrayBufferView* view, Getter& getter)

The predicate is asking the right question of the wrong pointer's allocation. It reads the length from the refreshed right-hand column while every actual access reads the base from the unrefreshed left-hand column. The two halves describe different allocations, and the safety check happens to sit on the half that moved.

What falls out is a typed array that presents to script as completely ordinary — live, in-bounds, and, after the grow, one page longer than it used to be — whose element accesses resolve into a released mapping. Concretely, from the script side:

  1. Allocate enough WebAssembly.Memory objects to exhaust the signaling-mode budget, so the next memory is backed in BoundsChecking mode.
  2. Create that memory, call toResizableBuffer(), and construct a Uint32Array over the resulting buffer.
  3. Call memory.grow(1). Handle A is released; the view's m_vector still points into it, and the view's length now reflects B.
  4. Allocate to reclaim A's address range with content of the attacker's choosing.
  5. Read and write through the view's ordinary indexed accessors.

The regression test is steps 1–4 verbatim, with step 5 reduced to a sentinel check: it sprays 100 × 64 KiB buffers filled with 0xDEADBEEF and asserts that ta[0] still reads back 0xCAFEBABE. Before the fix, that assertion is the freed page being observed after someone else took it.

The security consequence is a memory-safety break inside the WebContent process. The engine's assumption is that typed-array bounds checking suffices to confine element access to the buffer's storage, and that holds only when the base pointer and the length describe the same allocation. An attacker who reclaims the released mapping with chosen content could then read and write it through ordinary indexed access on a view the engine still considers valid, plus reach linearly past the old mapping's end because the bounds check uses the larger refreshed length — byte-granular read and write at attacker-chosen offsets over reclaimed memory. That is the customary stepping stone: an info leak of whatever metadata or pointers land in the reclaimed region, and a corruption primitive against the same, from which arbitrary read/write in the renderer could be constructed. Exploitation would remain inside the WebContent sandbox; a separate escape would still be required, and no GPU, Networking, or UI process appears anywhere in this diff.

The fix restores the invariant at both levels. The added m_data = memory->basePointer() makes the contents' own base track the handle it was just rebound to, and the incoming-reference walk propagates the new base to every view's m_vector as newData + byteOffsetRaw() — preserving each view's offset into the buffer while re-anchoring it to the current mapping. The newData == oldData early-out means signaling-mode growth, where the base genuinely never moves, skips the walk entirely.

Growth refreshed the buffer's length from the new mapping and its base from nothing, leaving a live typed array whose bounds check described one allocation and whose accesses hit another, freed one.

The missing m_data assignment is the smaller half of the story. The larger half is structural: exposing wasm linear memory as a resizable ArrayBuffer converted that backing store from address-stable to relocatable, and in one stroke every pointer derived from the base became a liability. The refresh hook introduced alongside that feature covered the owner object but not the transitive closure of caches — and the level it missed, JSArrayBufferView::m_vector, is precisely the one the language hands directly to script.

Two details about the fix itself are worth carrying forward. It repurposes the GC incoming-reference list as an observer list, and the comment on refreshVector is candid about the mismatch — "an incoming reference from a view to a buffer remains in place even after a view detaches." A registry whose membership rule is liveness rather than subscription will over-deliver to some observers (handled here by the hasVector() guard) and, more dangerously, silently omit any observer that is not a JS cell. And the bug's severity is amplified by the very asymmetry the early-out now guards: because the relocation refreshed the length while leaving the base alone, the pre-fix state was not a detached or out-of-bounds view that fast paths would reject, but a view that looked perfectly valid and slightly larger than its freed storage.