CVE-2026-20664
Overview
Background
- 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
- 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.
- Grow the memory Call memory.grow(n), which moves the backing store to a new base pointer and frees/unmaps the old one.
- Leave views stale Pre-patch, the typed array’s cached m_vector still points at the old (freed/moved) backing store.
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
forJSTests/wasm/stress/resizable-buffer-grow-view-refresh.js |
modified |
Files Changed
JSTests/wasm/stress/resizable-buffer-grow-view-refresh.jsSource/JavaScriptCore/runtime/ArrayBuffer.cppSource/JavaScriptCore/runtime/JSArrayBufferView.hSource/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
Audit Directions
- Other buffer-base changesgrep 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 consumersAudit JSArrayBufferView fast paths that read m_vector without revalidating against the buffer after operations that can move backing store.
- Resizable/growable buffer APIsReview ArrayBuffer.prototype.resize, SharedArrayBuffer.grow, and toResizableBuffer paths for the same view-refresh requirement.
Patch
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)
{
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);
Regression Test / PoC
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);