[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
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
Source/JavaScriptCore/runtime/JSArrayBufferViewInlines.h
JSTests/wasm/stress/resizable-buffer-grow-view-refresh.js
Patch Details
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이 여전히 읽히는지 확인합니다.
Background
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)이 아닙니다.
Analysis
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으로 해석됩니다. 스크립트 쪽에서 구체적으로 정리하면 다음과 같습니다.
- signaling 모드 예산을 고갈시킬 만큼
WebAssembly.Memory객체를 할당해, 다음 memory가BoundsChecking모드로 뒷받침되게 만듭니다. - 그 memory를 생성하고
toResizableBuffer()를 호출한 뒤, 얻은 buffer 위에Uint32Array를 구성합니다. memory.grow(1)을 호출합니다. handle A가 해제되지만, view의m_vector는 여전히 그 안을 가리키고, view의 length는 이제 B를 반영합니다.- 공격자가 선택한 내용으로 A의 주소 범위를 다시 확보하도록 할당을 수행합니다.
- 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가 남았습니다.
Insight
누락된 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였습니다.
Audit directions
-
Relocatable backing store, cached derivations of its old base. invariant는 이렇습니다. 재배치 알림은 직접 소유자뿐 아니라 캐시된 파생값들의 transitive closure 전체에 도달해야 합니다. Narrow: JSC 안에서
ArrayBufferContents::data()나 base +byteOffsetRaw()로 한 번만 초기화되는 필드를 전부 열거하고, 각각이 이제ArrayBuffer::refreshAfterWasmMemoryGrow로부터 도달 경로를 갖는지 확인합니다.m_vector는 이제 커버되었습니다. 다만JSDataView, WebCore consumer가toWrapped*/possiblySharedImpl()을 거쳐 보유하는 nativeArrayBufferViewbase 주소(이들은 JS cell이 아니므로incomingReferenceAt에 결코 나타나지 않습니다), 그리고JSWebAssemblyInstance에 캐시된 memory base나 size를 점검할 필요가 있습니다. Wider: 같은 부류는 다른 캐싱 메커니즘을 통해서도 드러납니다. JIT이 생성한 코드에 박힌 vector나 typed array element 접근용 inline cache, runtime call 전후로 고정되어 있는 pointer, WebGL/WebAudio/WebGPU에 건네진 뒤 한 turn을 넘겨 보관되는 pointer가 그 대상입니다. Widest: 이 원칙은 주소 안정성 보장을 완화하는 모든 시스템에 적용됩니다. V8의 resizableJSArrayBufferbacking store와JSTypedArraydata pointer의 관계, RustVec의 reallocation이 거기서 얻은 raw pointer를 무효화하는 상황, pin된 페이지를 이동시킬 수 있는 데이터베이스 page cache 등이 해당합니다. 각 단계의 match tell은 다릅니다. narrow에서는 생성자와 detach 경로에서만 대입되고 grow 경로에는 writer가 없는 멤버입니다. wider에서는 base pointer를 한 번 읽고, 자원을 확장시킬 수 있는 호출 이후에 그 값을 재사용하는 코드 검색 결과입니다. widest에서는refresh/revalidate/rebind헬퍼를 도입하는 commit입니다. 질문은 항상 같습니다. 그 헬퍼가 closure 전체를 커버하는지, 아니면 소유자만 커버하는지입니다. -
구독이 아니라 liveness를 기준으로 구성원이 결정되는 registry를 통해 알림이 전달되는 패턴. invariant는 observer 목록이 reachability edge가 아니라 observer 자체를 열거해야 한다는 것입니다. Narrow:
ArrayBuffer.cpp에서ArrayBuffer의 incoming-reference 목록을 사용하는 다른 지점들 — detach, transfer, sharing-mode 변경 — 이 동일한 가정을 두고 있는지 점검합니다. 이때 buffer의 data pointer를 들고 있는JSCell이 아닌 소유자에게도 같은 알림이 필요한지 별도로 확인해야 합니다. 새로 추가된 loop의dynamicDowncast<JSArrayBufferView>가 JS cell이 아닌 대상을 전부 조용히 건너뛰기 때문입니다. Wider: 같은 형태는 WebKit이 명시적인 subscriber 목록 대신 GC graph나 ownership graph에서 알림 대상을 도출하는 모든 지점에 나타납니다. JSC의Weak/WeakGCSet기반 invalidation, 그리고 관심 있는 대상이 전부 tree node라고 전제하는 WebCore의 style/render invalidation 순회가 여기에 해당합니다. Widest: "graph에서 도출한 registry가 graph 밖의 observer를 놓친다"는 일반적인 class는 dependency graph를 event bus로 재사용하는 모든 코드베이스에서 성립합니다. 등록된 singleton에만 알림을 보내는 DI container, detach된 entity를 놓치는 ORM change-tracking, 수동으로 캡처한 값이 dependency graph에서 빠지는 reactive framework가 그런 예입니다. Match tell: 알림 loop의 body 안에 컬렉션을 걸러내는 type test나 downcast가 들어 있는 경우입니다. 걸러져 나간 쪽이 바로 질문해야 할 대상입니다. -
하나의 자원을 여러 필드로 중복 기술하면서 일부만 갱신하는 패턴. invariant는 하나의 allocation을 함께 기술하는 필드들 — base, length, max length, 소유 handle — 이 observer 입장에서 atomic하게 기록되어야 한다는 것입니다. Narrow:
ArrayBufferContents와ArrayBuffer에서m_data,m_sizeInBytes,m_maxByteLength,m_memoryHandle,m_shared중 하나라도 건드리는 모든 mutator를 확인하고, 각각이 tuple 전체를 기록하는지 검증합니다. 패치 이전의refreshAfterWasmMemoryGrow는 handle과 size는 기록했지만 base는 기록하지 않았는데, dangling pointer가 in-bounds처럼 보이게 된 원인이 바로 이 지점입니다. Wider: 이 class에는 원본 옆에 파생 필드를 캐싱해 두는 WebKit 구조 전반이 포함됩니다.JSObject의 butterfly pointer와 public/vector length,StringImpl의 data pointer와 length, 서로 독립적으로 갱신되는 두 멤버로부터 다시 조립되는std::span형태의 pair가 여기에 해당합니다. Widest: 중복된 표현은 시간이 지나면 서로 어긋나게 됩니다. 이 어긋남이 특히 위험해지는 경우는 safety check가 tuple의 한쪽을 읽고 실제 access는 다른 쪽을 읽을 때입니다. C의 모든 buffer/length 쌍, stale pointer로부터 다시 만들어지는 Go slice, 경계값을 그 경계가 적용되는 객체와 분리해 저장하는 capability system이 그런 예입니다. Match tell: 같은 struct의 멤버 두 개 이상에 값을 대입하면서, 세 번째 멤버가 그중 하나로부터 파생되는 함수입니다. 파생 멤버가 대입 목록에서 빠져 있다면 그것이 후보입니다. -
나머지 growth 및 resize 경로 전반에서 early-out이 빠짐없이 적용되는지 여부.
Wasm::Memory::grow에서 base pointer가 바뀔 수 있는 모든 mode를 조사하고, 각각이ArrayBuffer::refreshAfterWasmMemoryGrow에 도달하는지 확인합니다. 이와 별개로, grow가 아니라 resizable non-shared buffer의 shrink /resize경로에서 view가 캐싱된 vector를 유지한 채 storage가 이전되거나 decommit될 수 있는지도 살펴볼 필요가 있습니다. 여기서의 invariant는 WebKit에 한정되며, wasm memory와 ArrayBuffer가 맞닿는 지점을 넘어서면 일반화되지 않습니다. 상한은BufferMemoryHandle자체의 lifecycle입니다. Match tell:BufferMemoryHandle에서 handle이나 mapping을 교체하면서 곧바로 refresh 호출이 뒤따르지 않는 연산, 또는 base는 그대로인 채 mapping의 extent만 바뀐 상황에서data()동일성만 비교하는 refresh 호출입니다.