← WebKit — 2026-W38

9e7715c0435339a24c51e4b2b22e1d44ff1f6766  [JSC] Update MemoryHandle in place when growing non-shared bounds-checking memory

severity high class UAF confidence 0.72 signal score 11.57 Source/JavaScriptCore/runtime patch-now AI review CONFIRMED
Primitive: resizable non-shared Wasm memory grow left ArrayBuffer/views pointing at freed Gigacage pages
Triage note: RC_REMOVE_SEC_ACCEL_3 marks the fix: WasmMemory::grow no longer swaps in a fresh BufferMemoryHandle (making m_handle const) but calls replaceAllocatedMemory() to update the existing handle in place, so ArrayBufferContents sharing that handle (via refreshAfterWasmMemoryGrow reading m_memoryHandle) see the new block instead of a stale/freed pointer; also plugs a physical-bytes leak on OOM.
Structural signals: smartptr_member_added (+3.0), security_assert (+4.87), null_guard (+1.2), area:Source/JavaScriptCore/runtime (+2.5)

The bug at a glance

Growing a resizable, non-shared WebAssembly memory reallocated its Gigacage-backed block and swapped in a fresh BufferMemoryHandle, while the associated ArrayBufferContents (and the typed-array views over it) were refreshed from a diverging source, so after a grow a JS ArrayBuffer/view could still address the old, replaced Gigacage block. This is in JavaScriptCore (WebContent) and web-reachable via WebAssembly.Memory.prototype.grow() on a resizable memory. The diff supports a stale/dangling data-pointer defect on grow; because the old block’s pages are released on the handle swap/replace, this reads as a use-after-free of freed Gigacage pages still reachable from JS (UAF/high is reasonable). The exact moment the old block is freed across the pre-patch swap is not fully shown, so whether every path is strictly use-after-free vs use-after-stale is (inferred). A secondary physical-bytes accounting leak on OOM is also fixed.

Resizable (BoundsChecking) Wasm memory actually reallocates on grow, so every ArrayBuffer/typed-array view caching the base pointer must be re-pointed; the bug was that grow replaced the whole memory handle, leaving the refresh source and the freed block out of sync.

Root cause

The invariant is that after a resizable non-shared Wasm memory grows, every ArrayBufferContents backed by it must observe the live memory block, and the old block must not be freed while anything can still reach it. For BoundsChecking (non-signaling) memory, grow allocates a new, larger Gigacage Primitive region, memcpy’s the old contents in, and must move all consumers to the new base.

Pre-patch (RC_REMOVE_SEC_ACCEL_3 branch): 1. Memory::grow() allocates newMemory via Gigacage::tryAllocateZeroedVirtualPages; 2. memcpy(newMemory, basePointer(), size()); 3. constructs a brand-new BufferMemoryHandle owning newMemory, transfers the instance anchors, and does m_handle = WTF::move(newHandle), dropping the old handle; 4. ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory*) is invoked from JSWebAssemblyMemory::growSuccessCallback and refreshes m_contents from the passed memory. The hazard is the handle swap: the ArrayBufferContents is keyed to a BufferMemoryHandle (m_memoryHandle), and replacing m_handle with a different object diverges the handle the buffer references from the one Memory now uses, while the old handle’s BoundsChecking/Default block is released via Gigacage::freeVirtualPages in its destructor. The result is that an ArrayBuffer/typed-array view can retain a data pointer into the freed/replaced old Gigacage block.

The fix removes the swap: Memory::m_handle becomes const Ref and grow calls m_handle->replaceAllocatedMemory(newMemory, desiredSize), which updates the single handle in place — m_memory = CagedMemory(newMemory), m_mappedCapacity/updateSize, then freePhysicalBytes(oldSize) and freeVirtualPagesUnlessNullBasePointer(oldMemory, oldSize) only after the handle already points at the new block. ArrayBufferContents::refreshAfterWasmMemoryGrow() (now parameterless) reads m_data = m_memoryHandle->memory() and m_sizeInBytes = m_memoryHandle->size() from that same, now-updated handle, so every consumer sharing the handle transparently sees the new block. These objects are Ref/ThreadSafeRefCounted (BufferMemoryHandle is ThreadSafeRefCounted; grow runs on the JS thread), so the defect is a block-lifetime/identity bug on the ref-counted handle, not a refcount underflow. I keep the triage UAF class; the precise free-timing across the old swap is (inferred) from the destructor/replace logic shown.

Key code

Update the memory block in place instead of swapping the handle

// WasmMemory.cpp grow():
-    auto newHandle = adoptRef(*new BufferMemoryHandle(newMemory, desiredSize, desiredSize, initial(), maximum(), sharingMode(), MemoryMode::BoundsChecking));
-    m_handle->transferAnchors(newHandle.get());
-    m_handle = WTF::move(newHandle);
+    m_handle->replaceAllocatedMemory(newMemory, desiredSize);

// BufferMemoryHandle::replaceAllocatedMemory():
    m_memory = CagedMemory(newMemory);
    m_mappedCapacity = newSize;
    updateSize(newSize);
    BufferMemoryManager::singleton().freePhysicalBytes(oldSize);
    freeVirtualPagesUnlessNullBasePointer(oldMemory, oldSize);

Fix approach

The patch makes the BufferMemoryHandle identity stable across grow. Memory::m_handle is declared const Ref, and grow() no longer allocates a replacement handle or transferAnchors(); it calls m_handle->replaceAllocatedMemory(newMemory, desiredSize) which, under RELEASE_ASSERTs that the handle is Default/BoundsChecking, repoints m_memory to the new caged block, updates mappedCapacity and size, and only then frees the old physical bytes and virtual pages (via the new freeVirtualPagesUnlessNullBasePointer helper, which skips the shared nullBasePointer sentinel). ArrayBufferContents::refreshAfterWasmMemoryGrow() loses its Wasm::Memory* parameter and simply reads memory()/size() off its own m_memoryHandle — the same object Memory just updated — so the ArrayBuffer and its views are re-pointed to the live block with no second handle in play. Semantically, grow becomes an in-place mutation of one handle shared by Memory and ArrayBufferContents, collapsing the two divergent handles into one. The OOM path additionally calls freePhysicalBytes(desiredSize) so a failed allocation no longer leaks the reserved physical-byte accounting.

Patch walkthrough

  • Source/JavaScriptCore/runtime/ArrayBuffer.cpp — ArrayBuffer::refreshAfterWasmMemoryGrow and ArrayBufferContents::refreshAfterWasmMemoryGrow drop the Wasm::Memory* parameter; the contents version now asserts isResizableNonShared() and sets m_data/m_sizeInBytes directly from m_memoryHandle->memory()/size().
  • Source/JavaScriptCore/runtime/ArrayBuffer.h — Declarations updated to the parameterless refreshAfterWasmMemoryGrow() and the Wasm::Memory forward-declare is no longer needed in the fixed build.
  • Source/JavaScriptCore/runtime/BufferMemoryHandle.cpp — Adds freeVirtualPagesUnlessNullBasePointer() and replaceAllocatedMemory(), which repoints the caged memory and size in place then frees the old physical bytes and virtual pages; transferAnchors() is dropped from the fixed build.
  • Source/JavaScriptCore/runtime/BufferMemoryHandle.h — Declares replaceAllocatedMemory() and removes transferAnchors() for the fixed build.
  • Source/JavaScriptCore/wasm/WasmMemory.cpp — Memory::grow() frees physical bytes on OOM and, on success, calls m_handle->replaceAllocatedMemory() instead of constructing/transferring/swapping a new handle.
  • Source/JavaScriptCore/wasm/WasmMemory.h — m_handle becomes const Ref, enforcing that the handle object is never replaced for a resizable non-shared memory.
  • Source/JavaScriptCore/wasm/js/JSWebAssemblyMemory.cpp — growSuccessCallback calls the parameterless m_buffer->refreshAfterWasmMemoryGrow() for the non-shared resizable case.

Background

Resizable non-shared Wasm memory (BoundsChecking) — A WebAssembly.Memory that can grow and is not shared; in BoundsChecking mode it does not reserve max up front, so growing reallocates the backing block rather than extending it.

Gigacage (Primitive) — JSC’s caged allocator region for primitive buffers; Wasm memory blocks are Gigacage::Primitive virtual pages, and freeing them returns the pages to the cage.

BufferMemoryHandle — The ThreadSafeRefCounted owner of a Wasm/ArrayBuffer memory block (caged pointer, size, mode, sharing); ArrayBufferContents keys to one via m_memoryHandle.

ArrayBufferContents / typed-array views — The backing store descriptor (data pointer + size) for a JS ArrayBuffer; typed-array views cache the base pointer and must be refreshed when the block moves.

growSuccessCallback / refreshAfterWasmMemoryGrow — After a successful grow, JSWebAssemblyMemory notifies the associated ArrayBuffer so it can re-point its contents to the new (or in-place updated) memory block.

freePhysicalBytes / BufferMemoryManager — JSC tracks a physical-byte budget for Wasm memories; failing to release it on OOM or on reallocation leaks the accounting and can wrongly throttle future allocations.

Vulnerability window

  1. Setup — Script creates a resizable non-shared WebAssembly.Memory and obtains its ArrayBuffer, creating typed-array views that cache the base pointer.
  2. Trigger — Script calls memory.grow(delta), which for BoundsChecking memory reallocates a larger Gigacage Primitive block and memcpy’s the old contents.
  3. Divergence/free — Pre-patch, grow swaps in a fresh BufferMemoryHandle and the old handle’s block is released, while the ArrayBufferContents’ handle reference and the refresh source diverge.
  4. Stale access — An ArrayBuffer/typed-array view retains a data pointer into the freed/replaced old block.
  5. Use — JS reads/writes through that view, touching freed Gigacage pages (UAF of cage memory still reachable from script).

Proof of concept

No regression test ships in the diff. Conceptual trigger (web path): const mem = new WebAssembly.Memory({ initial: 1, maximum: 100 }); // resizable, non-shared; let buf = mem.buffer; let view = new Uint8Array(buf); mem.grow(10); then access view/buf after grow. Pre-patch the view can still reference the old, replaced Gigacage block; whether a given access is strictly use-after-free vs use-after-stale depends on the exact free timing of the swapped-out handle (inferred). Reliably reclaiming the freed cage pages with controlled data is the escalation step and is not shown.

Exploitation

  1. Reach — Ordinary untrusted script via the WebAssembly JS API (resizable Memory + its ArrayBuffer/views); no special privileges.
  2. Trigger — Grow a resizable non-shared memory so its BoundsChecking block is reallocated while views still reference the old base.
  3. Primitive (primary) — A JS-reachable ArrayBuffer/typed-array view pointing at a freed/replaced Gigacage Primitive block — a dangling data pointer usable for read and write from script.
  4. Primitive (secondary) — If the freed cage pages are reallocated to another primitive buffer, the stale view yields cross-object read/write within the Primitive gigacage.
  5. Open question (gating) — Whether the freed block can be reliably reclaimed by an attacker-chosen allocation (and whether Gigacage confinement limits the reachable targets) gates escalation from a cage-confined UAF to a stronger primitive (inferred).

Detection & hunting

For defenders and detection engineers:

  • Handle swap on grow — Flag any Wasm Memory grow path that replaces its BufferMemoryHandle rather than updating in place while ArrayBufferContents still reference the old handle.
  • Stale ArrayBuffer base after grow — ASan/debug JSC asserting that ArrayBufferContents::data() matches the live BufferMemoryHandle::memory() after refreshAfterWasmMemoryGrow().
  • Physical-byte budget drift — Monitor BufferMemoryManager physical-byte accounting across grow failures for leaks (missing freePhysicalBytes on OOM).

Remaining risk in the patch

  • In-place free ordering window — replaceAllocatedMemory repoints the handle then frees the old pages; grow runs on the JS thread with no JS executing in between, but the diff does not show that no other thread/consumer (e.g. an instance anchor) can observe the old block between updateSize() and freeVirtualPagesUnlessNullBasePointer().
  • ASSERT(newSize >= oldSize) only — The size relationship is only an ASSERT (debug), so a release build relies on callers never shrinking via this path; the diff does not enforce it at runtime.

Audit directions

  • Other BufferMemoryHandle consumers — Find every holder of a BufferMemoryHandle (ArrayBufferContents, instances/anchors) and verify they survive an in-place replaceAllocatedMemory and never cache a block pointer past grow.
  • Memory mode transitions — Review Signaling/Shared vs BoundsChecking grow paths for the same base-pointer caching assumptions, including transitions between modes.
  • OOM accounting — Audit all BufferMemoryManager reservePhysicalBytes/freePhysicalBytes pairings for leak-on-failure, as fixed in the grow OOM path.
  • ArrayBuffer refresh callers — Check all callers of refreshAfterWasmMemoryGrow and disassociateArrayBuffer for correct view/pointer invalidation on fixed-length vs resizable buffers.

Before / after

Loading diff…