
Однодневный анализ + PoC-санитайзер для CVE-2026-43805 (гонка RPC в IOKit IODMACommand DriverKit, CWE-362)
IODMACommand из IOKitОднодневный анализ состояния гонки в Apple IOKit, исправленной в iOS/iPadOS 26.6, macOS Tahoe 26.6, Sequoia 15.7.8, Sonoma 14.8.8 и watchOS 26.6. На момент написания публичного proof-of-concept не существовало, поэтому работа здесь заключается в следующем: найти исправленный код, прочитав первичный исходный код XNU, объяснить первопричину вплоть до освобождённого объекта и воспроизвести дефект в виде достоверной модели, проверенной санитайзерами.
Уведомление Apple лаконично:
Состояние гонки было устранено за счёт улучшенной обработки состояния. Приложение может вызвать неожиданное завершение работы системы или запись в память ядра.
Классифицировано как CWE-362 (конкурентное выполнение с некорректной синхронизацией), CVSS 9.8. Уведомления: 128066 · 128067 · 128068 · 128071 · 128072.
IODMACommand доступен расширениям DriverKit (dext) через RPC. Реализации этих
точек входа RPC на стороне ядра — это методы _Impl в
iokit/Kernel/IOUserServer.cpp.
Dext управляет DMA-буфером через три из них:
| Метод RPC | строка (xnu-12377.121.6) | Удерживает fInternalState->fDextLock? |
|---|---|---|
PrepareForDMA_Impl | 1009 | да — IOLockLock @1033, разблокировка @1078 |
CompleteDMA_Impl | 1084 | да — IOLockLock @1096, разблокировка @1118 |
PerformOperation_Impl | 1154 | нет — читает fActive и fMemory без блокировки |
Блокировка существует. IODMACommand::init выделяет её
(iokit/Kernel/IODMACommand.cpp,
строка 342). Два из трёх «собратьев» её захватывают. Третий — нет, и именно третий
разыменовывает общий дескриптор памяти. Эта асимметрия и есть уязвимость.
xnu-12377.121.6 (macOS 26.x, последний тег, для которого Apple опубликовала исходный код)
по-прежнему содержит незаблокированный PerformOperation_Impl, поэтому это уязвимое
дерево; публикация исходного кода 26.6, содержащая исправление, пока не общедоступна.
Соответствующий исходный код зеркалирован в
analysis/IOUserServer_excerpt.cpp.
PerformOperation_Impl выполняет проверку fActive по времени, а затем использует fMemory:
if (!fActive) { // time-of-check (no lock held)
return kIOReturnNotReady;
}
...
if (length > fMemory->getLength()) { // time-of-use (fMemory may be gone)
return kIOReturnBadArgument;
}
...
copiedDMA = writeBytes(dmaOffset, buffer.data(), length); // write into the mapping
CompleteDMA_Impl выполняет демонтаж под блокировкой:
IOLockLock(fInternalState->fDextLock);
...
ret = clearMemoryDescriptor(true); // see below
...
IOLockUnlock(fInternalState->fDextLock);
и clearMemoryDescriptor(true) (IODMACommand.cpp, строка 455) — это то, что фактически освобождает объект, к которому PerformOperation собирается обратиться:
if (fMemory) {
while (fActive) {
complete(); // drains fActive to 0
}
...
fMemory.reset(); // releases the IOMemoryDescriptor
}
Итак, опасное чередование выглядит так:
PerformOperation_Impl передаёт if (!fActive), пока команда всё ещё активна.CompleteDMA_Impl захватывает блокировку, опустошает fActive и выполняет fMemory.reset(),
освобождая дескриптор памяти и позволяя его закреплённому backing быть освобождённым/переиспользованным.PerformOperation_Impl, всё ещё выполняющийся, разыменовывает теперь уже устаревший fMemory в
getLength(), а затем в writeBytes().Поскольку PerformOperation так и не захватил блокировку, шаг 2 может беспрепятственно попасть в промежуток
между шагами 1 и 3. writeBytes копирует предоставленные вызывающим кодом байты в DMA-отображение,
чей backing уже освобождён — как только эта страница будет переиспользована для другого выделения ядра,
это и есть «запись в память ядра», о которой предупреждает advisory. Если же попасть на путь чтения, получите use-after-free при чтении / неожиданное завершение.
sequenceDiagram
participant W as dext thread A — PerformOperation_Impl (no lock)
participant O as dext thread B — CompleteDMA_Impl (holds fDextLock)
W->>W: if (!fActive) → active, continue
Note over W: passed the check, about to use fMemory
O->>O: IOLockLock(fDextLock)
O->>O: clearMemoryDescriptor(true): fActive=0, fMemory.reset() ← FREE
O->>O: IOLockUnlock(fDextLock)
W->>W: fMemory->getLength() / writeBytes() ← USE-AFTER-FREE
Для запуска реального пути требуется подписанный DriverKit dext с правом
DriverKit от Apple на уязвимой сборке, что недостижимо с хоста Windows.
Вместо этого poc/race_model.cpp повторно реализует точную
логику разделяемого состояния трёх методов — та же дисциплина блокировок, тот же
порядок «проверка-затем-освобождение-затем-использование» — так что дефект можно наблюдать напрямую:
PrepareForDMA и CompleteDMA захватывают мьютекс; PerformOperation — нет
(если только не собрано с -DFIXED, что добавляет одну недостающую блокировку).CompleteDMA освобождает дескриптор, повторяя fMemory.reset().PerformOperation в одном потоке, а CompleteDMA в
другом, помещая teardown внутрь окна гонки с помощью атомарного сигнала
(только для синхронизации — это никогда не блокировка разделяемого состояния).cd poc
bash run.sh
Уязвимая сборка:
operator delete внутри CompleteDMA
конкурирует с чтением getLength() внутри PerformOperation, причём мьютекс
удерживается только удаляющим потоком. Полный лог: evidence/tsan_vulnerable.txt.CompleteDMA, читается в PerformOperation::getLength, а выделен в
PrepareForDMA. Полный лог: evidence/asan_vulnerable.txt.==ERROR: AddressSanitizer: heap-use-after-free ... in MemoryDescriptor::getLength()
freed by thread T0 here: ... IODMACommand::CompleteDMA() race_model.cpp:70
allocated by thread T0: ... IODMACommand::PrepareForDMA() race_model.cpp:61
Исправленная сборка (-DFIXED, блокировка добавлена в PerformOperation): оба санитайзера
чисты, и каждая операция корректно сериализуется относительно teardown. Логи:
evidence/tsan_fixed.txt,
evidence/asan_fixed.txt.
Это доказывает, что гонка реальна в логике и что блокировка одновременно необходима и достаточна. Это не живой kernel panic — см. область применимости ниже.