← All issues

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

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

Rated High because the observable effect is a typed-array view dereferencing freed Wasm-memory pages after a grow, the regression test demonstrates reclamation by an unrelated allocation, and escalation to a controlled R/W primitive is projected with confidence 0.9 from the cached-pointer model — bounded only by attacker control over BoundsChecking heap layout.

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;
+
+ 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());
m_memoryHandle = memory->handle();
+ m_data = memory->basePointer();
m_sizeInBytes = m_memoryHandle->size();

Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h

+inline void JSArrayBufferView::refreshVector(void* newData)
+{
+ if (hasVector()) {
+ void* newVectorPtr = static_cast<uint8_t*>(newData) + byteOffsetRaw();
+ m_vector.setWithoutBarrier(newVectorPtr);
+ }
+}

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

+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 coordinated changes inside the refreshAfterWasmMemoryGrow family. ArrayBufferContents::refreshAfterWasmMemoryGrow now also assigns m_data = memory->basePointer() — previously it only refreshed m_memoryHandle and m_sizeInBytes, leaving m_data stranded at the old base. ArrayBuffer::refreshAfterWasmMemoryGrow now snapshots the old data() pointer, runs the contents refresh, and — if the base actually moved — walks numberOfIncomingReferences() / incomingReferenceAt(i), downcasts each cell to JSArrayBufferView, and invokes a new refreshVector(newData) method. refreshVector recomputes the view's cached m_vector as newData + byteOffsetRaw() (gated on hasVector() because incoming-reference edges persist across view detachment).

Stale cached pointer to a relocatable backing store: a derived data pointer is computed once at view construction and not refreshed when the underlying buffer is reallocated.

WebAssembly.Memory({initial, maximum}) reserves a Wasm linear memory; memory.toResizableBuffer() exposes that memory as a JS ArrayBuffer whose length grows when memory.grow() is called. In BoundsChecking memory mode — a Wasm implementation strategy where bounds are enforced in software rather than via virtual-memory traps — growing can require allocating a fresh backing region and copying contents, so the base pointer changes. A JSArrayBufferView (the C++ object behind Uint32Array, DataView, etc.) caches a direct pointer (m_vector) into the buffer's storage so element access does not need to reload buffer->data() each time; this pointer equals buffer base + byteOffset at the moment the view is constructed. WebKit's GC tracks an "incoming references" set per cell (numberOfIncomingReferences/incomingReferenceAt), which is what the new walk uses to find dependent views.

Before this commit, a BoundsChecking grow that relocated the backing store left two pointers dangling: ArrayBufferContents::m_data (never assigned the new base inside refreshAfterWasmMemoryGrow) and every JSArrayBufferView::m_vector (never re-derived). The introduction of refreshAfterWasmMemoryGrow showed the developers recognised the relocation event existed — they just missed two of the cached pointers it needed to fan out to.

🔒

The lifetime and relocation implications of cached buffer pointers across WebAssembly memory growth are analyzed in depth, with a step-by-step walkthrough of the regression test's exploitation pattern.

Subscribe to read more

🔒

Multiple reusable audit patterns identified for cached-pointer/relocation bugs across JSC, with concrete starting points spanning the typed-array, Wasm memory, and JIT inline-cache surfaces.

Subscribe to read more