[JSC] Fix B3 ReduceStrength Select specialization when a Check appears between the Select and triggering Check
CVE: CVE-2026-64715 · Safari 26.6.1 · 2026년 8월 18일 출시 Impact: 악의적으로 조작된 웹 콘텐츠를 처리하면 예기치 않은 프로세스 crash가 발생할 수 있습니다 Apple's description: 메모리 관리 개선을 통해 use-after-free 문제를 해결했습니다 Credit: Hossein Lotfi (@hosselot) of TrendAI Zero Day Initiative
Medium. 컴파일러 내부 캐시가 optimizer가 방금 파괴한 IR 노드의 raw pointer를 그대로 들고 있었습니다. 게다가 그 캐시가 "이 항목이 아직 유효한가"를 판정하는 방식 자체가 이미 해제된 노드에서 값을 읽는 동작입니다. 현실적인 결과는 컴파일러 스레드의 crash 수준입니다. 이후의 CSE 판단까지 유도하려면 해제된 slot을 다른 IR 노드가 먼저 재사용해야 합니다.
JavaScriptCore의 최상위 optimizing tier는 JavaScript를 B3로 낮춥니다. B3는 SSA 형태의 intermediate representation이고, 노드 하나하나가 heap에 할당된 객체입니다. optimization phase들은 이 노드를 자유롭게 다시 쓰고, 복제하고, 파괴합니다.
각 phase는 속도를 위해 노드 identity 기준으로 결과를 memoize합니다. PureCSE는 pure computation의 구조적 해시를 키로 삼아, 그 값을 생성하는 raw Value* 포인터들을 작은 vector에 담아둡니다. 덕분에 같은 계산이 뒤에 다시 나타나면 앞선 값으로 접을 수 있습니다.
다만 이런 memoization에는 테이블 스스로 강제할 수 없는 의무가 따라붙습니다. 테이블에 올려둔 포인터는 테이블보다 오래 살아 있거나, 노드가 죽기 전에 미리 빠져나와야 합니다.
관전 포인트: 특정 IR 형태를 갖는 함수를 컴파일하도록 유도하는 웹 콘텐츠만으로, JIT 컴파일러 스레드가 이미 해제한 IR 노드를 읽게 만들 수 있습니다. 그 결과는 renderer의 crash입니다.
Source/JavaScriptCore/b3/B3ReduceStrength.cpp
Source/JavaScriptCore/b3/B3PureCSE.cpp
Source/JavaScriptCore/b3/B3PureCSE.h
Source/JavaScriptCore/b3/testb3_6.cpp
Patch Details
production 코드 변경 두 건과 regression test 하나로 구성되며, 모두 Source/JavaScriptCore/b3/ 안에 있습니다.
B3PureCSE.{h,cpp}에는 PureCSE::remove(const ValueKey&, Value*) 메서드가 새로 추가되었습니다. 동작은 단순합니다. key가 null이면 곧바로 반환하고, m_map에서 key를 찾아 없는 경우에도 그대로 반환합니다. key가 존재하면 Matches 버킷 — Vector<Value*, 1> — 에 대해 matches.removeAll(value)를 호출합니다.
비어버린 버킷을 지우지 않는 것은 의도된 선택입니다. findMatch()와 process() 모두 vector를 순회할 뿐이고, 비어 있으면 후보가 나오지 않으므로 남겨두어도 손해가 없기 때문입니다.
B3ReduceStrength.cpp에서는 이 메서드를 specializeSelect()에 연결합니다. 대상은 Select의 block 분할 지점과 트리거 Check 사이의 value들을 도는 루프 — for (unsigned i = startIndex + 1; i < predecessor->size(); ++i) — 입니다. 여기서 ValueKey key = value->key();를 루프 본문 맨 앞으로 끌어올렸고, 이유를 설명하는 주석도 함께 달렸습니다. cloneValue()가 value를 변경하므로 key를 먼저 확보해야 한다는 내용입니다.
타입 검사의 Void 쪽은 맨 else에서 블록 형태로 바뀌었습니다. 그 안에서 m_proc.deleteValue(value) 직전에 m_pureCSE.remove(key, value)를 호출합니다. value->owner = nullptr; 줄과 Void가 아닌 경우의 m_insertionSet.insertValue(...) 경로는 그대로 유지되었습니다.
테스트 쪽에는 B3 IR unit test 하나가 추가되었습니다. testb3_6.cpp의 testCheckSelectAndDeadCheckCSE()이며, testb3.h에 선언되고 testb3_1.cpp의 run()에서 RUN(...)으로 등록됩니다. 위치는 기존 testCheckSelectAndCSE() 바로 옆입니다. 기존 테스트에 없던 요소가 하나 더해졌는데, specialize 대상 구간 안에 Void Check가 있고 그 predicate가 이후에 다시 사용된다는 점입니다.
Background
B3. JavaScriptCore의 저수준 SSA intermediate representation이자 backend입니다. 최상위 JavaScript JIT인 FTL과 WebAssembly의 OMG tier가 모두 B3를 컴파일 대상으로 삼습니다. Procedure가 BasicBlock 집합을 소유하고, 각 block은 순서가 있는 Value 목록을 갖습니다. 모든 Value는 자신이 속한 block을 가리키는 owner 포인터를 들고 있으며, Procedure::deleteValue(Value*)는 procedure의 value collection에서 해당 value를 제거합니다.
reduceStrength. peephole rewrite, constant folding, reassociation, canonicalization을 담당하는 B3 phase입니다. value를 block 순서대로 훑으면서 fixpoint에 도달할 때까지 반복하므로, 같은 block을 여러 차례 다시 방문하게 됩니다. 이때 phase 객체가 들고 있는 상태는 sweep 전체에 걸쳐 유지됩니다.
Dominators. dominators.dominates(a, b)는 block b에 도달하는 모든 경로가 block a를 거칠 때 true가 됩니다. a에서 이미 계산된 value를 b에서 다시 계산하지 않고 재사용해도 되는지를 판정하는 조건입니다.
ValueKey와 PureCSE. ValueKey는 value의 opcode, 타입, children에서 유도되는 구조적 해시 키입니다. 같은 키를 갖는 두 value는 같은 계산을 수행합니다. PureCSE는 재사용 가능한 common-subexpression-elimination 테이블로, ValueKey를 Vector<Value*, 1> 타입인 Matches에 대응시킵니다. process(value, dominators)는 value를 키 아래에 기록하고, 같은 키로 이미 기록된 value가 현재 value를 dominate하는 관계라면 replaceWithIdentity(match)로 현재 value를 다시 씁니다. findMatch(key, block, dominators)는 기록 없이 동일한 dominator 필터링 조회만 수행합니다. 두 함수 모두 match->owner와 match->owner->isInserted()를 확인해 후보를 걸러냅니다.
Check와 Select. Check는 predicate를 검사해 값이 0이 아니면 out-of-line stackmap generator로 점프합니다. FTL에서는 OSR exit와 speculation 실패가 이런 형태로 표현됩니다. Check의 타입은 Void이므로 데이터 value를 생성하지 않습니다. 그럼에도 PureCSE::process()는 Check를 기록하는데, 같은 predicate에 대한 이후의 Branch를 dominate하는 Check 기준으로 해소할 수 있기 때문입니다. Select는 B3의 branch 없는 삼항 연산으로, Select(cond, a, b)는 a 또는 b를 반환합니다.
specializeSelect(). ReduceStrength의 transform 중 하나입니다. Check의 predicate가 상수 arm을 하나 이상 가진 Select에서 유도되고, 둘 사이의 거리가 selectSpecializationBound(현재 소스에서는 3) 이내인 경우에 동작합니다. 이때 해당 Check 지점에서 block을 분할하고, 사이에 있던 value들을 then/else 두 arm으로 복제합니다. 그러면 각 arm은 Select가 있던 자리에서 상수를 보게 되고, 합류 지점에는 Phi가 놓입니다. Void가 아닌 원본은 합류 지점에 다시 삽입되고, Void 원본은 원래 block에서 제거된 뒤 양쪽 arm에 다시 생성됩니다.
Analysis
이번 버그는 컴파일러 IR 노드에 대한 use-after-free입니다. 원인은 노드를 파괴하는 동작과 memoization 테이블이 서로의 존재를 전혀 모른 채 공존했다는 점에 있습니다.
reduceStrength sweep over the block
─────────────────────────────────────────────────────────────
i=1 Select(arg0, -42, 35) ── process() ─► m_pureCSE[key(Select…)] += @sel
i=2 Check(@cond) ── process() ─► m_pureCSE[key(Check,@cond)] += @chk
i=3 Add(@sel, 42)
i=4 Check(@add) ── triggers specializeSelect()
├─ clone @chk into then/else arms
├─ @chk->owner = nullptr (tombstone)
└─ deleteValue(@chk) (tombstone freed)
i=5 Check(@cond) ── process() ─► bucket still holds @chk
└─► load @chk->owner ✗ UAF
이 상황을 장전하는 쪽은 sweep 순서입니다. 2단계의 중간 Check는 트리거가 되는 Check보다 앞에 있습니다. 그래서 4단계에서 specialization이 발동하는 시점에는 PureCSE::process()가 이미 그 Check를 ValueKey(Check, Void, condition) 아래에 등록해 둔 상태입니다.
specialization은 그 Check를 양쪽 arm으로 복제한 뒤 원본을 파괴합니다. 자기 관점에서는 올바른 처리입니다. 해당 노드는 더 이상 block 어디에도 속하지 않기 때문입니다. 다만 패치 이전에는 이 사실을 m_pureCSE에 알리지 않았습니다. 결과적으로 stale Value*가 sweep이 끝날 때까지 버킷에 남게 되는데, ReduceStrength는 procedure 전체에 걸쳐 같은 PureCSE 인스턴스를 계속 유지합니다.
함정은 PureCSE에 무효화 프로토콜이 실제로 존재하고, specializeSelect()도 그것을 실제로 지킨다는 데 있습니다. 코드가 올바르게 읽히는 이유가 바로 여기에 있습니다. tombstone은 owner == nullptr이며, 이를 사용하는 두 곳 모두 이 조건을 확인합니다.
for (Value* match : iter->value) {
// Value is invalidated.
if (!match->owner)
continue;
if (!match->owner->isInserted())
continue;
if (dominators.dominates(match->owner, block))
return match;
}
tombstone이 생존 여부를 판단해야 할 바로 그 객체 안에 놓여 있습니다. block에서 연결만 끊긴 채 메모리는 여전히 할당되어 있는 value라면 이 방식으로도 충분합니다. 다만 deleteValue()가 실행되는 순간 전제가 무너집니다. 생존 검사를 구현하는 문장인 if (!match->owner) 자체가, 더 이상 살아 있는 Value를 담고 있지 않은 메모리에서 값을 읽는 동작이기 때문입니다.
specializeSelect()는 m_proc.deleteValue(value) 바로 윗줄에서 value->owner = nullptr을 설정합니다. 예의를 갖춘 동작 바로 뒤에 치명적인 동작이 이어지는 셈입니다.
트리거 조건은 새로 추가된 regression test가 조립해 놓은 형태 그대로입니다. 한 block 안에 다섯 가지 요소가 모입니다.
- 상수 arm을 가진
Select— specialization 후보입니다. - 일반 인자를 predicate로 삼는
Check. Select와 specialization을 유발할 Check 사이에 위치합니다. 타입이 Void이므로 재삽입이 아니라 삭제 대상이 됩니다. - Select를 입력으로 받는
Add. 4번 항목과의 거리를selectSpecializationBound이내로 유지하는 역할입니다. Add를 predicate로 삼는Check— 트리거입니다. bound 안에서 Select에 도달합니다.- 2번과 동일한 condition에 대한 두 번째
Check. 이때PureCSE::process()가 조회하는 키의 버킷에는 이미 해제된 포인터가 그대로 남아 있습니다.
패치는 양쪽 모두에서 이 간극을 메웁니다. PureCSE::remove()가 테이블에 등록 해제 진입점을 제공하고, specializeSelect()는 Void 경로에서 deleteValue() 직전에 이를 호출합니다. 그 결과 곧 사라질 노드를 버킷이 계속 가리키는 상황이 없어집니다.
패치 안의 순서 문제도 그 자체로 핵심입니다. ValueKey는 opcode, 타입, children에서 유도되는데 cloneValue()는 value를 변경합니다. 복제 이후에 키를 계산하면 m_map.find(key)에서 빗나가는 키가 만들어지고, 제거는 아무 소리 없이 아무 일도 하지 않게 됩니다. ValueKey key = value->key();를 cloneValue(value) 위로 끌어올리고 그 이유를 주석으로 명시한 배경입니다.
attacker가 여기서 얻을 수 있는 것은 제한적입니다. 우선 도달하려면 함수를 최상위 tier까지 끌어올리는 웹 콘텐츠가 필요합니다. OMG를 거치는 Wasm도 마찬가지이며, 어느 쪽이든 위 다섯 단계 패턴과 일치하는 IR 형태가 만들어져야 합니다. 성과물은 concurrent 컴파일러 스레드에서 파괴된 Value를 컴파일 시점에 읽는 동작입니다. 구체적으로는 match->owner 로드와 그에 이어지는 match->owner->isInserted()입니다.
해제된 slot에 attacker가 공급한 데이터가 들어가지는 않습니다. 그 자리를 재사용할 만한 후보는 같은 phase가 할당하는 또 다른 Value 서브클래스 정도입니다. 그래서 현실적인 결과는 WebContent process의 통제된 crash 수준에서 멈추며, Apple의 advisory가 보고하는 내용도 여기까지입니다.
이를 넘어서 확장하려면, 해제된 slot이 재사용된 상태에서 이후의 CSE나 branch folding 판단이 stale entry에 의해 유도되어야 합니다. heap 데이터를 제어하는 방향이 아니라, attacker가 공급한 코드를 잘못 컴파일하게 만드는 방향입니다. 어느 쪽이든 process나 sandbox 경계를 넘지는 않습니다. FTL 컴파일은 같은 프로세스 안에서 수행되므로, 여기서 발생한 fault는 콘텐츠가 이미 점유하고 있는 renderer를 종료시킬 뿐입니다.
CSE 테이블이 optimizer가 삭제한 IR 노드의 raw pointer를 그대로 보유했고, 테이블의 생존 검사 — 해당 노드의 owner 필드를 읽는 동작 — 자체가 use-after-free였습니다.
Insight
더 널리 적용할 만한 교훈은 오히려 이 패치 자체의 취약함에 있습니다. PureCSE::remove()는 value->key()를 키로 사용하고, ValueKey는 opcode와 children에서 유도됩니다. 모두 변할 수 있는 상태입니다. 패치에 "Compute before cloneValue mutates the Value"라는 주석이 달린 이유이기도 합니다.
문제는 이런 형태의 제거가 조용히 실패한다는 점입니다. 변경 가능한 객체 상태에서 키를 다시 계산하면 m_map.find(key)가 그냥 빗나가고, remove()는 반환하며, 아무도 이상을 알리지 않습니다. 나중에 누군가 리팩터링하면서 key() 계산을 한 줄 아래로 옮기면 정확히 같은 CVE가 되살아납니다. 이때 신호가 되어줄 것은 여기서 추가된 regression test 하나뿐입니다.
유도된 키로 삽입하는 코드는 이미 충분한 검토를 받습니다. 유도된 키로 제거하는 코드에도 같은 수준의 검토가 필요합니다.
Audit directions
-
파괴 연산을 사이에 두고 유지되는 raw IR back-pointer. Narrow:
Source/JavaScriptCore/b3/아래에서deleteValue(를 검색해 봅니다. 그 지점을 감싸는 phase가 무엇을 들고 있는지 봐야 합니다. 살아 있는PureCSE,HashMap<..., Value*>, 또는 value identity를 키로 쓰는IndexSet/IndexMap이 대상입니다. 그리고 그 컨테이너가 해당 시점에 비워지지도, 갱신되지도 않는다면 문제가 됩니다. 시작점으로는B3ReduceStrength.cpp(specializeSelect이외의 transform),B3EliminateCommonSubexpressions.cpp,B3ReduceLoopStrength.cpp,B3LowerMacros.cpp를 잡으면 됩니다. 단서는 rawValue*를 담은 컨테이너와deleteValue/replaceWithIdentity호출이 같은 함수 범위 안에 나란히 놓여 있는 형태입니다. Wider: rewrite engine이 node identity를 기준으로 memoize하는 곳이라면 어디서든 같은 유형이 반복됩니다. B3 Air의Code단위Tmp/Instside table이 그렇고,BlockInsertionSet변경을 넘어BasicBlock*을 캐싱하는 phase도 마찬가지입니다. 검색 단서는 phase의 멤버로 선언된 컨테이너입니다. 멤버로 선언되면 sweep 전체 구간 동안 살아남기 때문입니다. 이때 원소 타입이 같은 phase가 변경하는 collection을 가리키는 맨 포인터라면 위험합니다. Widest: 여기서 지켜야 할 invariant는 하나입니다. node identity를 키로 쓰는 memo table은 node를 파괴하는 바로 그 연산이 함께 무효화하거나, 스스로 무효화되는 handle을 저장해야 합니다. LLVM이CallbackVH/WeakVH를 제공하는 이유이고, V8 Turbofan이NodeMarker와 zone 범위 GVN 캐시를 쓰는 이유이며, Cranelift의 egraph가 arena index를 쓰는 이유이기도 합니다. 어느 코드베이스에서든 통하는 단서는, 무효화 callback도 generation counter도 갖지 않는 삭제 API입니다. -
유효성이 의심되는 메모리 안에 liveness flag를 저장하는 패턴. Narrow: 먼저
PureCSE::findMatch()와PureCSE::process()를 점검합니다. 두 함수 모두if (!match->owner)와match->owner->isInserted()로 후보를 걸러냅니다. 이어서 같은 형태의 B3/Air iterator를 찾아봅니다. 캐싱된 포인터를 순회하면서, 그 후보를 쓸 수 있는지 판단하기 위해 첫 문장에서 후보를 역참조하는 loop가 대상입니다. 단서는 오래 살아 있는 컨테이너에서 가져온 포인터에if (!p->someField) continue;형태의 조건이 붙는 경우입니다. Wider: WebKit에서WeakPtr/CanMakeWeakPtr대신T*를 저장하고 "t->m_dead면 건너뛴다" 같은 관례를 함께 두는 곳이라면 어디든 같은 유형이 나타납니다. 참조를 0으로 만드는 weak reference 대신 객체 내부의 dead flag에 의존하면서 맨 포인터를 담고 있는 WebCore·JSC 컨테이너들이 여기 해당합니다. 단서는 역참조 바로 위에 붙어 있는 "Value is invalidated" 같은 주석입니다. Widest: liveness 검사는 검사 대상 객체에서 값을 읽어와야만 성립하는 방식이어서는 안 됩니다. 이 원칙은 rawValue*와 대비되는 LLVMWeakVH에도, Rust slotmap과 ECS의 generational index에도, 외부 generation counter를 갖는 모든 handle table에도 그대로 적용됩니다. 어떤 코드베이스를 보든 "validity bit이 allocation을 기준으로 어디에 놓여 있는가"라는 질문을 들고 들어가면 됩니다. -
변경 가능한 derived state를 키로 삼는 hash 제거. Narrow: 새로 추가된
PureCSE::remove()의 호출 지점을 현재의 것과 앞으로 추가될 것까지 모두 확인해야 합니다. value를 변경하기 전에value->key()를 계산하는지가 핵심입니다.specializeSelect()에 patch가 직접 남긴 주석이 이 위험을 표시하고 있습니다. 단서는x의 opcode·type·children을 다시 쓰는 호출 뒤쪽에서map.find(x->derivedKey())나map.remove(x->derivedKey())가 등장하는 경우입니다. Wider: 코드가 직접 변경하기도 하는 derived state를 키로 쓰는 WebKit 캐시라면 어디서든 같은 형태가 반복됩니다.ValueKey를 키로 쓰는 B3 map, structure/opcode tuple을 키로 쓰는 DFG map, element attribute를 키로 쓰는 WebCore의 style·computed-value 캐시가 그 예입니다. 검색 결과에서 눈여겨볼 단서는, 객체가 아직 hash 컨테이너 안에 들어 있는 상태에서 hash key를 구성하는 필드가 변경되는 지점입니다. Widest: 컨테이너가 derived state를 키로 쓴다면, 그 state의 변경은 반드시 제거 후 재삽입으로 감싸야 합니다. Java에서hashCode가 변하는 객체 때문에 set이 망가지는 문제, Rust에서 interior mutability를 통해HashMap키가 변경되는 문제가 모두 같은 결함 유형에 속합니다. 다만 이 유형은 검색으로 잘 잡히지 않습니다. crash가 아니라 조용한 miss로 나타나기 때문입니다. 현실적인 점검 방법은 제거 시도에서 항목을 찾지 못했을 때 assert를 걸거나 로그를 남기는 것입니다. -
phase가 이동된 value에 대한 상태를 들고 있는 채로 순회 도중 수행되는 IR surgery. Narrow:
specializeSelect()의 block 분할과m_insertionSet사용에서 출발합니다. 그런 transform마다, value를 색인하는 phase 수준 구조들이 이후에도 일관성을 유지하는지 확인해야 합니다.m_pureCSE, insertion set, worklist, index를 키로 쓰는 map이 모두 여기 포함됩니다. 단서는value->owner를 다시 지정하거나, value를 새로 생성된 block으로 옮기거나, value를 삭제하면서도 위쪽에 선언된 phase 멤버는 갱신하지 않는 transform입니다. Wider: 이 유형은 "해당 IR에 대한 순회가 진행 중인 상태에서 수행되는 IR surgery"로 정리됩니다. walk 도중Inst를 삽입하거나 제거하는 Air phase도 여기 들어갑니다. 다른 곳으로 가져갈 invariant는 이렇습니다. 순회 안에서 수행되는 rewrite는, 순회가 계속되기 전에 그 순회가 의존하는 모든 index에 자신의 구조 변경을 반영해야 합니다.