
Analyse en une journée + PoC de désinfection de CVE-2026-43805 (course RPC DriverKit IODMACommand IOKit, CWE-362)
IODMACommandUne analyse one-day de la condition de course dans Apple IOKit corrigée dans iOS/iPadOS 26.6, macOS Tahoe 26.6, Sequoia 15.7.8, Sonoma 14.8.8 et watchOS 26.6. Aucun proof-of-concept public n'existait au moment de la rédaction, le travail ici consiste donc à : localiser le code corrigé en lisant les sources primaires de XNU, expliquer la cause racine jusqu'à l'objet libéré, et reproduire le défaut sous forme d'un modèle fidèle vérifié par sanitizer.
L'avis d'Apple est laconique :
Une condition de course a été corrigée par une meilleure gestion de l'état. Une app peut être en mesure de provoquer une terminaison inattendue du système ou d'écrire dans la mémoire du noyau.
Classifié CWE-362 (exécution concurrente avec synchronisation incorrecte), CVSS 9.8. Avis : 128066 · 128067 · 128068 · 128071 · 128072.
IODMACommand est exposé aux extensions DriverKit (dexts) via RPC. Les implémentations
côté noyau de ces points d'entrée RPC sont les méthodes _Impl dans
iokit/Kernel/IOUserServer.cpp.
Un dext pilote un tampon DMA à travers trois d'entre elles :
| Méthode RPC | ligne (xnu-12377.121.6) | détient fInternalState->fDextLock ? |
|---|---|---|
PrepareForDMA_Impl | 1009 | oui — IOLockLock @1033, unlock @1078 |
CompleteDMA_Impl | 1084 | oui — IOLockLock @1096, unlock @1118 |
PerformOperation_Impl | 1154 | non — lit fActive et fMemory sans verrou |
Le verrou existe. IODMACommand::init l'alloue
(iokit/Kernel/IODMACommand.cpp
ligne 342). Deux des trois méthodes sœurs l'acquièrent. La troisième ne le fait pas — et c'est
elle qui déréférence le descripteur de mémoire partagé. Cette asymétrie constitue la
vulnérabilité.
xnu-12377.121.6 (macOS 26.x, le dernier tag pour lequel Apple a publié les sources) montre encore
le PerformOperation_Impl sans verrou, c'est donc l'arbre vulnérable ; la publication des sources
26.6 qui contient le correctif n'est pas encore publique. La source pertinente est reproduite
dans analysis/IOUserServer_excerpt.cpp.
PerformOperation_Impl effectue une vérification temporelle sur fActive, puis utilise 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 exécute le démontage sous le verrou :
IOLockLock(fInternalState->fDextLock);
...
ret = clearMemoryDescriptor(true); // see below
...
IOLockUnlock(fInternalState->fDextLock);
et clearMemoryDescriptor(true) (IODMACommand.cpp ligne 455) est ce qui libère réellement l'objet que PerformOperation est sur le point de toucher :
if (fMemory) {
while (fActive) {
complete(); // drains fActive to 0
}
...
fMemory.reset(); // releases the IOMemoryDescriptor
}
L'entrelacement dangereux est donc le suivant :
PerformOperation_Impl passe if (!fActive) alors que la commande est encore active.CompleteDMA_Impl prend le verrou, vide fActive et exécute fMemory.reset(),
libérant le descripteur de mémoire et permettant à son support câblé d'être libéré/réutilisé.PerformOperation_Impl, toujours en cours d'exécution, déréférence le fMemory désormais obsolète dans
getLength() puis writeBytes().Comme PerformOperation n'a jamais pris le verrou, l'étape 2 peut se glisser dans l'intervalle
entre les étapes 1 et 3. writeBytes copie les octets fournis par l'appelant dans un mapping DMA
dont le support a été libéré — une fois cette page réutilisée pour une autre allocation du noyau,
c'est l'« écriture en mémoire du noyau » contre laquelle l'avis met en garde. Si on l'atteint plutôt sur
le chemin de lecture, on obtient une lecture use-after-free / un arrêt inattendu.
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
Déclencher le chemin réel nécessite un dext DriverKit signé avec l' entitlement DriverKit d'Apple sur une version vulnérable, ce qui n'est pas accessible depuis un hôte Windows. À la place, poc/race_model.cpp réimplémente la logique exacte d'état partagé des trois méthodes — même discipline de verrouillage, même ordre vérification-puis-libération-puis-utilisation — afin que le défaut puisse être observé directement :
PrepareForDMA et CompleteDMA prennent le mutex ; PerformOperation ne le prend pas (sauf s'il est compilé avec -DFIXED, qui ajoute le verrou manquant).CompleteDMA libère le descripteur, reproduisant fMemory.reset().PerformOperation sur un thread et CompleteDMA sur un autre, plaçant le démontage à l'intérieur de la fenêtre de course avec un signal atomique (uniquement pour le timing — ce n'est jamais un verrou sur l'état partagé).cd poc
bash run.sh
Build vulnérable :
operator delete à l'intérieur de CompleteDMA
en concurrence avec la lecture getLength() à l'intérieur de PerformOperation, le mutex
n'étant détenu que par le deleter. Journal complet : evidence/tsan_vulnerable.txt.CompleteDMA est lu dans PerformOperation::getLength, alloué dans
PrepareForDMA. Journal complet : 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
Le build corrigé (-DFIXED, le verrou ajouté à PerformOperation) : les deux sanitizers sont
propres et chaque opération se sérialise correctement face au teardown. Journaux :
evidence/tsan_fixed.txt,
evidence/asan_fixed.txt.
Cela prouve que la course est réelle dans la logique et que le verrou est à la fois nécessaire et suffisant. Il ne s'agit pas d'un kernel panic réel — voir le périmètre ci-dessous.