← All reports

[JSC] Fix B3 ReduceStrength Select specialization when a Check appears between the Select and triggering Check

MediumJSC B3UAF

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

1d5c10e | Bugzilla 316347

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와 check 사이의 모든 value를 처리합니다.
for (unsigned i = startIndex + 1; i < predecessor->size(); ++i) {
Value* value = predecessor->at(i);
+ ValueKey key = value->key(); // cloneValue가 Value를 변경하기 전에 계산
value->owner = nullptr;
 
cloneValue(value);
 
if (value->type() != Void)
m_insertionSet.insertValue(m_index, value);
 
- else
+ else {
+ m_pureCSE.remove(key, value);
m_proc.deleteValue(value);
+ }
}

Source/JavaScriptCore/b3/B3PureCSE.cpp

+void PureCSE::remove(const ValueKey& key, Value* value)
+{
+ if (!key)
+ return;
+
+ auto iter = m_map.find(key);
+ if (iter == m_map.end())
+ return;
+
+ Matches& matches = iter->value;
+ matches.removeAll(value);
+}

Source/JavaScriptCore/b3/B3PureCSE.h

void clear();
 
+ void remove(const ValueKey&, Value*);
+
Value* NODELETE findMatch(const ValueKey&, BasicBlock*, Dominators&);

Source/JavaScriptCore/b3/testb3_6.cpp

+void testCheckSelectAndDeadCheckCSE()
+{
+ ...
+ Value* condition = arguments[1];
+ auto* constant = root->appendNew<ConstPtrValue>(proc, Origin(), 42);
+ // (1) 상수 arm을 가진 Select
+ auto* selectValue = root->appendNew<Value>(proc, Select, Origin(), arguments[0],
+ root->appendNew<ConstPtrValue>(proc, Origin(), -42),
+ root->appendNew<ConstPtrValue>(proc, Origin(), 35));
+ // (2) 중간 Check -- Void이므로 specializeSelect가 삭제
+ appendCheck(condition, 1);
+ // (3) Select를 selectSpecializationBound(== 3) 안에 유지
+ auto* addValue = root->appendNew<Value>(proc, Add, Origin(), selectValue, constant);
+ // (4) 트리거가 되는 Check
+ appendCheck(addValue, 2);
+ // (5) 동일한 condition에 대한 이후 Check -> pure CSE 조회가 stale entry에 적중
+ appendCheck(condition, 3);
+ root->appendNewControlValue(proc, Return, Origin(), addValue);
+ auto code = compileProc(proc);
+ CHECK_EQ(invoke<intptr_t>(*code, 1, 0), 0);
+}

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가 이후에 다시 사용된다는 점입니다.

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에 다시 생성됩니다.

이번 버그는 컴파일러 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 안에 다섯 가지 요소가 모입니다.

  1. 상수 arm을 가진 Select — specialization 후보입니다.
  2. 일반 인자를 predicate로 삼는 Check. Select와 specialization을 유발할 Check 사이에 위치합니다. 타입이 Void이므로 재삽입이 아니라 삭제 대상이 됩니다.
  3. Select를 입력으로 받는 Add. 4번 항목과의 거리를 selectSpecializationBound 이내로 유지하는 역할입니다.
  4. Add를 predicate로 삼는 Check — 트리거입니다. bound 안에서 Select에 도달합니다.
  5. 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였습니다.

더 널리 적용할 만한 교훈은 오히려 이 패치 자체의 취약함에 있습니다. PureCSE::remove()는 value->key()를 키로 사용하고, ValueKey는 opcode와 children에서 유도됩니다. 모두 변할 수 있는 상태입니다. 패치에 "Compute before cloneValue mutates the Value"라는 주석이 달린 이유이기도 합니다.

문제는 이런 형태의 제거가 조용히 실패한다는 점입니다. 변경 가능한 객체 상태에서 키를 다시 계산하면 m_map.find(key)가 그냥 빗나가고, remove()는 반환하며, 아무도 이상을 알리지 않습니다. 나중에 누군가 리팩터링하면서 key() 계산을 한 줄 아래로 옮기면 정확히 같은 CVE가 되살아납니다. 이때 신호가 되어줄 것은 여기서 추가된 regression test 하나뿐입니다.

유도된 키로 삽입하는 코드는 이미 충분한 검토를 받습니다. 유도된 키로 제거하는 코드에도 같은 수준의 검토가 필요합니다.