Released March 24, 2026

CVE-2026-20664

CVSS 4.3 severity medium CWE-787CWE-120 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected process crash

Researchers: Yeonghyeon Choi, Daniel Rhea, Söhnke Benedikt Fischedick (Tripton), Emrovsky & Switch3301, Yevhen Pervushyn

Key insight. Growing a WebAssembly.Memory exposed as a resizable ArrayBuffer moved the backing store but did not refresh the data pointers cached in its typed-array views, leaving them dangling; refreshing every view’s m_vector to the new base after a grow fixes it.

Patch Details

diff --git a/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
new file mode 100644
index 000000000000..697eb051d2a6
--- /dev/null
+++ b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
@@ -0,0 +1,24 @@
+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+
+import * as assert from '../assert.js'
+
+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);
diff --git a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
index 14777a6a36a0..749112a9e540 100644
--- a/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
+++ b/Source/JavaScriptCore/runtime/ArrayBuffer.cpp
@@ -459,7 +459,20 @@ void ArrayBuffer::setAssociatedWasmMemory(Wasm::Memory* memory)
 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 ArrayBuffer::setSharingMode(ArrayBufferSharingMode newSharingMode)
@@ -764,6 +777,7 @@ 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();
 #else
     UNUSED_PARAM(memory);
diff --git a/Source/JavaScriptCore/runtime/JSArrayBufferView.h b/Source/JavaScriptCore/runtime/JSArrayBufferView.h
index 26c7ba00a807..ba3021e58933 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBufferView.h
+++ b/Source/JavaScriptCore/runtime/JSArrayBufferView.h
@@ -364,7 +364,7 @@ private:
     JS_EXPORT_PRIVATE ArrayBuffer* slowDownAndWasteMemory();
     static void finalize(JSCell*);
     void detachFromArrayBuffer();
-
+    void refreshVector(void* newData);
 
 protected:
     friend class LLIntOffsetsExtractor;
diff --git a/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h b/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
index 1fff67ddbef4..50dc90a0291b 100644
--- a/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
+++ b/Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
@@ -132,6 +132,21 @@ inline RefPtr<ArrayBufferView> JSArrayBufferView::toWrappedAllowSharedAndResizab
     return nullptr;
 }
 
+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
+
 template<typename Getter>
 bool isArrayBufferViewOutOfBounds(JSArrayBufferView* view, Getter& getter)
 {

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-20664
Bugzilla 306136
Component JSC
Bug Class UAF
Severity medium
Commit 6b357f32c6075bb8…
Advisory Apple Advisory

Terminology

  • Resizable ArrayBuffer over wasm memory — WebAssembly.Memory.toResizableBuffer() exposes growable wasm memory as an ArrayBuffer whose backing can move when the memory grows.
  • Typed-array m_vector cache — A JSArrayBufferView caches its buffer’s data pointer (plus byteOffset) in m_vector for fast indexed access.
  • Incoming references — An ArrayBuffer tracks the views referencing it; the fix uses this list to update each view after a grow.
  • Memory grow / reallocation — Growing wasm memory can move the backing store to a new base address, invalidating any cached pointer into the old store.

Root Cause Analysis

A WebAssembly.Memory can be exposed to JS as a resizable ArrayBuffer (toResizableBuffer), and typed-array views (JSArrayBufferView) over an ArrayBuffer cache the buffer’s backing data pointer in their m_vector field for fast element access. When the underlying wasm memory grows, its base data pointer can move (the memory is reallocated/remapped), and ArrayBufferContents is updated to the new base. Pre-patch, growing the wasm memory did not refresh the cached m_vector pointers of the views attached to that buffer, so every typed array over the resizable buffer kept pointing at the OLD backing store – a stale/dangling data pointer. Reading or writing such a view after the grow accesses freed or moved memory: a use-after-free / out-of-bounds access. The fix adds JSArrayBufferView::refreshVector(newData), which (when the view still hasVector()) recomputes m_vector as newData + byteOffsetRaw() and stores it without a write barrier; ArrayBuffer, after a wasm-memory grow, walks its incoming references (numberOfIncomingReferences/incomingReferenceAt), downcasts each to JSArrayBufferView, and calls refreshVector(newData) so every view is repointed at the new base, while ArrayBufferContents::refreshAfterWasmMemoryGrow sets m_data = memory->basePointer(). The restored invariant is that after a wasm memory grows, all typed-array views over its resizable buffer observe the buffer’s new base pointer rather than a stale one.

Attack Path

1. Get a resizable buffer over wasm memory

Create a WebAssembly.Memory with room to grow and obtain memory.toResizableBuffer(); allocate a typed array (e.g. Uint32Array) over it, caching the current data pointer.

2. Grow the memory

Call memory.grow(n), which moves the backing store to a new base pointer and frees/unmaps the old one.

3. Leave views stale

Pre-patch, the typed array’s cached m_vector still points at the old (freed/moved) backing store.

4. Access via the stale view

Read/write ta[i]; the access dereferences the stale data pointer – a use-after-free / out-of-bounds in WebContent, groomable by allocating over the freed region.

Impact Assessment

A stale-pointer use-after-free / out-of-bounds: growing a wasm memory left typed-array views pointing at the old backing store, reachable from ordinary JS over a resizable buffer in the WebContent process. The attacker controls the grow and can reallocate the freed region, making this a strong, groomable read/write primitive rather than a mere crash. Confined to WebContent; rated medium.

Audit Directions

Other buffer-base changes

grep JSC runtime for places that change ArrayBufferContents::m_data / basePointer (detach, transfer, resize, shared-memory remap) and confirm all incoming views are refreshed.

Cached m_vector consumers

Audit JSArrayBufferView fast paths that read m_vector without revalidating against the buffer after operations that can move backing store.

Resizable/growable buffer APIs

Review ArrayBuffer.prototype.resize, SharedArrayBuffer.grow, and toResizableBuffer paths for the same view-refresh requirement.

Changed Functions

Function File Change Note
ArrayBuffer::setAssociatedWasmMemory / grow-refresh path Source/JavaScriptCore/runtime/ArrayBuffer.cpp modified After a wasm memory grow changes the data pointer, iterates incoming references and calls JSArrayBufferView::refreshVector(newData) on each view so cached vectors are repointed.
JSArrayBufferView::refreshVector Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h added Recomputes m_vector as newData + byteOffsetRaw() (guarded by hasVector()) and stores it, so a view tracks the buffer’s new backing base.
ArrayBufferContents::refreshAfterWasmMemoryGrow Source/JavaScriptCore/runtime/ArrayBuffer.cpp modified Sets m_data = memory->basePointer() so the contents reflect the post-grow base.
JSArrayBufferView::refreshVector (decl) Source/JavaScriptCore/runtime/JSArrayBufferView.h modified Declares the new refresh entry point.

Files Changed

Source Files

  • Source/JavaScriptCore/runtime/ArrayBuffer.cpp
  • Source/JavaScriptCore/runtime/JSArrayBufferView.h
  • Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h

Test Files

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

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
new file mode 100644
index 000000000000..697eb051d2a6
--- /dev/null
+++ b/JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
@@ -0,0 +1,24 @@
+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+
+import * as assert from '../assert.js'
+
+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);