CVE-2026-50416: Windows 11 KASLR bypass
Sulla build Insider di Windows 11 10.0.28020.2149, il mapping in modalità utente dell'heap del desktop Win32k esponeva un puntatore grezzo al pool della sessione kernel all'offset 0x100.
La lettura in sé è quasi offensivamente piccola:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Nella mia sessione di test, restituiva:
0xffffc600dcc00040
Il valore rimaneva identico tra processi sullo stesso desktop e cambiava dopo un riavvio. Un processo avviato su un altro desktop riceveva un valore diverso perché aveva un heap del desktop diverso. Da questo singolo QWORD, il PoC ha recuperato la base dell'heap del desktop kernel e poi ha utilizzato user32!gSharedInfo per derivare gli indirizzi kernel degli oggetti finestra attivi.
La stessa lettura funzionava da Low integrity, AppContainer, una configurazione LPAC con zero capacità, e un figlio AppContainer a Low integrity con zero capacità.
L'heap del desktop dovrebbe essere condiviso. Il puntatore kernel non lo è.
Win32k memorizza oggetti USER come finestre, menu, classi, hook e metadati correlati negli heap del desktop. Ogni desktop ha il proprio heap. Parte di quell'heap viene mappata nei processi associati al desktop così che la modalità utente possa leggere lo stato GUI condiviso senza chiedere al kernel ogni campo.
Sulla build x64 testata, il mapping in modalità utente può essere raggiunto tramite i dati client del TEB del thread corrente:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
Gli offset sono specifici della build, ma il percorso è semplice:
GS:[0x30]
-> TEB
-> ClientInfo a TEB + 0x800
-> ClientInfo[5]
-> mapping dell'heap del desktop in modalità utente
Il PoC chiama VirtualQuery sull'indirizzo restituito e registra la regione mappata e la sua protezione. Non è ancora successo nulla di sbagliato. Un mapping di sola lettura dell'heap del desktop è un comportamento normale di Win32k.
Il problema inizia 256 byte più avanti.
Il PoC principale legge un QWORD dall'heap mappato:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Il valore ha superato i controlli di base attesi da un indirizzo virtuale kernel sul sistema testato:
Il test di stabilità crea finestre STATIC, BUTTON ed EDIT, legge il valore prima della creazione, lo rilegge mentre le finestre esistono, le distrugge e lo legge una terza volta.
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);
Tutte e tre le letture hanno restituito lo stesso valore. L'attività di allocazione delle finestre non lo ha spostato. Questo comportamento è coerente con un campo nei metadati dell'heap del desktop piuttosto che con un puntatore a un oggetto di breve durata.
La proprietà cross-process è altrettanto importante. Due processi collegati allo stesso desktop osservano lo stesso valore divulgato perché guardano lo stesso heap del desktop. Dopo un riavvio, KASLR dà alla sessione un nuovo indirizzo. Un figlio posto su un altro desktop osserva un altro puntatore perché quel desktop possiede un altro heap.
Questo conferisce alla fuga di informazioni un'identità utile:
stesso avvio + stesso desktop -> stesso puntatore
stesso avvio + desktop diverso -> puntatore diverso
nuovo avvio -> puntatore diverso
Sulla build testata, il puntatore divulgato si trova 0x40 byte sopra la base dell'heap del desktop kernel usata dal PoC:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
Usando il valore registrato della sessione:
puntatore divulgato = 0xffffc600dcc00040
base heap desktop kernel = 0xffffc600dcc00000
Questa relazione è specifica della build. Per la build usata durante i test, fornisce l'ancora lato kernel necessaria per il passo successivo.
Un puntatore è già utile. Un indirizzo per un oggetto scelto è molto più utile.
user32.dll esporta gSharedInfo, che espone l'elenco delle voci degli handle USER e la dimensione di ciascuna voce:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
Un HWND contiene un indice nella tabella degli handle USER. Il PoC prende i 16 bit bassi dell'handle, cammina fino alla voce corrispondente e legge l'offset dell'heap del desktop memorizzato lì.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
Lo stesso offset identifica l'oggetto in entrambi i mapping:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
Quindi il calcolo completo è:
base heap desktop kernel = desktop_heap[0x100] - 0x40
indice handle = HWND & 0xffff
offset heap = aheList[indice handle].offset
indirizzo finestra kernel = base heap desktop kernel + offset heap
Il PoC crea sei classi di finestra ed esegue il calcolo per ciascuna:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXPer ogni oggetto, stampa l'HWND, l'indice dell'handle, l'indirizzo dell'oggetto in modalità utente, l'offset dell'heap e l'indirizzo kernel.
HWND
-> indice handle a 16 bit bassi
-> voce handle gSharedInfo
-> offset heap del desktop
-> base heap desktop kernel + offset
-> indirizzo kernel di quell'oggetto finestra
Questa è la parte che trasforma la divulgazione da un puntatore kernel vago in un oracolo di indirizzi per oggetti USER selezionati sull'heap del desktop testato.
L'heap del desktop arriva tramite un mapping condiviso. I livelli di integrità e le restrizioni AppContainer non riscrivono i contenuti di quel mapping per ogni processo. Se il processo riceve l'heap del desktop, riceve anche il QWORD a 0x100.
Il PoC sandbox lancia figli in diversi contesti e fa leggere a ciascun figlio il valore dal proprio TEB e dal proprio mapping dell'heap del desktop.
| Contesto | Configurazione | Risultato |
|---|---|---|
| Integrità media | Processo utente standard | Divulgato |
| Integrità bassa | Integrità del token abbassata a Low | Divulgato |
| AppContainer | Zero capacità richieste | Divulgato |
| Configurazione LPAC | Tutti i pacchetti applicativi con policy opt-out, zero capacità richieste | Divulgato |
| AppContainer a integrità bassa | Low IL più AppContainer, zero capacità richieste | Divulgato |
| Desktop alternativo | Figlio assegnato a un nuovo desktop | Divulgato un valore diverso |
I primi cinque figli erano collegati al desktop predefinito e restituivano lo stesso indirizzo. Il figlio sul desktop alternativo restituiva un altro indirizzo perché riceveva un altro heap del desktop.
L'output del figlio ha un formato compatto così il genitore può confrontare i risultati:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
L'helper più rigoroso registra anche lo stato del token e il conteggio delle capacità:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
Il dettaglio importante non è che il figlio possa chiamare una speciale API Win32k. Non ne ha bisogno. Una volta che il mapping è presente, la fuga di informazioni è una normale lettura di memoria in modalità utente.
Un helper separato esegue la lettura senza chiamare CreateWindow.
Controlla il puntatore dell'heap del desktop, legge desktop_heap[0x100], carica esplicitamente user32.dll, controlla di nuovo il mapping e comunque non crea mai una finestra. Un altro figlio simile a un renderer carica user32.dll, esegue la stessa lettura ed esce senza creare alcuna finestra.
Il risultato utile è semplice:
Nessun oggetto finestra deve essere creato prima di leggere il QWORD divulgato.
La fuga di informazioni appartiene al mapping dell'heap del desktop stesso, non a una finestra creata dal processo attaccante.
supporting_proof_remote_trigger.c crea un figlio AppContainer a Low integrity con zero capacità richieste. Il figlio fa solo una piccola quantità di lavoro:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Output registrato:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
Questo dimostra la lettura da una configurazione di token simile a un renderer. Un bug separato di corruzione della memoria del browser che già fornisce esecuzione di codice nativo in tale processo non avrebbe bisogno di un'altra divulgazione di informazioni prima di leggere questo puntatore dell'heap del desktop.
Una volta ottenuto un puntatore affidabile, ho scansionato la regione mappata per vedere cos'altro era presente.
Lo scanner ha trovato da sei a dieci valori QWORD unici aggiuntivi per esecuzione che superavano gli stessi controlli di indirizzo canonico e allineamento. Il numero esatto cambiava con l'attività del desktop. L'offset 0x100 era la fuga di informazioni primaria stabile, ma non era l'unico valore con forma di indirizzo kernel nel mapping.
L'helper dei dati sensibili enumera le finestre di primo livello con EnumWindows, raccoglie i PID proprietari e i titoli, e poi cerca nel mapping dell'heap del desktop gli stessi titoli come stringhe UTF-16.
Nell'esecuzione registrata ha trovato venti titoli unici appartenenti ad altri processi. Gli esempi includevano schede del browser, Discord, Explorer, Spotify e finestre della barra di sistema.
Il programma stampa un titolo solo dopo che entrambe le condizioni sono vere:
EnumWindows riporta una finestra con quel titolo e un PID proprietario diverso dal processo di test.Questo rende l'output facile da verificare invece di affidarsi a stringhe stampabili casuali trovate in memoria.
L'helper scansiona anche valori DWORD nel mapping. Un valore viene conteggiato solo quando:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) ha successo per esso.EnumWindows.L'esecuzione registrata ha trovato 605 occorrenze DWORD corrispondenti. Questo è un conteggio di occorrenze nell'heap, non 605 processi unici. Lo stesso PID può apparire più di una volta.
L'helper crea un controllo EDIT con ES_PASSWORD, imposta il suo testo su SecretPassword123 e cerca nella regione mappata il prefisso SecretP. Non è stato trovato nell'esecuzione testata.
Quindi il mapping esponeva titoli, occorrenze di PID e valori con forma kernel, mentre la stringa della password testata non appariva lì.
Per un bug di corruzione della memoria Win32k, sapere che un oggetto esiste non è la stessa cosa che sapere dove vive nella memoria kernel.
Senza la divulgazione, l'attaccante deve gestire una base dell'heap del desktop sconosciuta e indirizzi di oggetti sconosciuti. Con la divulgazione, il lato degli indirizzi diventa:
leggi un QWORD
sottrai 0x40
leggi la voce dell'handle target
aggiungi il suo offset dell'heap
Per un HWND scelto, l'attaccante ora ha l'indirizzo corrispondente dell'heap del desktop kernel sulla build testata. Questo può aiutare con:
La fuga di informazioni risolve il problema dell'indirizzo. Il modellamento dell'heap, la sostituzione degli oggetti e la primitiva di corruzione della memoria rimangono parti separate dell'exploit.
Questa divisione conta. KASLR non ferma la corruzione della memoria. Rende il targeting affidabile più difficile. Questo QWORD rimuove quell'incertezza per la regione dell'heap del desktop usata dal PoC.
Windows 11 Insider Build 10.0.28020.2149
Utente standard
Integrità media di base
kaslr_bypass_poc.c: PoC principale di fuga di informazioni e risoluzione degli indirizzi delle finestrekaslr_sandbox_proof.c: test Medium IL, Low IL, AppContainer, configurazione LPAC, AppContainer Low IL e desktop alternativosupporting_proof_no_window.c: lettura senza creare una finestrasupporting_proof_no_caps_lpac.c: configurazioni AppContainer e LPAC a zero capacitàsupporting_proof_sensitive_data.c: titoli, occorrenze di PID, scansione di puntatori extra e controllo del campo passwordsupporting_proof_exploitability.c: sei classi di finestra e calcoli degli indirizzi kernelsupporting_proof_remote_trigger.c: figlio AppContainer Low IL simile a un renderercompile.bat: menu di compilazioneEsegui:
compile.bat
Seleziona il target dal menu.
Il PoC principale può anche essere compilato direttamente da un prompt dei comandi di sviluppo Visual Studio:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
Esegui il PoC principale due volte senza riavviare:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
Il puntatore a desktop_heap + 0x100 dovrebbe essere identico in entrambe le esecuzioni.
Apri un secondo terminale ed eseguilo da un altro processo sullo stesso desktop. Il valore dovrebbe corrispondere di nuovo.
Riavvia e ripeti. Il valore dovrebbe cambiare.
kaslr_sandbox_proof.exe
Il test lancia ogni figlio, cattura il suo output e confronta i valori divulgati. I figli sul desktop predefinito dovrebbero riportare lo stesso valore. Il figlio sul desktop alternativo dovrebbe riportare un valore diverso.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
Ogni helper isola una parte del risultato così può essere riprodotta senza leggere l'intero output del PoC completo.
Il mapping in modalità utente non dovrebbe contenere indirizzi virtuali kernel grezzi.
La correzione più piccola è sanificare il campo dell'intestazione dell'heap del desktop prima che la pagina diventi visibile in modalità utente. Windows usa già un valore opaco 0x6000000000 per altri campi puntatore dell'heap del desktop, quindi lo stesso stile di sostituzione potrebbe essere usato qui se la modalità utente ha ancora bisogno del campo.
Se la modalità utente non ha bisogno della pagina dell'intestazione, la correzione più pulita è non esporre quella pagina nel mapping condiviso.
Il test di regressione è semplice: crea processi a Medium IL, Low IL, AppContainer e configurazioni LPAC, mappa l'heap del desktop e rifiuta qualsiasi indirizzo kernel canonico trovato nell'intestazione visibile all'utente.
L'intera catena inizia con una lettura ordinaria da un mapping di sola lettura:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Quel QWORD identifica l'heap del desktop kernel. gSharedInfo fornisce l'offset per oggetto. Insieme trasformano un HWND in modalità utente nell'indirizzo kernel corrispondente sulla build testata.
Nessun trigger complicato si nasconde qui. Windows ha messo l'heap del desktop dove la modalità utente potesse leggerlo, poi ha lasciato un puntatore kernel dentro la parte che ha condiviso.
Un QWORD è stato sufficiente.