[JSC] Fix incorrect handling of unwritten slots in operationPopulateObjectInOSR for PhantomNewArrayWithButterfly
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
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
Patch Details
실제 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가 함께 작동합니다.
Background
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 단위로 채워집니다.
Analysis
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이었습니다.
이 테스트의 실행 과정은 해당 상태에 도달하는 최소한의 경로입니다.
opt()를 200번 호출하면 FTL까지 올라갑니다.arr.map(f)가 inline되면서 결과 array의 allocation은PhantomNewArrayWithButterfly로 sink됩니다. Builtin이 iteration마다 수행하는@putByValDirect(result, i, newValue)store들은 그 위에PutHint로 남습니다.- 0번째 iteration에서
f는(0)[0]을 실행합니다. 이는 base가Int32인GetByVal이지만, FTL은 이 자리를 cell로 speculate해 두었습니다. - Speculation check가 실패하면서 BadType OSR exit가 발생합니다. 결과 array 7개 slot 중 어느 것도 기록되지 않은 상태이며, 기껏해야 앞쪽 일부만 기록될 수 있는 상황이었습니다.
- Materialization은 7개 원소짜리 결과를 실제로 할당하고 slot 0부터 6까지 순회하면서, 기록되지 않은 slot들을
putDirectIndex에 그대로 넘깁니다. - 이어서
ret[0] = 1이 materialization이 방금 공개한 array에 store를 수행합니다.
5단계가 두 결과 중 어느 쪽으로 이어지는지는 recovery record에서 absent slot이 어떻게 encoding되는지에 달려 있습니다. Empty JSValue가 putDirectIndex에 도달하면 그 함수의 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이 그대로 전달된 것입니다.
Insight
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)과 같지 않기 때문입니다.
Audit directions
-
전체 항목이 존재한다고 가정하는 store API를 가진 구조에, 지연되거나 부분적인 state record가 replay되는 패턴. Invariant: materializer는 "X로 기록됨"과 "한 번도 기록되지 않음"을 구분해야 하며, 후자를 위한 별도의 emission 경로를 가지고 있어야 합니다. Narrow —
Source/JavaScriptCore/ftl/FTLOperations.cpp의operationMaterializeObjectInOSR/operationPopulateObjectInOSR와 DFG 쪽 대응 함수가 처리하는 모든 phantom node 종류를 점검하십시오. Indexed slot이든 named slot이든 각 loop에서, absent case가 store 호출 이전에 자신만의 분기를 갖고 있는지 확인해야 합니다. 매칭 단서는, slot index를 순회하며 값을 계산해 store helper를 무조건 호출하면서, 앞단에if (!value)/isEmpty()guard가 없는 loop입니다. Wider — 같은 형태는 JSC가 기록된 state를 실제 객체로 replay하는 모든 곳에서 나타날 수 있습니다.DFGOSRExit의 value recovery,DFGObjectAllocationSinkingPhase.cpp의Materialize*처리, arguments 객체 materialization(PhantomDirectArguments,PhantomClonedArguments), structure-transition replay 등이 대상입니다. 단서는 default-construct된 recovery entry를 정당한 값으로 취급하는 consumer가 있는지입니다. Widest — 원칙은 전체(total) 구조로 replay되는 부분적 record에는 absent 값을 명시적으로 encoding하는 장치가 필요하다는 것이며, 이는 WebKit 바깥의 모든 deoptimizing 런타임에도 적용됩니다. V8의 Turbofan escape-analysis deopt materialization, SpiderMonkey의 recover instruction과 bailout snapshot이 그 예이고, protobuf/JSON round-trip에서 "field unset"이 "field is zero"로 뭉개지는 일반적인 serialization 코드에도 같은 원칙이 적용됩니다. 이런 코드베이스를 볼 때 던져야 할 질문은, replay 경로에 "value"와 "default" 외에 세 번째 case가 있는지, 그리고 caller의 precondition이 그 case에 대해 검사되는지입니다. -
내부용 sentinel 값이 script에서 볼 수 있는 컨테이너로 새어 나가는 패턴. Invariant: JavaScript가 이름 붙일 수 없는 sentinel은 일반적인 indexed/named read를 통해 절대 도달 가능해서는 안 됩니다. Narrow —
Source/JavaScriptCore/ftl/FTLOperations.cpp,Source/JavaScriptCore/dfg/DFGOperations.cpp,Source/JavaScriptCore/runtime/JSObject.cpp에서putDirectIndex,putDirectMayBeIndex,initializeIndex호출을 검색하고, 이때 값 operand가 exit scratch buffer나 recovery record에서 온 경우를 살펴보십시오. 단서는 OSR 구조에서 나온JSValue가if (value)나ASSERT(value)같은 중간 검사 없이 그대로 store helper로 흘러드는 경우입니다. Wider — 이 범주는 hole 표현이 indexing type별로 선택되는 WebKit의 모든 경로를 포괄합니다. Contiguous(empty), double(poisoned NaN), ArrayStorage backing 각각에서 slot이 건너뛰어지는 모든 지점이 제대로 처리되는지 확인해야 합니다. Contiguous storage만 고치는 fix는ArrayWithDouble에 대해서는 여전히 틀린 결과를 낼 수 있기 때문입니다.convertContiguousToArrayStorage의 호출자들과ensure*계열 함수를 따라가며 확인하십시오. Widest — producer와 consumer가 encoding에 대해 합의하지 않으면 in-band sentinel이 새어 나간다는 원칙은, tagged value와 예약된 bit pattern을 쓰는 모든 런타임에 적용됩니다. Holey array에서의 V8the_hole, list 내부에서 CPython의NULL-대-Py_None구분, Rust의MaybeUninitslice가 그 예입니다. 데이터 구조가 "여기엔 값이 없다"는 encoding을 예약해 둔 곳이라면 어디든, 그 encoding이 평범한 데이터로 도달할 수 있는지 모든 write 경로를 점검해야 합니다. -
Control-dependent writes inside an inlined builtin loop whose target allocation has been sunk. 여기서 지켜야 할 invariant는 다음과 같습니다. 실제로 taken path에서 실행된 store의 개수와, materialize된 hint의 개수가 정확히 일치해야 합니다. 루프가 언젠가 수행할 store 개수와 일치해야 하는 것이 아닙니다. 범위를 좁혀서 보면,
Source/JavaScriptCore/builtins/ArrayPrototype.js에 있는 builtin들, 즉 result array를 할당한 뒤 user callback 아래에서 index별로 채워 넣는map,filter,flatMap, 그리고splice인접 helper들을 점검할 필요가 있습니다. 각 builtin이 n개 중 k번째 iteration에서 callback이 exit할 때 무엇을 materialize하는지 확인해야 합니다. 이번 commit은map케이스를 다루고 있으므로, 나머지는 바로 인접한 variant 집합에 해당합니다. 패턴을 식별하는 tell은@arraySpeciesCreate혹은@newArrayWithSize를 호출한 뒤, user JS로 재진입 가능한 루프 body 안에서@putByValDirect로 write하는 형태입니다. 범위를 넓히면, user callback이 allocation과 완전한 초기화 사이에서 exit를 강제할 수 있는 모든 inline 영역이 이 class에 포함됩니다. Iterator helper,Promisecombinator builtin, typed-array의from/of경로가 여기에 해당합니다. 코드를 검색할 때는, user JS로 재진입 가능한 body를 가진 루프에 dominate되는 allocation node를 찾아보아야 합니다. 가장 넓은 범위에서 보면, 이는 제어 흐름의 이른 이전으로 인해 부분적으로만 초기화된 객체가 외부에 관찰 가능해지는 일반적인 class에 해당합니다. 이 class는this를 완성 전에 publish하는 constructor, 어떤 JIT의 escape analysis에서든 발생할 수 있는 scalar replacement, 그리고 C++ constructor의 exception 경로까지 이어집니다. Allocation과 초기화 완료 사이에서 제어권이 해당 영역을 벗어날 수 있다면, 어떤 component든 외부에서 본 half-built 객체의 모습을 명확히 정의해야 합니다. -
The same reconstruction logic implemented twice across tiers, with a fix landing in only one. 범위를 좁혀서 보면,
Source/JavaScriptCore/ftl/FTLOperations.cpp의 slot 채우기 루프를, 각 phantom node type에 대응하는Source/JavaScriptCore/dfg/DFGOSRExit.cpp와DFGOSRExitCompilerCommon.cpp의 recovery 처리와 diff해 보고, 두 곳 모두 absent-slot case를 분기 처리하는지 확인할 필요가 있습니다. 이때의 tell은 한 파일의 switch문에 열거된 phantom node kind가, 다른 파일에서는 다르게 처리되거나 아예 처리되지 않는 경우입니다. 범위를 넓히면, 이 class는 JSC가 DFG와 FTL 양쪽에 대해 두 번씩 구현하는 모든 semantics로 일반화됩니다. Abstract interpretation rule, clobberize effect, safe-to-execute rule이 여기에 해당하며, 한쪽 tier에서 이루어진 soundness fix가 다른 tiering 경로를 통해 여전히 도달 가능한 상태로 남는 경우입니다. 가장 넓은 범위에서 보면, 같은 abstraction의 두 구현에 중복 존재하는 semantics는 양쪽을 모두 검증하는 shared oracle이 없는 한 조용히 divergence를 일으킨다는 것이 핵심 audit 질문이며, 이는 V8의 Turbofan 대 Maglev lowering, LLVM의 target별 instruction selection이 generic combine을 중복하는 경우, 그리고 모든 dual-path validator에도 동일하게 적용됩니다. 여기서 divergence를 확인하는 작업은 단순한 grep으로 되지 않습니다. 새로 추가된 regression test를 DFG-only, FTL-only tier 설정 각각에서 실행하고 결과를 비교하는 과정이 필요할 가능성이 높습니다.