
Analyse des causes profondes de la CVE-2026-54107 : une use-after-free dans win32kfull.sys de Windows avec débogage de conditions de course, analyse statique, informations sur le triage MSRC et recherche pratique sur l'exploitation du noyau.
ValidateHwnd n'est pas une barrière : analyse racine de CVE-2026-54107Un use-after-free dans la gestion du cycle de vie des fenêtres de
win32kfull.sys— comment je l'ai trouvé, comment je me suis convaincu qu'il était réel, et à quoi ressemblait vraiment le processus MSRC vu du côté du chercheur.
Il était presque 2 h du matin quand la VM cible a cessé de répondre au heartbeat du débogueur et s'est arrêtée sur un break à exactement l'instruction dont j'avais passé des semaines à soutenir qu'elle était atteignable. Pas une assertion, pas un arrêt sur pool corrompu — une simple violation d'accès sur le chemin de dispatch des messages, déréférençant un objet qu'un autre thread avait déjà démonté.
Ce break est devenu CVE-2026-54107, cas MSRC 11xxxxx, corrigé dans la mise à jour de sécurité de juillet 2026 sur 27 produits Windows.
Ce billet est la moitié non soumise à embargo de l'histoire : cause racine, pourquoi cette classe de bug est ce qu'elle est, et le raisonnement qui m'y a mené. Les détails d'exploitation restent hors champ.
Win32k est la moitié en mode noyau du sous-système graphique de Windows. C'est ancien, c'est énorme, et — point crucial — c'est atteignable depuis des contextes censés être non fiables. Cette dernière propriété explique pourquoi cela reste une cible de recherche permanente malgré vingt ans de durcissement, de filtrage et de restrictions sur les appels système.
Dans win32k, l'objet tagWND (PWND) est particulièrement intéressant parce que sa durée de vie est gérée par plusieurs mécanismes à la fois. Une fenêtre est :
ValidateHwnd,Tout objet avec plusieurs chemins de référence indépendants et un chemin de destruction partagé mérite d'être lu lentement. Ce n'est pas une affirmation de vulnérabilité — c'est une heuristique pour savoir où passer du temps.
Ce qui m'a fait m'attarder sur ce composant, c'est la surface d'imports. win32kfull.sys tire de ntoskrnl trois primitives distinctes de référencement d'objets :```c
NTSTATUS ObReferenceObjectByPointer(
void *Object,
uint32_t DesiredAccess,
POBJECT_TYPE ObjectType,
char AccessMode);
NTSTATUS ObReferenceObjectByHandle( HANDLE Handle, uint32_t DesiredAccess, POBJECT_TYPE ObjectType, char AccessMode, void **Object, OBJECT_HANDLE_INFORMATION *HandleInformation);
NTSTATUS ObReferenceObjectByName(/* ... */);
Trois façons d'entrer, une seule sortie via `ObfDereferenceObject`.
Cela ne signifie pas que le code est incorrect. Cela signifie que l'**invariant est distribué** — aucune fonction ne possède à elle seule *"cet objet est vivant en ce moment"*, donc l'exactitude dépend de l'accord de chaque appelant sur la référence qu'il détient et sa durée de validité. Les invariants distribués sont là où vivent les conditions de course, car une course n'est jamais un bug de logique visible dans une seule fonction. C'est un bug dans une hypothèse partagée entre deux fonctions.
Aussi, la question que j'ai commencé à poser à chaque fonction qui touchait un `PWND` n'était pas *"ce code est-il correct ?"* mais :
> **Si ce corps de fonction exact s'exécute sur deux threads à quelques instructions d'écart, lequel des deux est dans l'erreur ?**
## 3. Cause racine
Le défaut est un **écart entre le temps de vérification et le temps d'utilisation entre la libération de la référence et le démontage de l'objet** dans le chemin de destruction de la fenêtre, sans synchronisation adéquate face à un consommateur concurrent qui valide les handles.
Réduit à l'essentiel :```c
/* Thread A — teardown */
NtUserDestroyWindow(HWND hwnd)
{
PWND pWnd = ValidateHwnd(hwnd);
if (pWnd) {
ObfDereferenceObject(pWnd); /* reference released */
/* <-- race window: object may become reclaimable here */
FreeWindowObject(pWnd); /* teardown proceeds on a pointer
no longer guaranteed live */
}
}
I don't see any content to translate. The input appears to be empty after the INPUT: marker. Please provide the chunk text you'd like me to translate.```c
/* Thread B — consumer, concurrent /
NtUserMessageCall(HWND hwnd, UINT msg, ...)
{
PWND pWnd = ValidateHwnd(hwnd); / may resolve a handle whose object
is mid-teardown */
if (pWnd->fnid == FNID_BUTTON) /* use-after-free */
...
}
Deux conditions doivent être réunies pour que cela ait de l'importance, et les deux l'étaient :
**(a) La fenêtre est réelle.** `ValidateHwnd` est le garde-fou censé rendre l'accès basé sur les handles sûr. Si la validation peut réussir sur un objet dont la destruction a déjà commencé, le garde-fou n'est pas un garde-fou — c'est une suggestion.
**(b) La mémoire libérée est influençable par l'attaquant.** Les champs lus immédiatement après la validation incluent `fnid`, qui pilote la répartition des messages. Une décision de répartition prise à partir de mémoire récupérée fait la différence entre *« crash peu fiable »* et *« violation de frontière de sécurité »*. Cette distinction est la raison même pour laquelle il s'agit d'une CWE-362 avec impact EoP et non d'un bug de stabilité.
> La **corruption** observée est une use-after-free ; la **cause** est une CWE-362, exécution concurrente utilisant une ressource partagée avec une synchronisation inappropriée. Ce sont deux affirmations différentes et MSRC s'intéresse à la seconde. **Signalez la cause, pas seulement le symptôme.**
### Pourquoi les courses win32k sont structurellement plus dures qu'elles n'en ont l'air
Si vous avez traqué des bugs de course dans d'autres sous-systèmes, win32k vous frustrera, car l'architecture vous combat de trois manières spécifiques.
**Les fenêtres ont une affinité de thread.** Une fenêtre appartient au thread qui l'a créée. Une grande partie du sous-système repose sur l'hypothèse que le thread propriétaire est celui qui touche l'objet, ce qui signifie que l'approche naïve « lancer deux threads appelant la même API » ne se chevauche souvent pas — vous ne faites pas une course, vous faites une file d'attente. Pour que deux chemins entrent réellement en collision sur le même objet, il faut comprendre quelles opérations s'exécutent réellement sur le thread appelant par rapport à celles qui sont marshallées vers le thread propriétaire.
**La répartition des messages vous sérialise partiellement.** Les envois (*sends*) et les publications (*posts*) se comportent différemment, et la répartition inter-threads se comporte encore différemment de la répartition intra-thread. Une partie de ce qui ressemble à une opportunité de concurrence est silencieusement convertie en opération ordonnée avant même d'atteindre le code qui vous intéresse. Si vous ne savez pas dans quelle catégorie tombe votre déclencheur, vous conclurez qu'une vraie course est inaccessible — un faux négatif qui ressemble exactement à « pas de bug ici ».
**La section critique se cache chez l'appelant.** Une grande partie du sous-système s'exécute sous un verrou grossier acquis bien au-dessus de la fonction que vous examinez. C'est la plus grande source de temps perdu dans l'audit de win32k : une fonction sans synchronisation visible qui est néanmoins parfaitement sûre parce que tous les chemins qui y mènent sont déjà sérialisés. **La couverture de verrou est une propriété interprocédurale.** Il faut remonter le graphe d'appel, pas seulement lire la fonction.
Ce troisième point explique pourquoi *« pas de verrou dans cette fonction »* ne vaut presque rien comme signal, et pourquoi la majeure partie du travail dans cette chasse a été consacrée à l'accessibilité plutôt qu'au défaut lui-même.
## 4. Pourquoi la note d'impact est ce qu'elle est
MSRC a évalué cela comme **Important, Élévation de privilèges, CVSS 8.8, vecteur d'attaque local, authentifié.** Deux propriétés motivent cela :
**Accessibilité depuis une intégrité faible.** La surface d'appels de messages win32k est accessible depuis des contextes bien en dessous de SYSTEM. C'est ce qui la rend pertinente pour les chaînes d'évasion de sandbox — un processus de rendu qui a déjà obtenu l'exécution de code dans son sandbox peut toujours atteindre cette surface. La gravité d'un bug noyau est surtout une fonction de *qui peut le toucher*, pas de la sophistication de la corruption.
**Corruption influençant la répartition.** Corrompre un champ sur lequel s'exécute un `switch` est qualitativement pire que corrompre un champ qui n'est que journalisé. Le premier transforme un bug mémoire en question de flux de contrôle.
Je veux être précis sur un point ici, car j'ai vu des articles sur les premiers CVE exagérer cela : **j'ai démontré la course et l'use-after-free. Je n'ai pas livré d'exploit armé au niveau SYSTEM.** Le cadrage d'évasion de sandbox décrit la *classe de chaîne* à laquelle appartient ce type de bug et pourquoi la surface est précieuse — c'est un argument sur l'accessibilité, pas une affirmation que j'en ai construit une. Surexposer l'impact est le moyen le plus rapide de perdre sa crédibilité auprès d'un éditeur, et l'évaluation de MSRC est le chiffre qui compte, pas le mien.
## 5. La falsification est venue en premier — la plupart des candidats sont morts
La partie dont personne ne parle : ce n'était pas le premier candidat. C'était celui qui a survécu.
Ma règle de travail est qu'**un candidat est coupable jusqu'à preuve du contraire.** Chaque motif prometteur reçoit une raison spécifique et écrite pour laquelle il ne *devrait pas* être exploitable, et je vais essayer d'établir cette raison avant d'essayer de le déclencher. Les candidats que j'ai clos avant celui-ci comprenaient :
- des chemins qui semblaient non synchronisés mais qui étaient sérialisés par un verrou acquis une trame plus haut,
- des chemins où l'objet « libéré » était en fait mis en cache plutôt que relâché,
- des chemins réellement concurrents mais inaccessibles depuis un appelant qu'un utilisateur à faibles privilèges pouvait piloter.
Chacun d'eux est une découverte que je n'ai *pas* envoyée à MSRC. C'est tout l'intérêt. Le débit d'un chercheur n'est pas le nombre de candidats qu'il génère — c'est la vitesse à laquelle il peut tuer les mauvais pour ne pas encore les tenir à 2 heures du matin.
**Les trois questions qui ont tué la plupart des candidats :**
1. **Quelqu'un au-dessus de moi tient-il un verrou ?** Interprocédural, pas local. L'absence de verrou dans une fonction ne signifie rien.
2. **Un appelant non privilégié peut-il réellement atteindre les deux côtés ?** Une course entre deux chemins exigeant des niveaux de privilèges différents n'est pas une course, c'est une expérience de pensée.
3. **La mémoire libérée est-elle récupérable dans une fenêtre que je peux influencer ?** Si la destruction se termine atomiquement pour des raisons pratiques, il n'y a pas de bug digne d'être signalé.
## 6. Vérification : le statique donne des hypothèses, le débogueur donne la vérité
L'analyse statique de `win32kfull.sys` m'a donné l'hypothèse. Elle n'aurait jamais pu me donner le bug. **Les conditions de course ne sont pas visibles dans un désassembleur**, car le défaut n'est pas dans les instructions — il est dans l'entrelacement.
### Lab
| Rôle | Configuration |
| --- | --- |
| Hôte / débogueur | Windows 11, WinDbg |
| Cible | Windows Server 2022, Build 20348.2159 |
| Analyse | Kali Linux + VM Windows 11 |
| Transport de débogage | COM série VMware, débogage noyau hôte → cible |
| Analyse statique | Ghidra via GhidraMCP |
| Aide au triage | Passe assistée par IA sur la sortie désassemblée |
Trois couches d'instrumentation ont fait le travail réel.
### Pool spécial et Driver Verifier
L'étape la plus rentable dans toute investigation de UAF noyau. Par défaut, la mémoire du pool libérée est réutilisée presque immédiatement par la prochaine allocation de taille similaire — ce qui signifie qu'une use-after-free ne provoque généralement *pas* de fault. Elle lit les données valides de quelqu'un d'autre, continue de s'exécuter, et explose ailleurs sans rapport quelques minutes plus tard. Vous passez alors trois jours à auditer une fonction innocente.
Le pool spécial change cela. Chaque allocation reçoit sa propre page avec une page de garde adjacente, et les pages libérées sont marquées sans accès plutôt que recyclées. Le résultat est que la référence fautive provoque une fault **à l'instruction qui l'exécute**, et non en aval :```
!verifier 0x1 win32kfull.sys ; special pool on the target driver
!verifier 0x8 win32kfull.sys ; pool tracking
Combiné au filtrage par balise de pool, c'est ce qui transforme "bugcheck intermittent sous charge" en un défaut reproductible et imputable.
Si vous ne retenez qu'une chose de cet article : activez le pool spécial avant de commencer, pas après être bloqué.
Une fois que vous avez un défaut, la question est de savoir si vous faites face à une corruption ou à un bug de durée de vie. Ces deux cas nécessitent des rapports différents. Les métadonnées du pool y répondent :``` kd> !pool
kd> !pool 2Un bloc *alloué* avec une étiquette plausible et un contenu aléatoire pointe vers une **corruption**. Un bloc *libéré*, ou résidant dans une page sans accès d'un pool spécial, pointe vers un **bug de durée de vie** — quelque chose qui a conservé un pointeur après la mort de l'objet. C'est la distinction entre *« l'attaquant a écrit ici »* et *« cet objet n'aurait pas dû être accessible »*, et c'est la différence entre un rapport de corruption du tas et un rapport CWE-362.
Recoupez le type d'objet avant de vous engager dans l'une ou l'autre voie. Un `PWND` a une forme reconnaissable ; si la mémoire sur laquelle vous avez déclenché la faute porte encore les restes d'un tel objet, vous êtes presque certainement face à un problème de fenêtre de durée de vie plutôt qu'à une réécriture aléatoire.
### Débogage noyau en direct sur l'entrelacement
Même avec un pool spécial, une course est un problème d'ordonnancement, et **le débogueur modifie l'ordonnancement.** C'est la frustration centrale du travail sur les courses : l'instrument perturbe ce qu'il mesure. Un point d'arrêt sur le chemin de démontage sérialise exactement les deux threads que vous essayez de faire se chevaucher, et le bug disparaît poliment.
La solution consiste à cesser d'essayer d'attraper la course avec un point d'arrêt et plutôt à :
- **élargir artificiellement la fenêtre** — tout ce qui allonge l'intervalle entre la libération de la référence et le démontage rend la collision atteignable à des cadences d'ordonnancement normales,
- **augmenter les tentatives de collision plutôt que la précision** — exécuter les deux chemins en continu et laisser la probabilité faire le travail,
- **utiliser des points d'arrêt conditionnels et à usage unique** qui ne s'arment qu'une fois que l'état intéressant existe, au lieu de s'arrêter à chaque entrée,
- **confirmer l'entrelacement après la faute**, à partir de l'état des threads et des piles, plutôt que d'essayer de l'observer en direct.
La faute elle-même, une fois que vous l'avez, est sans gloire — une déréférence d'un `PWND` sur le chemin de distribution des messages alors que l'objet a déjà subi le démontage sur un autre thread, avec `!pool` confirmant que le bloc a été libéré plutôt que réécrit :```
kd> !analyze -v
EXCEPTION_CODE: (NTSTATUS) 0xc0000005 - Access violation
FAULTING_MODULE: win32kfull
(Les offsets, adresses et détails de reproduction sont retenus dans le cadre d'une divulgation coordonnée.)
L'impact ne se prouve pas par un crash. Il se prouve par qui peut provoquer le crash. Chaque exécution du déclencheur a été effectuée depuis un compte utilisateur standard, non administrateur, sur la cible, car un défaut du noyau accessible uniquement depuis un contexte déjà privilégié est un bug de stabilité, pas un bug de sécurité. Vérifier le niveau d'intégrité du processus qui a provoqué la collision est une étape de trente secondes qui décide si vous avez un cas de bounty ou une entrée Windows Feedback Hub.
La discipline clé : je n'ai pas cru un seul crash. Un seul crash sur une course est du bruit. Ce qui l'a rendu signalable, c'est la reproductibilité avec un timing contrôlé — pouvoir dire « ces deux chemins, cet ordonnancement, cette fenêtre » et obtenir la même faute en retour. C'est la différence entre un rapport sur lequel MSRC peut agir et un rapport qu'ils ferment comme non reproductible.
J'ai utilisé un tri assisté par IA pour parcourir plus vite la sortie décompilée, et je dirai clairement à quoi cela servait et à quoi cela ne servait pas.
Bon pour : couvrir la surface d'attaque. Lire un grand volume de HLIL et signaler « ces fonctions touchent un objet partagé sans synchronisation visible », c'est du pattern-matching, et le pattern-matching à grande échelle est exactement ce que ces outils font bien. Cela a réduit des semaines de lecture rapide à quelques jours.
Inutile pour : le jugement. Il racontera avec confiance une chaîne d'attaque qui n'existe pas, affirmera une atteignabilité qu'il n'a pas établie, et produira un rapport magnifiquement structuré pour un bug qui n'existe pas. Chaque conclusion a dû survivre à une vérification manuelle dans WinDbg avant de s'approcher d'un rapport.
Le mode de défaillance à redouter n'est pas que l'outil se trompe. C'est l'outil fluide tout en se trompant, à 2 h du matin, quand vous voulez qu'il ait raison.
Un résultat fabriqué envoyé à MSRC coûte du temps réel à leurs ingénieurs et vous coûte une réputation que vous ne pouvez pas reconstruire rapidement.
Cinq semaines de silence entre la soumission et la confirmation. C'est la partie qui vous met à l'épreuve. Vous avez écrit une affirmation sur le noyau de quelqu'un d'autre et vous n'avez encore aucune idée de savoir si votre raisonnement tient, si c'est un doublon, ou même si cela se reproduit sur leur build. L'e-mail de confirmation est le moment où cela cesse d'être une théorie que vous avez et devient une vulnérabilité qui existe.
Une petite chose qui m'a fait rire de moi : le 14 juillet, à mon heure, j'ai relancé le dossier pour demander pourquoi la CVE n'avait pas été publiée. La réponse, poliment : c'est le 13 juillet à Seattle. Le calendrier de publication de Microsoft fonctionne à l'heure du Pacifique. Maintenant, je le sais.
Lisez pour les invariants, pas pour les bugs. « Où ce code suppose-t-il quelque chose qu'il n'impose pas ? » trouve plus que « où est le débordement ? » — surtout dans les composants matures et fortement audités où les classes faciles ont disparu.
Une course est une affirmation interprocédurale. Vous ne pouvez ni l'établir ni la réfuter à partir d'une seule fonction. Si votre analyse s'arrête à la limite de la fonction, vous générerez des candidats que vous ne pourrez jamais écarter.
Votre environnement de débogage, c'est le travail. J'ai perdu plus d'heures à cause d'une liaison série COM instable qu'à cause de la chasse elle-même, et un pipeline cassé produit des faux négatifs qui ressemblent exactement à « il n'y a rien ici ». J'ai failli abandonner cette cible à cause d'un réglage de port COM.
Signalez la cause, pas le crash. MSRC reçoit des crashes. Ce qui fait avancer un cas, c'est une histoire cohérente sur quel invariant a été rompu et pourquoi il n'était pas appliqué.
Confirmé ne signifie pas terminé. Entre la confirmation et le correctif publié, il y a le suivi de reproductibilité, le comportement Canary et la revue. Restez impliqué.
Même méthodologie, surfaces différentes — tcpip.sys, afd.sys, clfs.sys. Je préfère être connu pour un ensemble de travaux que pour une seule trouvaille chanceuse, et la seule façon d'y parvenir est de continuer à éliminer des candidats plus vite que je n'en génère.
Si vous êtes là où j'étais il y a un an — venant des bounties web, curieux du travail sur le noyau, incertain de savoir si vous êtes le genre de personne qui peut faire ça — vous le découvrirez en le faisant. Choisissez un pilote. Attachez un débogueur. Lisez lentement. Continuez à demander ce qui se passe si cela s'exécute deux fois.
C'est vraiment là que tout commence.
Écrit par Pravin Choudhary (@pr4v1nx) — chercheur indépendant en sécurité offensive. Divulgué à Microsoft dans le cadre d'une divulgation coordonnée. Les détails d'exploitation, les offsets et le code de reproduction sont volontairement retenus.
| Date | Événement |
|---|
| 7 mai 2026 | Soumis — VULN-186460 |
| 7 mai 2026 | Cas ouvert — Cas MSRC 11xxxxx |
| 11 juin 2026 | Comportement confirmé par Microsoft ; examen du bounty ouvert |
| 27 juin 2026 | Correctif planifié pour la version de juillet ; CVE-2026-54107 assignée (avant publication) |
| 30 juin 2026 | Bounty attribué — 8 000 $US, Windows Insider Preview Bounty Program |
| 14 juillet 2026 | Correctif publié ; CVE publiée |
| Champ | Détail |
|---|
| CVE | CVE-2026-54107 |
| Cas MSRC | 11xxxxx (VULN-186460) |
| Composant | win32kfull.sys — cycle de vie des objets de fenêtre |
| Classe | Condition de course → use-after-free |
| CWE | CWE-362 |
| Impact | Élévation de privilèges |
| Sévérité | Important (MSRC) |
| CVSS v3.1 | 8.8 (Élevé) |
| Vecteur | Local, authentifié |
| Programme | Windows Insider Preview Bounty Program |
| Récompense | 8 000 $US |
| Corrigé | Mise à jour de sécurité de juillet 2026 |