← All reports

[JSC] Use-after-free after Wasm memory grow via stale pointer folded by DFGConstantFoldingPhase

HighJSC DFG JIT —UAF

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

Severity: High | Component: JSC DFG JIT — ConstantFoldingPhase | 13bfbf9 | Bugzilla 312781

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 역사 대부분에서 이 "뒤집을 수 없는" 영역에 편안히 자리해 왔습니다. JSArrayBufferViewvector pointer는 buffer가 할당되는 순간 고정되었기 때문입니다. 그런데 resizable ArrayBuffer와 growable Wasm memory가 등장하면서, 이 사실은 가변 영역으로 넘어갔습니다. 문제는 여기에 의존하던 최적화들은 함께 옮겨가지 않았다는 점입니다.

관전 포인트: 페이지 하나만으로도 optimized JIT code가 엔진이 이미 해제한 메모리 영역에 attacker가 선택한 double 값을 계속 기록하도록 만들 수 있습니다.

Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp

// https://bugs.webkit.org/show_bug.cgi?id=125425
break;
}
 
-
+
+ if (view->isResizableOrGrowableShared()) {
+ // resizable 및 growable-shared view는 resize() / WebAssembly.Memory.grow()에 의해
+ // backing store가 재할당될 수 있으므로, folding된 vector pointer가 stale해질 수 있습니다.
+ break;
+ }
+
m_interpreter.execute(indexInBlock);
eliminated = true;

JSTests/stress/resizable-array-constant-folding.js

+//@ runDefault("--useConcurrentJIT=false")
+const memories = [];
+(function() {
+ while (true) {
+ try {
+ memories.push(new WebAssembly.Memory({initial: 65535, maximum: 65536}));
+ } catch (e) {
+ break;
+ }
+ }
+})();
+
+const memory = new WebAssembly.Memory({initial: 1, maximum: 100});
+const buffer = memory.toResizableBuffer();
+const view = new Float64Array(buffer);
+
+function trigger(val) {
+ view[0] = val;
+}
+
+for (let i = 0; i < 10000; i++)
+ trigger(13.37);
+
+memory.grow(1);
+trigger(1.1);

수정된 파일은 두 개이며, 그중 실제 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가 이어지는 순서가 항상 동일하게 재현됩니다.

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.MemorytoResizableBuffer(). Wasm linear memory는 initialmaximum 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로 시작하는 이유입니다.

여기서 빠진 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가 제자리에서 일어난다"는 확률적 장애물이 항상 동일하게 재현되는 재할당으로 바뀝니다.

스크립트가 수행하는 순서는 짧습니다.

  1. 대상 memory가 제자리에서 grow할 수 없도록 큰 virtual reservation들을 먼저 소진시킵니다.
  2. 작은 growable memory를 생성하고 toResizableBuffer()를 얻은 뒤, 그 위에 Float64Array를 구성합니다.
  3. trigger()를 10,000회 실행해 DFG 컴파일을 강제로 유발합니다. 이때 fold가 현재 시점의 vector를 캡처합니다.
  4. memory.grow(1)을 호출하면 재할당이 일어나며 기존 영역이 해제됩니다.
  5. 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가 무엇인지는 증명했지만, 그 상태를 계속 유지해도 되는지는 한 번도 묻지 않았습니다. 값은 알아낼 수 있었지만, 불변하지는 않았습니다.

이전에는 불변이었던 runtime 속성을 가변으로 바꾸는 기능 추가는, 그 옛 가정 위에 세워진 모든 최적화를 소급해서 무효화시킵니다. 문제는 그런 최적화들이 그 기능을 망가뜨린 지점과 한곳에 모여 있지 않고, 여러 compiler phase에 흩어져 있다는 점입니다. Resizable ArrayBuffer와 growable Wasm memory는 언어 기능으로 출시되었지만, storage 주소가 고정되어 있다는 가정에 암묵적으로 의존하던 compiler pass들은 완전히 다른 곳에 흩어져 있었고, 하나하나가 별도로 발견되어야 했습니다. 이 특정 folding 지점에 이미 bug 125425를 참조하는 FIXME가 달려 있었다는 사실이 단서입니다. 이 코드는 원래도 delicate하다고 알려져 있었고, 새 기능은 여기에 잘못될 수 있는 두 번째 경로를 추가한 셈입니다.