← All issues

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

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

grow 이후 typed-array view가 해제된 Wasm 메모리 페이지를 역참조하는 현상이 실제로 관찰됩니다. Regression test에서는 관련 없는 allocation이 해제된 메모리를 재사용하는 과정이 확인되었으며, cached-pointer 모델을 기반으로 controlled R/W primitive로의 escalation 가능성이 confidence 0.9로 평가됩니다. 단, BoundsChecking heap layout에 대한 attacker의 제어 수준에 의해 제약됩니다.

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);

refreshAfterWasmMemoryGrow 계열에서 두 가지 변경이 함께 이루어졌습니다. 먼저 ArrayBufferContents::refreshAfterWasmMemoryGrow는 이제 m_data = memory->basePointer()도 함께 갱신합니다. 기존에는 m_memoryHandlem_sizeInBytes만 갱신하여, m_data가 이전 base 주소를 계속 가리키는 상태가 되었습니다. 한편 ArrayBuffer::refreshAfterWasmMemoryGrow는 기존 data() 포인터를 먼저 저장한 뒤 contents를 갱신하도록 변경되었습니다. base 주소가 실제로 이동한 경우에는 numberOfIncomingReferences() / incomingReferenceAt(i)를 순회하며 각 cell을 JSArrayBufferView로 downcast하고, 새로 추가된 refreshVector(newData) 메서드를 호출합니다. refreshVectornewData + byteOffsetRaw() 계산을 통해 view의 cached m_vector를 새로 갱신합니다. hasVector() 조건이 있는 이유는 incoming-reference edge가 view detach 이후에도 유지되기 때문입니다.

재할당 가능한 backing store를 가리키는 stale cached pointer: view 생성 시 한 번 계산된 derived data pointer가 underlying buffer 재할당 시 갱신되지 않는 패턴.

WebAssembly.Memory({initial, maximum})는 Wasm linear memory를 예약합니다. memory.toResizableBuffer()를 호출하면 해당 메모리가 JS ArrayBuffer로 노출되며, memory.grow()가 호출될 때 길이가 늘어납니다. BoundsChecking memory mode는 virtual-memory trap 대신 소프트웨어에서 bounds를 확인하는 Wasm 구현 방식입니다. 이 모드에서는 grow 시 새로운 backing 영역을 할당하고 내용을 복사하는 과정이 필요할 수 있어, base pointer가 변경됩니다.

JSArrayBufferViewUint32Array, DataView 등의 내부 C++ 객체로, buffer storage에 대한 직접 포인터(m_vector)를 캐싱합니다. 덕분에 element에 접근할 때마다 buffer->data()를 다시 조회하지 않아도 됩니다. 이 포인터는 view가 생성되는 시점에 buffer base + byteOffset으로 계산됩니다. WebKit의 GC는 각 cell에 대한 incoming references 집합을 추적합니다(numberOfIncomingReferences/incomingReferenceAt). 새롭게 추가된 순회 로직은 이를 활용하여 의존하는 view들을 탐색합니다.

이 commit 이전에는, BoundsChecking grow로 인해 backing store가 이동하면 두 가지 포인터가 dangling 상태로 남았습니다. ArrayBufferContents::m_datarefreshAfterWasmMemoryGrow 내부에서 새 base가 할당되지 않았고, 모든 JSArrayBufferView::m_vector 역시 재계산되지 않았습니다. refreshAfterWasmMemoryGrow가 도입된 것 자체는 개발자들이 relocation 이벤트를 인식하고 있었음을 보여줍니다. 다만 fan-out이 필요한 cached pointer 중 두 개를 누락한 셈입니다.

🔒

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.

더 확인하려면 구독해 주세요

🔒

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.

더 확인하려면 구독해 주세요