[JSC] Use-after-free after Wasm memory grow via stale pointer folded by DFGConstantFoldingPhase
CVE: CVE-2026-43663 · Safari 26.5.2 · 2026년 6월 29일 출시 Impact: 악의적으로 조작된 웹 콘텐츠를 처리할 경우 예기치 않은 프로세스 crash로 이어질 수 있습니다 Apple's description: 개선된 메모리 처리 방식을 적용하여 문제를 수정했습니다. Credit: stratan (@5tratan) of Almamater Technologies, Soyeon Park, Amy Burnett, Khai Tran, sherkito, Kota Toda, HexRabbit (@h3xr4bb1t) and NiNi (@terrynini38514) of DEVCORE Research Team, Using GLM From Z.AI, Tristan Madani (@TristanInSec) from Talence Security, Brian Carpenter
High. 컴파일러는 heap 주소를 machine code에 그대로 박아 넣었고, runtime은 그 코드 밑에서 해당 주소를 옮길 수 있었습니다. 누락된 guard는 단 네 줄이지만, JIT constant와 freed 영역 사이를 막아주는 것은 이 guard 하나뿐입니다. "reliable crash" 수준을 넘어서는 확장 가능성은 해제된 Wasm memory를 공격자가 allocator 안으로 다시 grooming할 수 있는지에 달려 있습니다.
Optimizing compiler가 속도를 얻는 방식은 compile time에 사실을 증명해두고, runtime에서 이를 다시 검사하지 않는 데 있습니다. 다만 이 거래가 성립하려면, runtime이 나중에 그 사실을 뒤집을 수 없어야 합니다. Typed array의 storage 주소는 JavaScript 역사 대부분에서 이 "뒤집을 수 없는" 영역에 편안히 자리해 왔습니다. JSArrayBufferView의 vector pointer는 buffer가 할당되는 순간 고정되었기 때문입니다. 그런데 resizable ArrayBuffer와 growable Wasm memory가 등장하면서, 이 사실은 가변 영역으로 넘어갔습니다. 문제는 여기에 의존하던 최적화들은 함께 옮겨가지 않았다는 점입니다.
관전 포인트: 페이지 하나만으로도 optimized JIT code가 엔진이 이미 해제한 메모리 영역에 attacker가 선택한 double 값을 계속 기록하도록 만들 수 있습니다.
Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
JSTests/stress/resizable-array-constant-folding.js
Patch Details
수정된 파일은 두 개이며, 그중 실제 production code는 하나뿐입니다. JSC::DFG::ConstantFoldingPhase::foldConstants에 가해진 변경은, abstract interpreter가 base object를 특정 JSArrayBufferView로 확정지었을 때 GetIndexedPropertyStorage를 folding하는 handler에 네 줄짜리 조기 break를 삽입한 것입니다. Guard가 추가되기 전에는, 이 handler가 m_interpreter.execute(indexInBlock)을 실행하고 eliminated = true를 설정해, storage-pointer 연산을 view의 현재 vector에서 얻은 compile-time constant로 대체했습니다. Guard 이후에는 isResizableOrGrowableShared()가 true를 반환하는 view라면 folding 대상에서 제외되어 일반 경로를 타게 되고, 이 경로에서는 매 access마다 storage pointer를 view에서 runtime에 다시 로드합니다.
Before (resizable view): After (resizable view):
GetIndexedPropertyStorage(view) GetIndexedPropertyStorage(view)
└─► fold to Constant(0x1234) └─► isResizableOrGrowableShared()? ──yes──► break
└─► eliminated = true └─► node survives to codegen
└─► codegen: mov rax, 0x1234 └─► codegen: mov rax, [view + vectorOffset]
패치가 추가한 주석 두 줄이 논거 전체를 담고 있습니다. resize()와 WebAssembly.Memory.grow()가 backing store를 재할당할 수 있기 때문에, folding된 vector pointer가 stale해진다는 내용입니다. Guard가 삽입된 위치도 눈여겨볼 만합니다. 바로 위에 bug 125425를 참조하는 FIXME가 달린 기존 break가 있습니다. 이 folding 지점에는 이미 delicate하다고 알려진 escape hatch가 하나 있었고, 이번 fix는 그 옆에 두 번째 escape hatch를 추가한 셈입니다.
나머지 파일 JSTests/stress/resizable-array-constant-folding.js는 regression test입니다. 재현을 위해서는 Wasm allocator의 최적화 자체를 무력화해야 하기 때문에, 일반적인 regression test보다 흥미로운 구성을 가지고 있습니다. --useConcurrentJIT=false 옵션으로 실행되어 compilation이 main thread에서 동기적으로 일어나며, 그 덕분에 warm-up 이후 grow가 이어지는 순서가 항상 동일하게 재현됩니다.
Background
DFG와 abstract interpretation. JSC는 hot JavaScript를 tier ladder를 통해 컴파일합니다. DFG는 그 중간 단계에 해당하는 optimizing compiler로, runtime profiling과 abstract interpreter(AI)를 함께 사용해 코드를 특화시킵니다. 이때 AI는 각 값에 대해 증명 가능한 정보를 intermediate representation 전체로 전파하는 역할을 합니다. AI가 어떤 node의 값을 정확히 증명해내면, 이후 단계들은 그 값을 constant로 치환할 권한을 갖게 됩니다.
Constant folding. ConstantFoldingPhase는 이렇게 확보된 증명을 실제로 현금화하는 pass입니다. 각 basic block을 순회하면서 abstract interpreter에게 각 node의 operand에 대해 알고 있는 정보를 묻고, 그 답이 충분히 정확하면 해당 node를 constant로 교체하며 원래 연산은 eliminated로 표시합니다. 이 pass가 내놓은 결과는 이후 모든 단계에서 그대로 신뢰됩니다. Folding phase가 내놓은 constant를 다시 검증하는 pass는 이후에 존재하지 않습니다.
Typed-array storage. Float64Array, Uint8Array 등의 배후에 있는 C++ 객체인 JSArrayBufferView는, 자신이 가리키는 바이트에 대한 raw vector pointer를 갖고 있습니다. Indexed read/write는 모두 이 base로부터의 offset으로 컴파일됩니다. DFG는 이 base를 구하는 과정을 GetIndexedPropertyStorage라는 별도의 IR node로 모델링합니다. 덕분에 같은 view를 여러 번 다루는 loop라면 base를 한 번만 계산하면 됩니다. 한편 DFG는 typed-array의 length와 bounds check를 처리하는 별개의 독립적인 machinery를 갖고 있으며, 이 둘은 서로 연결되어 있지 않습니다.
Resizable and growable buffers. 비교적 최근에 언어에 추가된 두 기능은 buffer가 생성된 이후에도 byte length를 바꿀 수 있게 해줍니다. resize()를 통한 resizable ArrayBuffer와, grow()를 통한 growable SharedArrayBuffer가 그것입니다. JSArrayBufferView::isResizableOrGrowableShared()는 주어진 view가 이 두 유형 중 하나 위에 있는지를 알려줍니다.
WebAssembly.Memory와 toResizableBuffer(). Wasm linear memory는 initial과 maximum page count로 생성되며, grow(n)으로 크기를 늘릴 수 있습니다. 구현체는 보통 처음부터 큰 연속 virtual address 영역을 미리 예약해 둡니다. 이렇게 해두면 growth가 일어날 때 같은 자리에서 page만 더 commit하면 되고, base 주소는 그대로 유지됩니다. 이런 예약 공간이 없는 경우에는, 새 영역을 할당하고 기존 내용을 복사한 뒤 이전 영역을 해제하는 방식으로 대체됩니다. toResizableBuffer()는 이 memory를 JavaScript에 resizable buffer 형태로 노출시키며, 이 위에 일반적인 typed array를 구성할 수 있습니다.
Tiering warm-up. 함수는 DFG가 컴파일을 시도하기 전에 여러 번 실행되어야 합니다. JSC JIT proof-of-concept가 하나같이 10,000회 반복 hot loop로 시작하는 이유입니다.
Analysis
여기서 빠진 check는 bounds check가 아닙니다. stability check입니다. Folding phase는 view의 storage 주소를 알고 있다는 사실만 확인했을 뿐, 그 사실을 계속 알고 있어도 되는지는 한 번도 묻지 않았습니다.
Compile time Runtime
──────────────────────────── ─────────────────────────────────
AI proves base == view → trigger() runs 10,000× on 0xA000
read view->vector() 0xA000
fold to Constant(0xA000) memory.grow(1)
eliminated = true └─► no in-place room
└─► alloc 0xB000, copy, free 0xA000
└─► view->vector = 0xB000
trigger(1.1)
└─► store to 0xA000 + 0 ← freed
다이어그램을 왼쪽에서 오른쪽으로 읽어보면, compile-time 열에서는 view->vector()를 한 번 캡처해 immediate operand로 박아 넣고, runtime 열의 그 무엇도 이 immediate 값을 바꿀 수 없습니다. Grow 경로는 자신의 역할을 충실히 수행합니다. View의 vector 필드를 새 영역으로 갱신하기는 합니다. 다만 DFG로 컴파일된 trigger()의 본문은 더 이상 이 필드를 참조하지 않습니다. 마지막 줄의 store는 엔진이 이미 해제한, grow 이전 주소를 대상으로 합니다.
Bounds check가 storage-pointer fold와 독립적으로 동작한다는 점이 이 문제를 단순히 "틀린 stale pointer"보다 더 나쁘게 만듭니다. DFG의 length·bounds machinery는 storage-pointer fold와 분리되어 있기 때문에, access는 index를 view의 현재, 즉 grow된 length에 대해 검증한 뒤, 그렇게 검증된 index를 stale한 base에 적용합니다. Check와 pointer가 같은 객체의 서로 다른 시점에서 온 셈입니다. Grow 이후 buffer 기준으로는 완전히 정당한 index가, 이미 존재하지 않는 영역에 적용됩니다. 게다가 옛 영역의 범위를 넘어서는 index라면, access는 freed allocation의 끝을 완전히 넘어서게 됩니다.
Regression test 도입부는 단순한 test setup이 아니라 adversarial한 작업을 수행하고 있다는 점에서 눈여겨볼 만합니다.
while (true) {
try {
memories.push(new WebAssembly.Memory({initial: 65535, maximum: 65536}));
} catch (e) {
break;
}
}
이 memory들은 각각 거의 4GB에 달하는 reservation을 요구하며, loop는 process가 할당을 거부할 때까지 계속 allocate합니다. 목적은 다음번 WebAssembly.Memory가 in-place growth를 위해 사용했을 address space를 미리 소진시키는 것입니다. 이 공간이 소진된 상태에서 {initial: 1, maximum: 100} 크기의 작은 memory가 생성되면, 뒤에 넉넉한 reservation이 남아 있지 않게 됩니다. 그 결과 memory.grow(1)은 제자리에서 확장할 여유가 없어 allocate-copy-release 경로로 강제로 밀려납니다. 이 기법을 통해 "보통은 grow가 제자리에서 일어난다"는 확률적 장애물이 항상 동일하게 재현되는 재할당으로 바뀝니다.
스크립트가 수행하는 순서는 짧습니다.
- 대상 memory가 제자리에서 grow할 수 없도록 큰 virtual reservation들을 먼저 소진시킵니다.
- 작은 growable memory를 생성하고
toResizableBuffer()를 얻은 뒤, 그 위에Float64Array를 구성합니다. trigger()를 10,000회 실행해 DFG 컴파일을 강제로 유발합니다. 이때 fold가 현재 시점의vector를 캡처합니다.memory.grow(1)을 호출하면 재할당이 일어나며 기존 영역이 해제됩니다.trigger()를 다시 호출하면, store가 해제된 메모리에 그대로 도달합니다.
이번 fix는, view의 buffer가 runtime에서 주소가 바뀔 수 있는 유형에 속할 경우 아예 folding을 거부하도록 만들어 invariant를 되살립니다. Folding되지 않은 경로를 타게 되면 GetIndexedPropertyStorage가 code generation까지 그대로 살아남고, 매 access마다 base를 view에서 다시 로드하게 됩니다. 그 결과 컴파일된 코드는 항상 grow 경로가 가장 최근에 기록한 vector 값을 읽게 됩니다. 비용은 resizable view에 대해 access마다 load가 한 번씩 추가된다는 정도이고, 대신 컴파일러가 runtime이 명시적으로 뒤집을 권한을 가진 사실을 더 이상 단정하지 않게 된다는 이점을 얻습니다.
공격자가 여기서 실제로 얻는 것은 해제된 영역에 이후 무슨 일이 일어나느냐에 달려 있습니다. 확인되는 primitive는, 옛 backing store 범위 내의 attacker가 선택한 index에 attacker가 선택한 Float64 값을 store하는 것입니다. 이는 일반적인 web content에서 도달 가능하며, 같은 view를 통해 대칭적인 load도 가능합니다. 만약 해제된 영역을 공격자가 grooming한 allocation으로 다시 채울 수 있다면, 같은 access가 JavaScript type system이 더 이상 관장하지 않는 메모리에 대한 controlled relative write로 이어질 가능성이 있습니다. 이는 renderer 안에서 더 강력한 read/write primitive를 만들어가는 통상적인 출발점에 해당합니다. Reclamation이 일어나지 않는 경우라면, 안정적으로 재현되는 결과는 Apple의 advisory가 설명하는 crash입니다. 이 모든 과정은 WebContent process 내부에 머무릅니다. Stale pointer가 가리키는 대상은 같은 process가 소유한 Wasm linear-memory 영역이며, renderer 바깥으로 나아가려면 별도의 sandbox escape가 여전히 필요합니다.
Constant folding은 storage pointer가 무엇인지는 증명했지만, 그 상태를 계속 유지해도 되는지는 한 번도 묻지 않았습니다. 값은 알아낼 수 있었지만, 불변하지는 않았습니다.
Insight
이전에는 불변이었던 runtime 속성을 가변으로 바꾸는 기능 추가는, 그 옛 가정 위에 세워진 모든 최적화를 소급해서 무효화시킵니다. 문제는 그런 최적화들이 그 기능을 망가뜨린 지점과 한곳에 모여 있지 않고, 여러 compiler phase에 흩어져 있다는 점입니다. Resizable ArrayBuffer와 growable Wasm memory는 언어 기능으로 출시되었지만, storage 주소가 고정되어 있다는 가정에 암묵적으로 의존하던 compiler pass들은 완전히 다른 곳에 흩어져 있었고, 하나하나가 별도로 발견되어야 했습니다. 이 특정 folding 지점에 이미 bug 125425를 참조하는 FIXME가 달려 있었다는 사실이 단서입니다. 이 코드는 원래도 delicate하다고 알려져 있었고, 새 기능은 여기에 잘못될 수 있는 두 번째 경로를 추가한 셈입니다.
Audit directions
-
컴파일 시점에 relocatable address를 상수화하는 문제. 이때 지켜야 할 invariant는, 컴파일된 코드가 살아있는 동안 외부에서 값을 바꿀 수 없는 경우에만 그 값을 상수로 fold할 수 있다는 것입니다. Narrow:
Source/JavaScriptCore/dfg/와Source/JavaScriptCore/ftl/에서 컴파일 중에 상수JSArrayBufferView의->vector(),->byteLength(),->length()를 읽어 그 결과를 노드에 baking하는 지점을 모두 검색해야 합니다.DFGConstantFoldingPhase.cpp,DFGAbstractInterpreterInlines.h,DFGFixupPhase.cpp,FTLLowerDFGToB3.cpp가 시작점입니다. Match tell은 컴파일 시점에 heap object로부터 값을 로드한 뒤insertConstant/convertToConstant/eliminated = true로 이어지면서,isResizableOrGrowableShared()나 detachment, watchpoint에 대한 검사가 동반되지 않는 패턴입니다. Wider: 같은 계열의 문제는 최신 기능이 mutable하게 만든 property에 invalidation을 의존하는 모든 cached-address 최적화에서 나타납니다. JIT 컴파일된 Wasm 코드에 캐싱된 Wasm memory base pointer, transition 이전에 fold된Structure와 butterfly pointer, raw storage address를 들고 있는 inline-cache stub 등이 해당됩니다. 여기서 주목해야 할 형태는 컴파일 중에 캡처된 raw pointer가 script 실행이 가능한 시점 이후에도 그대로 사용되는 패턴입니다. Widest: constant propagation은 단순히 "현재 알 수 있는가"가 아니라 "불변인가"를 기준으로 게이팅되어야 합니다. 같은 질문이 V8 Turbofan의JSArrayBufferbacking store fold, SpiderMonkey Ion의TypedArrayObjectdata-pointer 특수화, 그리고 relocation이 허용된 allocator(moving GC, compacting allocator,realloc기반 growth)로부터 pointer를 inline하는 모든 AOT/JIT 시스템에도 동일하게 적용됩니다. fold된 주소마다 다음을 확인해야 합니다. 어떤 주체가 relocating API를 호출할 수 있는지, 그리고 그 호출에 watchpoint나 recompilation trigger가 걸려 있는지입니다. -
거울상 버그: pointer caching이 아니라 length caching. Narrow:
isResizableOrGrowableShared()가 성립하는 view에 대해GetArrayLength/GetTypedArrayByteOffset를 사용하는 DFG 노드를 모두 나열하고, 각각이 shrink와 growth 양쪽 모두에 대해 방어되어 있는지 확인해야 합니다. Match tell은 base view가 resizable인 상태에서 bounds check의 limit operand가 상수이거나 loop-invariant load인 경우입니다. bounds-check elimination, length load의 LICM,GetArrayLengthfold가 모두 script를 실행하거나 buffer를 resize할 수 있는 연산 이후에 length를 다시 계산하는지 검증해야 합니다. Wider: 같은 계열의 문제는 컨테이너 크기가 콜백이나 growth 가능한 API 호출 이전에 스냅샷되는 모든 위치에서 나타납니다. user callback을 거치는Vector/Arrayiteration, mutation을 거치는 DOM node-list length caching,Atomics.wait를 거치는SharedArrayBufferlength read 등이 해당됩니다. Widest: bounds check는 그 limit이 buffer 크기 변경만큼 자주 갱신될 때에만 안전합니다. 이 원칙은 V8의 resizableJSArrayBuffer지원이나,push동안 유지되는 raw slice pointer를 무효화하는 RustVec의 재할당에도 그대로 적용됩니다. -
FTL도 동일한 fold를 독립적으로 재현하는가? Fix는 하나의 DFG phase에만 존재하지만, abstract interpreter는 공유되며 FTL lowering은 AI가 base를 증명한 이후 constant storage pointer를 emit할 별도의 기회를 갖습니다. Narrow:
GetIndexedPropertyStorage를 비롯해 storage를 materialize하는 노드들을FTLLowerDFGToB3.cpp까지 추적해서, FTL 경로가 vector를 loop-invariant로 취급하거나 B3의 LICM/CSE가 이를 loop 밖으로 hoist하도록 두기 전에isResizableOrGrowableShared()를 확인하는지 검사해야 합니다. Match tell은 B3 상수, 혹은 growth를 유발할 수 있는 노드보다 위로 hoist된 값이면서, 그 출처가 컴파일 시점에 읽은 view의 data pointer인 경우입니다. Ceiling: 이 rung은 JSC의 two-tier DFG/FTL 구조에 종속됩니다. 한 tier의 phase에 적용된 fix가 다른 tier의 lowering까지 자동으로 커버하지는 않습니다. 이는 이식 가능한 원칙이라기보다 WebKit 고유의 구조적 hazard이며, ladder는 여기서 멈춥니다. -
grow 이전의 base pointer를 또 누가 들고 있는가? buffer를 relocate하는 API는 옛 base를 가리키는 모든 cached reference를 열거하고 갱신해야 합니다. 이 중 하나라도 놓치면 정확히 이런 종류의 stale pointer가 만들어집니다. Narrow:
memory.grow()가 copy-and-release 경로를 탈 때 무엇을 갱신하는지 추적해야 합니다. view의vector, Wasm instance의 memory-base register와 global, JIT에 embed된 base 등이 대상이며, 이 중 지연 갱신되거나 전혀 갱신되지 않는 holder를 식별해야 합니다. Match tell은 grow 경로가 기록하는 단일 정본 위치가 아닌 다른 곳에 base pointer가 저장되어 있는 경우입니다. Widest: relocation에는 완전한 reference fixup이 동반되거나, fixup 자체가 필요 없어지는 수준의 간접화가 필요합니다. 같은 감사 원칙이 compacting garbage collector, 임의 allocator의mremap기반 growth, database page-cache relocation에도 그대로 적용됩니다. 각 경우마다 질문은 동일합니다. cache를 모두 열거한 뒤, relocating routine이 그 전부를 건드리는지 증명해야 합니다.