← All reports

[JSC] Fix incorrect handling of unwritten slots in operationPopulateObjectInOSR for PhantomNewArrayWithButterfly

MediumJSC FTL JIT — OSR-exit object materializationMemoryCorruption

CVE: CVE-2026-28905 · Safari 26.5 · Released May 13, 2026 Impact: 악의적으로 조작된 웹 콘텐츠를 처리하는 과정에서 예기치 않은 process crash가 발생할 수 있습니다. Apple's description: 개선된 memory handling으로 문제를 해결했습니다. Credit: Yuhao Hu, Yuanming Lai, Chenggang Wu, and Zhe Wang

392f508 | Bugzilla 308545

Medium. Apple의 crash 분류가 실제 한계치에 가까울 가능성이 높습니다. 다만 severity가 어느 쪽으로 굳어지는지는, 기록되지 않은 slot이 두 가지 결과 중 어느 쪽으로 떨어지는지에 전적으로 달려 있습니다. 이 항목이 Low로 내려가지 않는 이유는 두 번째 결과 때문입니다. Empty JSValue가 그 위치까지 포함하는 published length를 가진 butterfly에 그대로 남는다면, 이는 단순한 signal이 아니라 value confusion에 해당합니다.

Optimizing JIT 컴파일러는 컴파일된 영역을 벗어나지 않는 객체의 할당을 생략함으로써 상당한 속도 이득을 얻습니다. 다만 그 대가로, speculation이 실패하는 순간 해당 객체를 다시 만들어낼 방법을 런타임에 빚지게 됩니다. JSC에서는 이 빚이 OSR exit 시점에 청산됩니다. 이때 런타임 helper가 컴파일 타임에 기록된 hint 목록을 순회하며 새로 할당한 heap 객체에 이를 재생합니다. PhantomNewArrayWithButterfly — indexed storage를 소유하는 array의 phantom 형태 — 의 경우, 이 기록은 태생적으로 부분적입니다. 인덱스 k에 대한 store는 프로그램이 실제로 밟은 경로가 k까지 도달했을 때만 기록되기 때문입니다.

관전 포인트: map callback이 루프 도중 JIT를 bail out시키게 만드는 페이지는, 한 번도 기록되지 않은 항목이 hole이 아니라 값으로 공개된 array를 얻게 됩니다.

JSTests/stress/ftl-osr-exit-phantom-array-unwritten-slot.js

+function opt() {
+ const arr = [0,0,0,0,0,0,0];
+ function f() {
+ arr[0];
+ (0)[0]; // 숫자에 대한 property access - BadType OSR exit를 유발합니다
+ return arr;
+ }
+ const ret = arr.map(f) // map의 결과 array는 sunk allocation입니다 (PhantomNewArrayWithButterfly)
+ ret[0] = 1 // OSR exit로 materialize된 array에 store
+}
+for (let i = 0; i < 200; i++) {
+ opt() // 200회 반복 -> FTL tier-up
+}

실제 production 변경은 operationPopulateObjectInOSR에 적용됩니다. 이 함수는 top tier가 아래 tier로 bail out하는 순간 sunk allocation을 채워 넣는 FTL helper입니다. Commit message에 따르면, OSR exit 도중 PhantomNewArrayWithButterfly가 materialize될 때 exit 지점 이전에 조건부로 기록되지 않은 indexed slot은, putDirectIndex에 전달되는 대신 hole로 materialize됩니다. 다시 말해 population loop에 두 번째 emission 경로가 추가된 셈입니다. 기록된 값이 없는 slot은 더 이상 "여기에 실제 값을 기록하라"는 계약을 가진 store helper로 흘러들지 않고, 그대로 비어 있는 상태로 남겨집니다.

이 패치와 함께 위 regression test가 추가되었는데, 이 테스트는 버그를 설명하는 spec으로 읽어볼 가치가 있습니다. arr은 원소 7개짜리 array literal입니다. arr.map(f)는 JSC의 Array.prototype.map builtin을 inline시키므로, 결과 array는 inline된 body 내부에 할당되어 sinking 후보가 됩니다. Callback f(0)[0]을 실행하는데, 이는 숫자에 대한 property access이며 FTL이 cell로 speculate해 두었던 자리입니다. 따라서 speculation은 callback의 첫 번째 호출에서 곧바로 실패합니다. 뒤이어 나오는 ret[0] = 1이 이 테스트의 핵심입니다. Materialize된 array에 후속 store를 가해, 잘못 채워진 결과가 단순히 생성되는 데서 그치지 않고 실제로 건드려지도록 합니다. 200회 반복하는 driver loop는 이 함수를 baseline과 DFG를 거쳐 FTL까지 끌어올리기 위한 것으로, 이 tier에서 sinking과 이번 materialization helper가 함께 작동합니다.

Tiering과 OSR exit. JSC는 함수를 interpreter, baseline, DFG, FTL 순으로 점점 더 공격적인 tier로 실행하며, 수백 회 호출된 이후 상위 tier로 승격시킵니다. Optimizing tier는 speculative하게 컴파일됩니다. 즉 profiling으로 관찰된 shape가 계속 유지될 것이라 가정하고, 그 가정 하에서만 유효한 코드를 생성합니다. 이 가정이 깨지면 엔진은 OSR exit를 수행합니다. 실행 도중이던 optimized frame을 포기하고 하위 tier로 전환하며, 컴파일 타임에 준비해 둔 recovery record를 바탕으로 interpreter가 볼 수 있는 상태를 다시 구성합니다.

Object allocation sinking. 컴파일러가 할당된 객체가 컴파일된 영역을 절대 벗어나지 않는다는 것을 증명할 수 있으면, 해당 할당을 아예 제거하고 그 자리를 런타임 작업을 전혀 수행하지 않는 Phantom… 노드로 대체합니다. 이후 객체의 내용은 symbolic하게 추적됩니다. 실제로 일어났을 각 store는 PutHint라는 IR 노드가 되며, "이 프로그램 지점에서 sunk 객체의 이 field 또는 index는 이 값을 가졌을 것이다"를 기록합니다.

Materialization. Sinking은 아무도 해당 객체를 관찰할 수 없는 동안에만 유효합니다. Sunk 객체가 관찰 가능해지는 시점 — 대표적으로 OSR exit — 이 오면, 런타임은 그 객체를 실제로 할당하고 기록된 hint로부터 값을 채워 넣어야 합니다. operationPopulateObjectInOSR은 이 population 단계를 수행하는 FTL 측 helper입니다.

PhantomNewArrayWithButterfly. butterfly를 소유하는 array allocation의 phantom 형태입니다. Butterfly란 객체의 indexed element와 named property를 담는 out-of-line storage block을 말합니다. Indexed storage를 소유하므로 기록되는 상태에는 named property뿐 아니라 indexed slot도 포함되며, population loop는 index 위치를 순회합니다.

Holes, and the empty JSValue. hole은 array의 length 범위 안에 있지만 값을 가지지 않는 index를 말합니다. Hole을 읽으면 array에서 멈추지 않고 prototype chain으로 이어져 내려갑니다. Hole이 어떻게 표현되는지는 array의 indexing type에 따라 달라지는데, contiguous storage에서는 empty JSValue로, double storage에서는 poisoned NaN으로 표현됩니다. empty JSValue는 엔진이 내부적으로 "여기에는 아무것도 없다"는 의미로 사용하는 default-construct된 JSValue() sentinel입니다. JavaScript 소스 코드가 직접 이름 붙이거나 만들어낼 수 있는 값이 아닙니다.

putDirectIndex. JSObject의 direct indexed-store 진입점으로, builtin들이 @putByValDirect를 통해 사용합니다. Index에 실제 값을 저장하는 메커니즘이며, hole을 만들어내는 메커니즘은 아닙니다.

Array.prototype.map. JSC에서는 JS builtin으로 구현되어 있어, optimizing tier가 그 body를 caller 안으로 inline할 수 있습니다. 결과 array는 이 inline된 body 내부에 할당되며, 사용자 JavaScript로 다시 callback을 호출하는 loop 안에서 index 단위로 채워집니다.

Root cause는 exit-state를 재구성하는 과정의 논리 오류입니다. Hint record가 두 가지 형태를 가질 수 있음에도, population loop에는 emission 경로가 하나뿐이었습니다.

  Before:                              After:
  populate(array, hints)               populate(array, hints)
    └─ for i in 0..len-1                 └─ for i in 0..len-1
         v = hints[i]                         v = hints[i]
         └─► putDirectIndex(i, v)             ├─ no hint ─► leave slot as hole
                ▲                             └─ hint ───► putDirectIndex(i, v)
                └─ i never written on the
                   taken path: v is the
                   "no value" sentinel

왼쪽 열이 fix 이전의 모습입니다. operationPopulateObjectInOSR은 materialize된 array의 모든 indexed slot을 순회하면서, 마치 각 slot이 실제 값을 담고 있는 것처럼 취급해 곧바로 putDirectIndex로 넘겼습니다. 이 방식이 안전하려면 hint set이 완전해야 합니다. 하지만 hint set은 결코 완전하지 않습니다. 부분적이라는 사실 자체가 sinking의 존재 이유이기 때문입니다. Index k에 대한 PutHint는 control-dependent합니다. 즉 어떤 경로에서는 store가 일어나고 다른 경로에서는 일어나지 않거나, 프로그램이 도달하지 못한 이후 iteration에서야 일어났을 store를 기록합니다. 그 store가 실행되기 전에 exit이 발생하면, index k에 대한 recovery record는 값을 갖지 못합니다. 이때 store helper로 흘러들어간 것은 그 위치에서 type system이 정당하다고 간주하는 JSValue가 아니라, 내부용 "no value" sentinel이었습니다.

이 테스트의 실행 과정은 해당 상태에 도달하는 최소한의 경로입니다.

  1. opt()를 200번 호출하면 FTL까지 올라갑니다.
  2. arr.map(f)가 inline되면서 결과 array의 allocation은 PhantomNewArrayWithButterfly로 sink됩니다. Builtin이 iteration마다 수행하는 @putByValDirect(result, i, newValue) store들은 그 위에 PutHint로 남습니다.
  3. 0번째 iteration에서 f(0)[0]을 실행합니다. 이는 base가 Int32GetByVal이지만, FTL은 이 자리를 cell로 speculate해 두었습니다.
  4. Speculation check가 실패하면서 BadType OSR exit가 발생합니다. 결과 array 7개 slot 중 어느 것도 기록되지 않은 상태이며, 기껏해야 앞쪽 일부만 기록될 수 있는 상황이었습니다.
  5. Materialization은 7개 원소짜리 결과를 실제로 할당하고 slot 0부터 6까지 순회하면서, 기록되지 않은 slot들을 putDirectIndex에 그대로 넘깁니다.
  6. 이어서 ret[0] = 1이 materialization이 방금 공개한 array에 store를 수행합니다.

5단계가 두 결과 중 어느 쪽으로 이어지는지는 recovery record에서 absent slot이 어떻게 encoding되는지에 달려 있습니다. Empty JSValueputDirectIndex에 도달하면 그 함수의 precondition을 위반하게 됩니다. 이 경우 release assertion이 발생해 attacker가 유발한 renderer crash로 이어지거나 — 이는 Apple이 말하는 "unexpected process crash"에 해당합니다 — 또는 publicLength가 해당 위치를 포함하는 butterfly에 empty 값이 그대로 기록될 수 있습니다. 두 번째 결과는 "script에서 관찰 가능한 empty JSValue"라는 전형적인 위험에 해당합니다. 이후 ret[0]을 읽으면 type system이 그 위치에 존재할 수 없다고 믿는 bit pattern이 반환될 가능성이 있고, 이는 단순한 signal이 아니라 value confusion의 출발점이 됩니다. Commit 메시지 자체의 표현 — incorrect handling of unwritten slots — 과 Apple의 분류를 함께 보면, 실제로는 assertion 경로가 발현되었을 가능성에 무게가 실립니다.

이번 fix는 materializer가 원래 지켜야 할 invariant를 복원합니다. Array의 butterfly에 공개되는 모든 indexed slot은 완전히 초기화된, script가 표현할 수 있는 값이어야 하며, 실행된 경로가 한 번도 기록하지 않은 index는 hole로 나타나야 합니다. 올바른 hole encoding은 indexing type에 따라 달라지므로, "hole로 취급한다"는 것은 단일 명령이 아니라 backing별로 내려야 하는 결정입니다. Contiguous storage에는 empty JSValue가, double storage에는 poisoned NaN이 필요합니다.

실행된 경로가 한 번도 기록하지 않은 index가, 마치 값을 갖고 있던 것처럼 materialize된 array에 replay되었습니다. 실제 값을 요구하는 store 경로에 내부용 "no value" sentinel이 그대로 전달된 것입니다.

Sunk allocation을 materialize하는 지점은 JSC에서 반복적으로 문제가 되는 취약 부위입니다. 컴파일러가 가진 객체 모델 — 일부는 control-dependent한 hint들의 집합 — 을 모든 런타임 invariant를 동시에 만족하는 실제 heap 객체로 되돌려야 하는 유일한 지점이기 때문입니다. Hint set은 본질적으로 부분적인 반면, 그 값을 받아 처리하는 store 경로(putDirectIndex 및 그 계열 함수들)는 완전한 경우를 전제로 작성되어 있습니다. PhantomNewArrayWithButterfly처럼 indexed storage를 소유하는 새로운 phantom node 종류가 도입될 때마다, 이 storage 종류에서 "absent"가 무엇을 의미하는가라는 질문에 매번 처음부터 다시 답해야 합니다. Indexed storage에 대한 정답(hole)이 named property의 정답(대개 undefined)과 같지 않기 때문입니다.