← All reports

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

CVE: CVE-2026-20664 · Safari 26.4 · Released March 24, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process crash Apple's description: The issue was addressed with improved memory handling. Credit: Yeonghyeon Choi, Daniel Rhea, Söhnke Benedikt Fischedick (Tripton), Emrovsky & Switch3301, Yevhen Pervushyn

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

High. relocation hook이 buffer의 length만 갱신하고 base는 그대로 남겨두었습니다. 그 결과 grow 이후의 typed array는 detach되지도 않았고 out-of-bounds도 아닙니다. 해제된 mapping을 가리키면서 크기는 더 커졌다고 믿는 상태에 머무릅니다. 단순 crash가 아니라 read/write primitive의 형태에 해당합니다.

Typed array는 view이지만, 그것은 어디까지나 bookkeeping 차원의 이야기입니다. length는 buffer를 거쳐 조회하는 반면, data pointer는 생성 시점에 view 안으로 복사해 둔 사본입니다. 이 구조가 안전한 조건은 하나입니다. ArrayBuffer의 storage가 절대 이동하지 않아야 합니다. JS에서 보이는 모든 buffer는 실제로 그 조건을 만족했습니다. WebAssembly linear memory가 buffer로 노출되기 시작하면서 상황이 달라졌습니다. WebAssembly.Memory.prototype.toResizableBuffer()가 스크립트에 넘겨준 buffer는 grow 시점에 backing mapping이 통째로 교체될 수 있습니다. 그 순간부터 base에서 파생된 모든 캐시 값 — buffer 자신의 m_data, 그리고 각 view의 m_vector — 은 이동 사실을 통보받아야 하는 pointer가 되었습니다.

관전 포인트: resizable buffer로 노출한 WebAssembly memory를 grow한 페이지는, 겉보기에 완전히 정상인 typed array를 되돌려받습니다. 다만 그 read/write는 이미 해제되어 재사용 가능한 메모리에 도달합니다.

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;
+
+ // JSArrayBufferView(typed array)는 실질적으로 buffer의 data pointer를 캐싱한다.
+ 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());
// 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();

Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h

+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

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

+//@ requireOptions("--useWasmMemoryToBufferAPIs=true")
+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);

production 변경 두 개와 regression test 하나가 포함되었습니다. 두 변경은 같은 캐싱 사슬의 서로 다른 계층에 자리합니다.

먼저 contents 계층입니다. 기존 ArrayBufferContents::refreshAfterWasmMemoryGrow(Wasm::Memory*)는 두 가지 일만 했습니다. m_memoryHandle을 memory->handle()로 다시 묶고, 그 새 handle에서 m_sizeInBytes를 다시 계산했습니다. 패치는 이 둘 사이에 한 줄 — m_data = memory->basePointer() — 을 삽입했습니다. 이로써 descriptor가 직접 들고 있던 base pointer도 방금 rebind된 handle을 따라가게 되었습니다. 순서에 주목할 필요가 있습니다. size는 이미 새 handle에서 읽고 있었습니다. 그래서 결과 상태가 단순히 오래된 값이 남은 수준이 아니라, 내부적으로 서로 모순된 상태가 되었습니다.

다음은 buffer 계층입니다. ArrayBuffer::refreshAfterWasmMemoryGrow(Wasm::Memory*)는 위임 호출 앞뒤를 비교하는 로직을 얻었습니다. m_contents.data()를 먼저 snapshot으로 남기고, contents refresh를 호출한 뒤 pointer를 다시 읽습니다. base가 이동하지 않았다면 그대로 조기 반환합니다. 페이지를 제자리에 commit하는 signaling 모드 memory는 이 경로를 타므로 추가 비용이 전혀 없습니다. base가 실제로 이동한 경우에는, numberOfIncomingReferences() / incomingReferenceAt(i)로 buffer의 incoming-reference 목록을 역순으로 순회합니다. 각 cell을 dynamicDowncast<JSArrayBufferView>로 확인하고, 해당하는 대상마다 새로 추가된 refreshVector(newData)를 호출합니다.

JSArrayBufferView::refreshVector(void*)는 JSArrayBufferView.h에 선언되고 JSArrayBufferViewInlines.h에서 inline으로 구현되었습니다. raw pointer 연산 때문에 WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END로 감싸져 있습니다. 내부는 hasVector()로 보호되는데, 주석이 그 guard가 왜 핵심인지 설명합니다. 이 알림은 detach 이후에도 멤버십이 유지되는 목록을 타고 전달됩니다. 따라서 이미 storage를 놓은 view도 여전히 방문 대상이 되며, 새로 계산한 pointer를 받는 게 아니라 건너뛰어야 합니다. storage를 가진 view에 대해서는 새 vector가 newData + byteOffsetRaw()로 계산되고, m_vector.setWithoutBarrier(...)로 저장됩니다. 대상이 GC에서 보이는 cell이 아니라 raw memory이기 때문입니다.

regression test는 그 자체로 하나의 트리거 레시피로 읽어 볼 만합니다. 먼저 최대 500개의 WebAssembly.Memory 할당으로 signaling(fast) wasm memory 풀을 고갈시킵니다. 그래야 실제로 다룰 memory가 BoundsChecking 모드로 밀려나게 됩니다. 그 다음에야 한 페이지 크기의 memory를 만들고, toResizableBuffer()로 노출하고, 그 위에 Uint32Array를 올립니다. index 0에 0xCAFEBABE를 기록한 뒤 한 페이지를 grow하고, 0xDEADBEEF로 채운 64 KiB buffer 100개를 spray합니다. 마지막으로 view를 통해 sentinel이 여전히 읽히는지 확인합니다.

WebAssembly linear memory and growth. WebAssembly.Memory는 linear memory 영역을 소유하며, 스크립트는 memory.grow(n)으로 이를 64 KiB 페이지 단위로 확장할 수 있습니다. JSC는 이 영역을 두 가지 모드 중 하나로 뒷받침합니다. signaling(fast) 모드에서는 엔진이 큰 가상 주소 영역을 미리 예약하고, grow는 그 안에서 추가 페이지를 commit합니다. 그래서 base 주소가 memory의 lifetime 동안 안정적으로 유지됩니다. 반면 BoundsChecking 모드에서는 mapping이 현재 필요한 크기에 맞춰 잡히고, grow는 다른 mapping을 새로 얻습니다. signaling 모드 memory는 주소 공간을 많이 쓰기 때문에, 한 프로세스가 동시에 보유할 수 있는 개수에 한계가 있습니다. 그 예산이 고갈되면 이후 memory는 bounds-checking 모드로 떨어집니다. 결과적으로 특정 memory가 어느 모드에 놓이는지는 그 페이지가 이미 무엇을 할당해 두었는지에 따라 달라집니다.

BufferMemoryHandle. 각 mapping은 refcount 기반의 BufferMemoryHandle이 소유합니다. tryAllocateResizableMemory는 adoptRef(*new BufferMemoryHandle(...))로 이를 생성합니다. memory 기반 buffer의 경우 ArrayBufferContents::m_memoryHandle이 그 handle에 대한 reference를 들고 있으며, ArrayBuffer 쪽에서 mapping을 살려두는 역할을 담당합니다.

toResizableBuffer() and ArrayBufferContents. WebAssembly.Memory.prototype.toResizableBuffer()는 wasm memory를 resizable non-shared ArrayBuffer로 JavaScript에 노출합니다. 주변 코드는 이 API 계열 전체를 Options::useWasmMemoryToBufferAPIs()로 통제합니다. ArrayBufferContents는 ArrayBuffer 뒤에 있는 storage descriptor로, m_data(base를 직접 가리키는 pointer), m_sizeInBytes, m_maxByteLength를 보유하며, memory 기반 buffer라면 m_memoryHandle까지 함께 들고 있습니다. m_data는 생성 시점에 단 한 번 설정됩니다. ArrayBufferContents::ArrayBufferContents(void*, size_t, size_t, Ref<BufferMemoryHandle>&&)가 m_data(data)로 초기화합니다.

JSArrayBufferView::m_vector and view modes. 모든 typed array와 DataView는 view가 커버하는 첫 바이트를 가리키는 CagedBarrierPtr<Gigacage::Primitive, void>를 각자 보유합니다. 값은 buffer base에 byteOffsetRaw()를 더한 것이며, 생성 시점에 계산됩니다. indexed element 접근은 접근마다 buffer에서 주소를 다시 유도하지 않고 m_vector를 직접 읽고 씁니다. hasVector()는 현재 storage를 가진 view와 detach된 view를 구분합니다. ArrayBuffer 위에 만들어진 view는 WastefulTypedArray / DataViewMode 계열 중 하나를 사용하며, 여기서는 ResizableNonSharedAutoLengthWastefulTypedArray입니다. 이 모드에서는 storage 소유권이 buffer에 있고 view에는 없습니다. 또한 auto-length view는 접근 시점에 buffer의 현재 byte length에서 자신의 length를 유도합니다. isArrayBufferViewOutOfBounds는 그 length를 view->possiblySharedBuffer()로부터 계산합니다.

Incoming references. ArrayBuffer는 자신을 참조하는 JS cell을 추적하며, numberOfIncomingReferences()와 incomingReferenceAt(i)로 열거할 수 있습니다. 이 목록은 garbage collection 목적으로 존재합니다. 멤버십 기준은 reachability이며, 구독(subscription)이 아닙니다.

backing store가 재배치된 뒤에도 살아남은 오래된 base pointer로 인한 use-after-free입니다. 같은 설명의 length 쪽 절반은 정확하게 갱신되었다는 점이 문제를 더 키웁니다.

  BoundsChecking wasm memory, memory.grow(1):

   handle A (64 KiB)                 handle B (128 KiB)
   base 0x1000_0000  ──released──►   base 0x2000_0000
        ▲                                 ▲
        │ m_vector  (stale)               │ m_memoryHandle  (refreshed)
        │ m_data    (stale)               │ m_sizeInBytes = 131072 (refreshed)
        │                                 │
   ┌────┴──────────┐               ┌──────┴────────┐
   │ Uint32Array   │──── length ──►│ ArrayBuffer   │
   │ (not detached)│   131072      │ (not detached)│
   └───────────────┘               └───────────────┘

다이어그램은 위에서 아래로 읽습니다. bounds-checking memory에서의 grow는 handle A를 늘리지 않습니다. 새 주소에 handle B를 만들어냅니다. ArrayBufferContents::refreshAfterWasmMemoryGrow는 바로 그 사건을 위한 notification hook으로 작성되었습니다. 실제로 m_memoryHandle을 B로 rebind하고 m_sizeInBytes를 B에서 다시 계산했습니다. 다만 m_data는 A의 base를 계속 가리킨 채로 남았습니다. 생성 시점에 한 번 받고 이후 한 번도 다시 손대지 않은 값입니다. 즉 다이어그램의 오른쪽 열만 전진했고, 왼쪽 열은 그대로였습니다.

이 rebind 자체가 왼쪽 열을 단순히 오래된 값이 아니라 dangling 상태로 만드는 원인입니다. 새 handle을 m_memoryHandle에 대입하면 contents가 A에 대해 들고 있던 reference가 해제됩니다. 그 reference가 마지막 하나였다면 A의 mapping이 해제됩니다. 이 시점부터 m_data와 모든 view의 m_vector는 해제된 메모리를 주소로 삼습니다. object graph 어디에도 이 사실이 기록되지 않습니다. buffer는 detach되지 않았습니다. view들도 detach되지 않았고, hasVector()는 여전히 storage가 있다고 보고합니다. out-of-bounds 검사도 도움이 되지 않습니다. isArrayBufferViewOutOfBounds가 참조하는 것은 buffer의 byte length이고, refresh가 실제로 갱신한 값이 바로 그것 — B의 더 큰 크기 — 이기 때문입니다.

template<typename Getter>
bool isArrayBufferViewOutOfBounds(JSArrayBufferView* view, Getter& getter)

이 predicate는 올바른 질문을 던지지만 대상 allocation이 틀렸습니다. length는 갱신된 오른쪽 열에서 읽고, 실제 모든 접근은 갱신되지 않은 왼쪽 열에서 base를 읽습니다. 두 절반이 서로 다른 allocation을 기술하고 있으며, 하필 safety check가 이동한 쪽 절반에 얹혀 있습니다.

결과적으로 남는 것은 스크립트 입장에서 완전히 평범해 보이는 typed array입니다. 살아 있고, in-bounds이며, grow 이후에는 이전보다 한 페이지 길어진 상태입니다. 다만 그 element 접근은 해제된 mapping으로 해석됩니다. 스크립트 쪽에서 구체적으로 정리하면 다음과 같습니다.

  1. signaling 모드 예산을 고갈시킬 만큼 WebAssembly.Memory 객체를 할당해, 다음 memory가 BoundsChecking 모드로 뒷받침되게 만듭니다.
  2. 그 memory를 생성하고 toResizableBuffer()를 호출한 뒤, 얻은 buffer 위에 Uint32Array를 구성합니다.
  3. memory.grow(1)을 호출합니다. handle A가 해제되지만, view의 m_vector는 여전히 그 안을 가리키고, view의 length는 이제 B를 반영합니다.
  4. 공격자가 선택한 내용으로 A의 주소 범위를 다시 확보하도록 할당을 수행합니다.
  5. view의 일반 indexed accessor로 읽고 씁니다.

regression test는 1–4단계를 그대로 재현하고, 5단계는 sentinel 확인으로 축소한 형태입니다. 0xDEADBEEF로 채운 64 KiB buffer 100개를 spray한 뒤 ta[0]이 여전히 0xCAFEBABE로 읽히는지 assert합니다. 패치 이전에는 그 assertion이 곧, 다른 누군가가 가져간 해제된 페이지를 관찰하는 행위였습니다.

보안 관점의 결과는 WebContent process 내부의 memory-safety 붕괴입니다. 엔진은 typed array bounds checking만으로 element 접근을 buffer storage 안에 가둘 수 있다고 가정합니다. 그 가정은 base pointer와 length가 같은 allocation을 기술할 때에만 성립합니다. 공격자가 해제된 mapping을 원하는 내용으로 다시 확보할 수 있는 경우를 가정할 수 있습니다. 이 경우 엔진이 여전히 유효하다고 간주하는 view에 대해 평범한 indexed 접근으로 그 메모리를 읽고 쓸 수 있게 됩니다. 나아가 bounds check가 갱신된 더 큰 length를 쓰기 때문에, 이전 mapping의 끝을 넘어 선형적으로 도달하는 것도 가능해집니다. 재확보된 메모리를 대상으로 공격자가 고른 offset에서 바이트 단위 read/write를 얻는 셈입니다. 이후 전개는 흔히 알려진 발판입니다. 재확보 영역에 놓인 metadata나 pointer가 그대로 info leak이 되고, 같은 영역에 대한 corruption primitive가 확보됩니다. 여기서 renderer 내 arbitrary read/write를 구성할 가능성이 존재합니다. 다만 exploitation은 WebContent sandbox 안에 머무릅니다. 별도의 escape가 여전히 필요하며, 이 diff에는 GPU, Networking, UI process가 전혀 등장하지 않습니다.

fix는 두 계층 모두에서 invariant를 복원합니다. 추가된 m_data = memory->basePointer()는 contents 자신의 base가 방금 rebind된 handle을 따라가게 만듭니다. 그리고 incoming-reference 순회가 새 base를 모든 view의 m_vector에 newData + byteOffsetRaw() 형태로 전파합니다. 각 view가 buffer 안에서 갖던 offset은 유지하면서, 기준점만 현재 mapping으로 다시 잡는 방식입니다. newData == oldData 조기 반환이 있으므로, base가 실제로 이동하지 않는 signaling 모드 grow는 순회를 전혀 거치지 않습니다.

grow가 buffer의 length는 새 mapping에서 갱신하고 base는 아무 곳에서도 갱신하지 않았습니다. 그 결과 bounds check는 한 allocation을 기술하고 실제 접근은 이미 해제된 다른 allocation에 도달하는, 살아 있는 typed array가 남았습니다.

누락된 m_data 대입은 이야기의 작은 쪽 절반입니다. 큰 쪽 절반은 구조적입니다. wasm linear memory를 resizable ArrayBuffer로 노출한 결정은 그 backing store를 주소 안정적인 것에서 재배치 가능한 것으로 바꿔놓았습니다. 그 한 번의 전환으로 base에서 파생된 모든 pointer가 부채가 되었습니다. 해당 기능과 함께 도입된 refresh hook은 소유 객체는 커버했지만, 캐시들의 transitive closure까지는 닿지 않았습니다. 그리고 놓친 계층인 JSArrayBufferView::m_vector는 하필 언어가 스크립트에 직접 건네주는 바로 그 계층입니다.

fix 자체에서 앞으로 기억해 둘 세부 사항이 두 가지 있습니다. 먼저 이 fix는 GC용 incoming-reference 목록을 observer 목록으로 재활용합니다. refreshVector에 달린 주석은 그 어긋남을 솔직하게 인정합니다 — "an incoming reference from a view to a buffer remains in place even after a view detaches." 멤버십 기준이 구독이 아니라 liveness인 registry는 일부 observer에게 과도하게 전달하게 됩니다. 여기서는 hasVector() guard가 그 부분을 처리합니다. 더 위험한 쪽은 JS cell이 아닌 observer를 조용히 빠뜨리게 된다는 점입니다. 한편 이 버그의 severity는 조기 반환이 이제 감시하는 바로 그 비대칭성 때문에 증폭됩니다. 재배치가 length는 갱신하고 base는 건드리지 않았기 때문에, 패치 이전 상태는 fast path가 거부할 detach된 view나 out-of-bounds view가 아니었습니다. 완벽하게 유효해 보이면서, 해제된 storage보다 살짝 더 큰 view였습니다.