
CVE-2026-50416: contournement du KASLR sous Windows 11
Sur la build Windows 11 Insider 10.0.28020.2149, le mappage en mode utilisateur du tas de bureau de win32k expose un pointeur brut vers le pool de session du noyau à l'offset 0x100. Tout processus disposant d'un bureau peut le lire. Cela inclut les processus à intégrité Low, les AppContainers, les LPAC sans aucune capacité, et un AppContainer à intégrité Low qui ressemble exactement à un renderer de navigateur. À partir de ce seul QWORD divulgué, on peut retrouver la base du tas de bureau noyau, puis, avec l'aide de gSharedInfo, calculer l'adresse virtuelle noyau exacte de chaque fenêtre, menu et objet de classe présent sur le bureau.
C'est un contournement de KASLR, une divulgation d'informations. Ce n'est pas, en soi, une exécution de code. Je tiens à le dire d'emblée, car sur-vendre un bug est le moyen le plus rapide de rendre un write-up illisible. Ce que c'est, c'est une fuite très propre provenant d'endroits qu'elle ne devrait vraiment pas atteindre.
CVE-2026-50416. Ce post documente le bug, la preuve de concept, et ce qui m'a vraiment fait sursauter : les frontières de sandbox à travers lesquelles le pointeur noyau reste exposé.
Chaque processus qui rejoint un bureau voit le tas de bureau mappé en lecture seule dans son espace d'adressage. Le noyau écrit les objets de fenêtres et de menus dans ce tas, et le mode utilisateur les relit, ce qui est normal et nécessaire. Ce qui n'est pas nécessaire, c'est que l'offset 0x100 de ce mappage contienne un pointeur noyau valide vers le pool de session. Personne ne l'assainit avant qu'il ne devienne visible en mode utilisateur. Windows sait déjà assainir les pointeurs noyau dans ce tas : il utilise une sentinelle 0x6000000000 pour d'autres champs. Celui-ci a simplement été oublié.
win32k est le composant du noyau qui possède les fenêtres, les menus, les curseurs, les hooks, tout le monde des objets graphiques. Une grande partie de l'état de win32k vit par bureau dans une structure appelée le tas de bureau. Pour rendre les opérations de fenêtres rapides, le noyau mappe un morceau de ce tas dans chaque processus qui rejoint le bureau, en tant que section partagée en lecture seule. Votre processus lit le texte ou le style d'une fenêtre directement dans ce mappage, sans syscall. Ce mappage est l'illusion partagée qui rend l'interface graphique instantanée.
Le trouver depuis le mode utilisateur est trivial et documenté. Le TEB contient une structure ClientInfo. L'adresse en mode utilisateur du tas de bureau se trouve dans ClientInfo[5]. En code :
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID* clientInfo = (PVOID*)((BYTE*)teb + 0x800);
BYTE* desktopHeap = (BYTE*)clientInfo[5];
C'est là toute l'affaire. Pas encore de vulnérabilité. Cette partie est voulue.
ULONG64 leaked = *(ULONG64*)(desktopHeap + 0x100);
Sur la build testée, ce QWORD contient quelque chose comme 0xFFFFA804DDE00040. C'est une adresse noyau canonique. Elle est alignée sur huit octets. Elle pointe dans le pool de session, à 0x40 octets du côté noyau de ce même tas de bureau.
J'ai effectué les vérifications de cohérence habituelles avant d'y croire.
C'est donc une véritable adresse noyau, persistante et valable pour toute la session de démarrage, ni valeur parasite ni valeur obsolète. Les propriétés quatre et cinq, combinées, sont l'empreinte d'une adresse randomisée par KASLR qui est constante au sein d'un même démarrage. C'est exactement ce que KASLR est censé cacher.
Pour les curieux, la valeur dans ma véritable session de test était 0xFFFFC600DCC00040. Même forme, même comportement. La vôtre différera, à cause de KASLR. C'est en quelque sorte le but.
Une seule adresse divulguée, c'est bien. La transformer en l'adresse d'un objet précis, c'est là que ça devient utile.
Deux faits font le travail.
Premièrement, la valeur divulguée pointe à 0x40 octets dans le tas de bureau noyau. La base du tas de bureau noyau est donc la valeur divulguée moins 0x40.
kernel desktop heap base = leaked minus 0x40
Deuxièmement, user32 exporte une structure appelée gSharedInfo. Entre autres choses, elle vous donne la table globale de handles (aheList) et la taille d'une entrée de cette table (HeEntrySize, 0x20 sur x64). Chaque HWND n'est en réalité qu'un index dans cette table. Prenez les 16 bits de poids faible d'un HWND, parcourez l'entrée correspondante, et le premier QWORD de l'entrée est l'offset de cet objet fenêtre dans le tas de bureau.
Assemblez les deux :
offset = gSharedInfo.aheList[HWND & 0xFFFF].offset
kernel addr = kernel desktop heap base plus offset
C'est l'adresse virtuelle noyau exacte de l'objet fenêtre associé à un HWND donné. Pas une devinette. Pas un spray. L'adresse réelle.
La preuve de concept crée six fenêtres de classes différentes (STATIC, BUTTON, EDIT, LISTBOX, SCROLLBAR, COMBOBOX) et résout les six. Elle scanne aussi le tas et trouve une autre demi-douzaine de pointeurs noyau non assainis au-delà de 0x100 ; 0x100 n'est donc que le plus fiable, pas le seul.
Voici la partie qui a fait passer ce bug d'intéressant à réellement préoccupant.
J'ai écrit un second programme qui crée des processus enfants dans des contextes de plus en plus restrictifs et demande à chacun s'il peut lire le même pointeur. Les contextes, classés des plus limités aux moins limités :
CreateDesktop. Fuit, avec une valeur différente car il possède son propre tas de bureau.Tous les contextes de un à cinq ont renvoyé la même adresse noyau, car ils partagent le tas de bureau par défaut. Le contexte six a renvoyé une adresse différente car c'est un tas différent, mais la technique a fonctionné à l'identique.
Le cinquième est celui qu'il faut regarder de plus près. Un processus tournant en intégrité Low, dans un AppContainer, avec zéro capacité, est à peu près aussi verrouillé qu'un processus en mode utilisateur peut l'être sous Windows. C'est la sandbox dans laquelle un navigateur place son renderer. Cette sandbox peut lire une adresse noyau.
Il y a une raison pour laquelle ça fait mal. Tout le modèle de défense en profondeur des navigateurs suppose que même si un attaquant obtient l'exécution de code dans le renderer via un bug distinct du moteur, la sandbox contient les dégâts et cache le noyau. KASLR représente une grande partie de ce camouflage. Cette fuite atteint l'intérieur de la sandbox et offre à l'attaquant une adresse noyau sans syscall supplémentaire ni élévation de privilèges. La sandbox a parfaitement fait son travail. L'information a quand même fui sous elle, à travers une section partagée que le modèle de sandbox considère comme bénigne.
Je n'arrêtais pas d'attendre le piège. Il faut forcément créer une fenêtre d'abord, ou appeler un syscall graphique que la sandbox remarquerait. Non.
Sur cette build, le mappage du tas de bureau est déjà présent dans l'espace d'adressage du processus avant même que user32.dll soit chargée. La preuve lit desktop_heap[0x100] avant et après le chargement de user32, et les deux lectures renvoient le même pointeur noyau. Pas de CreateWindow. Pas de GetDesktopWindow. Rien. Tout processus associé à un bureau, y compris les services sans interface et les processus d'arrière-plan, peut le lire dès qu'il démarre.
La preuve du renderer s'appuie là-dessus. Elle lance un enfant en intégrité Low dans un AppContainer sans aucune capacité, lui fait charger user32 et rien d'autre, et il lit le pointeur immédiatement au démarrage. Sortie textuelle :
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
IL=0x1000 correspond à l'intégrité Low. AC=1 signifie que l'on est dans un AppContainer. NoWindowCreated signifie exactement ce qu'il paraît.
Pendant que j'y étais, j'ai pointé un scanner sur le tas pour voir ce qui traînait d'autre. C'est plus que des pointeurs.
Les titres de fenêtres d'autres processus. Le scanner a trouvé vingt titres uniques provenant d'autres processus, présents dans le tas en UTF16, chacun recoupé avec EnumWindows pour confirmer que le PID propriétaire possédait effectivement une fenêtre avec ce titre. Onglets Chrome, Discord, Explorer, Spotify, fenêtres de la barre système. Le tas est un petit tableau d'affichage de ce que tout le monde fait sur le bureau, et tout processus en sandbox peut le lire sans un seul IPC.
Les identifiants de processus. Plus de six cents valeurs DWORD dans le tas ont été vérifiées comme étant de véritables PID en cours d'exécution, chacun confirmé de deux manières : le succès de OpenProcess et la présence du PID dans la liste d'EnumWindows. Un processus en sandbox peut donc énumérer silencieusement qui possède des fenêtres sur le bureau.
D'autres pointeurs noyau. Six à dix par exécution, variant selon l'activité du bureau, pas seulement celui à 0x100.
Une chose qu'il n'expose PAS, et que j'ai vérifiée exprès : le texte des champs de mot de passe. J'ai créé un EDIT avec ES_PASSWORD, défini son texte sur SecretPassword123, et cherché dans le tas. Pas là. Windows masque le contenu des champs de mot de passe dans le tas de bureau. Bien. C'est déjà ça.
Le tas fuit donc des adresses et des titres de fenêtres, mais au moins il garde vos mots de passe. Je prends la victoire où je peux la trouver.
KASLR existe pour rendre l'exploitation du noyau probabiliste. Sans connaissance des adresses, un use-after-free dans win32k se transforme en spray de pool aveugle. Vous inondez l'allocateur du noyau, espérez que votre objet de remplacement atterrit là où pointe le pointeur pendant, et priez. Sur les implémentations modernes du pool, ce taux de réussite est faible et ne cesse de baisser.
Maintenant, donnez cette fuite à l'attaquant. Il connaît l'adresse noyau exacte de l'objet fenêtre libéré. Il peut façonner une allocation de remplacement à exactement cette adresse et contrôler précisément ce que référence le pointeur pendant. La corruption aveugle devient déterministe. C'est la véritable valeur d'un contournement propre de KASLR : c'est le multiplicateur de fiabilité d'un bug de corruption mémoire sans rapport, pas un bug en soi.
Les mesures d'atténuation que cette fuite érode, concrètement :
gSharedInfo pour connaître les offsets exacts de chaque objet fenêtre, puis calcule les adresses noyau directement.Les objets dont l'adresse noyau résulte de cette seule lecture : les objets fenêtre (tagWND), les objets menu (tagMENU), les objets de classe (tagCLS), et tout ce qui, sur le tas de bureau, est accessible via la table de handles. Une lecture, tout le bureau mappé.
Le répertoire de la PoC contient un compile.bat qui trouve Visual Studio et compile la cible de votre choix.
compile.bat then choose a number from the menu
Les cibles, dans l'ordre de ce qu'elles vous montrent :
kaslr_bypass_poc.exe. La principale. Parcourt la fuite, calcule la base du noyau, résout les adresses réelles des fenêtres, scanne les pointeurs supplémentaires et affiche la chaîne complète. Exécutez-la d'abord.kaslr_sandbox_proof.exe. Génère des enfants en Low IL, dans un AppContainer, dans un LPAC, en Low IL plus AppContainer, et sur un bureau alternatif, puis indique si chacun a fuité et s'ils concordent tous.supporting_proof_no_window.exe. Prouve que la fuite fonctionne avant qu'une fenêtre n'existe.supporting_proof_no_caps_lpac.exe. Prouve qu'elle fonctionne depuis un AppContainer et un LPAC avec zéro capacité.supporting_proof_sensitive_data.exe. Prouve l'exposition de données inter-processus, les titres et les PID, et que le texte des mots de passe n'est pas exposé.supporting_proof_exploitability.exe. Résout les adresses noyau de six classes de fenêtres et montre la défaite des mesures d'atténuation.supporting_proof_remote_trigger.exe. Le contexte équivalent au renderer.Trois vérifications rapides pour vous convaincre que c'est réel :
Un et deux confirment qu'il s'agit d'une adresse stable, partagée et réelle. Trois confirme qu'elle est randomisée par KASLR. Ensemble, elles constituent le bug.
Attendu. Le mappage du tas de bureau en mode utilisateur ne devrait jamais exposer de pointeurs noyau bruts. Tout pointeur noyau présent dans les métadonnées du tas de bureau devrait être assaini avant que le mode utilisateur puisse le voir.
Réel. L'offset 0x100 expose un pointeur vers le pool de session du noyau. Tout processus disposant d'un bureau le lit, y compris les processus en intégrité Low, AppContainer et LPAC. Dans la session testée, chaque contexte a renvoyé 0xFFFFC600DCC00040.
Le correctif le moins coûteux correspond à ce que Windows fait déjà ailleurs dans ce tas. Assainir les pointeurs noyau de l'en-tête du tas de bureau avant qu'ils n'atteignent le mappage en mode utilisateur, le même traitement par sentinelle 0x6000000000 utilisé pour les autres champs.
Le correctif plus robuste, si le mode utilisateur n'a en réalité pas besoin de la page d'en-tête, consiste à cesser d'exposer cette page à travers le mappage en mode utilisateur. Je n'ai pas d'opinion forte sur celui que Microsoft choisira. J'ai une opinion forte : un pointeur noyau brut ne devrait pas être à un __readgsqword et un déréférencement de pointeur d'un renderer sans aucune capacité.
Je reviens sans cesse au contexte cinq. Un AppContainer en intégrité Low sans aucune capacité est censé être la boîte d'où rien d'intéressant ne s'échappe. Le mappage du tas de bureau est le genre de ressource partagée que le modèle de sandbox a décidé il y a longtemps qu'il était sûr de laisser mappée, parce qu'il est en lecture seule et parce que lire le texte d'une fenêtre est inoffensif. Ces deux choses restent vraies. Ce qui a changé, c'est qu'un champ de l'en-tête a transformé cette fenêtre en lecture seule inoffensive en un trou de serrure donnant directement sur le pool de session.
La sandbox n'a pas échoué. L'hypothèse sous la sandbox a échoué. Ce sont des échecs différents, et le second est plus difficile à voir venir, ce qui explique probablement pourquoi personne ne l'a attrapé. Jusqu'à ce que vous lisiez l'offset 0x100.