CVE-2026-20664
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.cppSource/JavaScriptCore/runtime/JSArrayBufferView.hSource/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);