
Analisi della causa principale di CVE-2026-54107: un use-after-free in Windows win32kfull.sys con debug di race condition, analisi statica, approfondimenti sul triage MSRC e ricerca pratica sullo sfruttamento del kernel.
ValidateHwnd Non È un Cancello: Analisi delle Cause di CVE-2026-54107Un use-after-free nella gestione del ciclo di vita delle finestre in
win32kfull.sys— come l'ho trovato, come mi sono convinto che fosse reale, e come appariva davvero il processo MSRC dal lato del ricercatore.
Erano quasi le 2 di notte quando la VM di test ha smesso di rispondere al heartbeat del debugger ed è caduta in un breakpoint esattamente sull'istruzione che per settimane avevo sostenuto essere raggiungibile. Non un'asserzione, non uno stop per pool corrotto — una semplice violazione di accesso sul percorso di dispatch dei messaggi, nel dereferenziare un oggetto che un altro thread aveva già smantellato.
kd> !pool 2Quel breakpoint è diventato CVE-2026-54107, caso MSRC 11xxxxx, corretto nell'Aggiornamento di sicurezza di luglio 2026 in 27 prodotti Windows.
Questo post è la metà non soggetta a embargo della storia: causa principale, perché la classe di bug è quella che è, e il ragionamento che mi ha portato fin qui. I dettagli di sfruttamento restano fuori.
Win32k è la metà in modalità kernel del sottosistema grafico di Windows. È vecchio, è enorme e — cosa cruciale — è raggiungibile da contesti che dovrebbero essere non fidati. Quest'ultima proprietà è il motivo per cui rimane un bersaglio di ricerca permanente nonostante vent'anni di lavoro su hardening, filtri e restrizioni sulle syscall.
All'interno di win32k, l'oggetto tagWND (PWND) è insolitamente interessante perché il suo ciclo di vita è gestito da più di un meccanismo contemporaneamente. Una finestra è:
ValidateHwnd,Qualsiasi oggetto con diversi percorsi di riferimento indipendenti e un unico percorso di distruzione condiviso merita di essere letto con calma. Non è un'affermazione di vulnerabilità — è un'euristica su dove spendere tempo.
Ciò che mi ha fatto soffermare su questo componente è stata la superficie di importazione. win32kfull.sys tira dentro tre primitive distinte di riferimento a oggetti da ntoskrnl:```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(/* ... */);
Three vie in ingresso, un solo percorso di uscita `ObfDereferenceObject`.
Ciò non significa che il codice sia sbagliato. Significa che l'**invariante è distribuita** — nessuna singola funzione possiede *"questo oggetto è vivo in questo momento,"* quindi la correttezza dipende dal fatto che ogni chiamante concordi su quale riferimento detiene e per quanto tempo sia valido. Le invarianti distribuite sono il luogo in cui vivono le race condition, perché una race non è mai un bug logico che puoi vedere in una singola funzione. È un bug in un'ipotesi condivisa tra due.
Quindi la domanda che ho iniziato a porre a ogni funzione che toccava un `PWND` non era *"questo codice è corretto?"* ma:
> **Se questo esatto corpo di funzione viene eseguito su due thread a pochi istruzioni di distanza, quale dei due è sbagliato?**
## 3. Causa principale
Il difetto è un **intervallo time-of-check / time-of-use tra il rilascio del riferimento e la distruzione dell'oggetto** nel percorso di distruzione della finestra, senza una sincronizzazione adeguata rispetto a un consumer concorrente che convalida handle.
Ridotto alla sua forma:```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 */
}
}
(Mostra il menu completo con tutti gli argomenti, le loro descrizioni e i valori predefiniti. Questo parametro non richiede un valore, basta inserirlo come '--help')```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 */
...
}
Due cose devono essere vere perché questo conti, ed entrambe lo erano:
**(a) La finestra è reale.** `ValidateHwnd` è il gate che dovrebbe rendere sicuro l'accesso basato sugli handle. Se la validazione può avere successo contro un oggetto la cui distruzione è già iniziata, il gate non è un gate — è un suggerimento.
**(b) La memoria liberata è influenzabile dall'attaccante.** I campi letti subito dopo la validazione includono `fnid`, che guida il dispatch dei messaggi. Una decisione di dispatch presa da memoria recuperata è la differenza tra *"crash inaffidabile"* e *"violazione del confine di sicurezza"*. Questa distinzione è l'intera ragione per cui questa è CWE-362 con impatto EoP e non un bug di stabilità.
> La **corruzione** osservata è una use-after-free; la **causa** è CWE-362, esecuzione concorrente che usa una risorsa condivisa con sincronizzazione impropria. Sono due affermazioni diverse e a MSRC interessa la seconda. **Riporta la causa, non solo il sintomo.**
### Perché le race condition in win32k sono strutturalmente più difficili di quanto sembrano
Se hai fatto race in altri sottosistemi, win32k ti frustrerà, perché l'architettura ti combatte in tre modi specifici.
**Le finestre hanno affinità di thread.** Una finestra appartiene al thread che l'ha creata. Gran parte del sottosistema è costruita attorno all'assunzione che il thread proprietario sia quello che tocca l'oggetto, il che significa che l'approccio ingenuo di "far girare due thread che chiamano la stessa API" spesso non sovrappone nulla — non stai gareggiando, ti stai accodando. Far collidere realmente due percorsi sullo stesso oggetto richiede di capire quali operazioni vengono effettivamente eseguite sul thread del chiamante e quali invece vengono marshallate su quello del proprietario.
**Il dispatch dei messaggi ti serializza in parte.** Le send e i post si comportano diversamente, e il dispatch cross-thread si comporta di nuovo diversamente da quello same-thread. Parte di ciò che sembra un'opportunità di concorrenza viene silenziosamente convertita in un'operazione ordinata prima ancora di raggiungere il codice che ti interessa. Se non sai in quale categoria rientra il tuo trigger, concluderai che una race reale è irraggiungibile — un falso negativo identico a "qui non c'è nessun bug".
**La sezione critica si nasconde nel chiamante.** Gran parte del sottosistema gira sotto un lock grossolano acquisito ben al di sopra della funzione che stai osservando. Questa è la più grande fonte di tempo sprecato nell'auditing di win32k: una funzione senza sincronizzazione visibile che è comunque perfettamente sicura perché ogni percorso che vi arriva è già serializzato. **La copertura del lock è una proprietà interprocedurale.** Devi risalire il call graph, non limitarti a leggere la funzione.
Questo terzo punto è il motivo per cui *"nessun lock in questa funzione"* vale quasi nulla come segnale, e perché la maggior parte del lavoro in questa caccia è stata dedicata alla raggiungibilità piuttosto che al difetto stesso.
## 4. Perché la valutazione dell'impatto è quella che è
MSRC ha valutato questa come **Important, Elevation of Privilege, CVSS 8.8, vettore d'attacco locale, autenticato.** Due proprietà determinano questo:
**Raggiungibilità da bassa integrità.** La superficie di chiamata dei messaggi di win32k è raggiungibile da contesti molto al di sotto di SYSTEM. È ciò che la rende rilevante per le catene di sandbox-escape — un processo renderer che ha già ottenuto l'esecuzione di codice all'interno della sua sandbox può ancora raggiungere questa superficie. La gravità di un bug del kernel è in gran parte funzione di *chi può toccarlo*, non di quanto sia intelligente la corruzione.
**Corruzione che influenza il dispatch.** Corrompere un campo su cui opera un `switch` è qualitativamente peggio che corrompere un campo che viene solo loggato. Il primo trasforma un bug di memoria in una questione di flusso di controllo.
Voglio essere preciso su una cosa qui, perché ho visto post sul primo CVE esagerare questo punto: **ho dimostrato la race e la use-after-free. Non ho consegnato un exploit weaponizzato a livello SYSTEM.** La cornice del sandbox-escape descrive la *classe di catena* a cui appartiene questo tipo di bug e perché la superficie è preziosa — è un argomento sulla raggiungibilità, non un'affermazione che ne abbia costruita una. Sovrastimare l'impatto è il modo più rapido per bruciare la credibilità con un vendor, e la valutazione di MSRC è il numero che conta, non la mia.
## 5. La falsificazione è arrivata prima: la maggior parte dei candidati è morta
La parte di cui nessuno scrive: questo non era il primo candidato. Era quello sopravvissuto.
La mia regola di lavoro è che **un candidato è colpevole finché non viene dimostrato colpevole.** Ogni pattern promettente riceve una ragione specifica e scritta per cui *non* dovrebbe essere sfruttabile, e io provo a stabilire quella ragione prima di provare a innescarlo. I candidati che ho chiuso prima di questo includevano:
- percorsi che sembravano non sincronizzati ma erano serializzati da un lock acquisito un frame più su,
- percorsi in cui l'oggetto "liberato" era in realtà memorizzato nella cache anziché rilasciato,
- percorsi realmente in race ma non raggiungibili da nessun chiamante che un utente a bassi privilegi potesse pilotare.
Ognuno di questi è un finding che *non* ho inviato a MSRC. È questo il punto. La produttività di un ricercatore non è quanti candidati genera — è la velocità con cui riesce a eliminare quelli sbagliati, così da non ritrovarseli ancora tra le mani alle 2 di notte.
**Le tre domande che hanno eliminato la maggior parte dei candidati:**
1. **C'è qualcosa sopra di me che tiene un lock?** Interprocedurale, non locale. L'assenza di un lock in una funzione non significa nulla.
2. **Un chiamante non privilegiato può davvero raggiungere entrambi i lati?** Una race tra due percorsi che richiedono livelli di privilegio diversi non è una race, è un esperimento mentale.
3. **La memoria liberata è recuperabile in una finestra che posso influenzare?** Se il teardown si completa atomicamente a tutti gli effetti pratici, non c'è nessun bug che valga la pena segnalare.
## 6. Verifica: la statica dà ipotesi, il debugger dà la verità
L'analisi statica di `win32kfull.sys` mi ha dato l'ipotesi. Non avrebbe mai potuto darmi il bug. **Le race condition non sono visibili in un decompilatore** perché il difetto non è nelle istruzioni — è nell'interleaving.
### Laboratorio
| Ruolo | Configurazione |
| --- | --- |
| Host / debugger | Windows 11, WinDbg |
| Target | Windows Server 2022, Build 20348.2159 |
| Analisi | Kali Linux + Windows 11 VM |
| Trasporto debug | VMware serial COM, debug del kernel host → target |
| Analisi statica | Ghidra via GhidraMCP |
| Assistenza triage | Passaggio assistito da IA sull'output decompilato |
Tre livelli di strumentazione hanno fatto il vero lavoro.
### Special pool e Driver Verifier
Il singolo passo con la massima leva in qualsiasi indagine su una UAF del kernel. Di default, la memoria del pool liberata viene riusata quasi immediatamente dalla successiva allocazione di dimensione simile — il che significa che una use-after-free di solito *non genera un fault*. Legge dati validi di qualcun altro, continua a eseguire e detona da qualche parte non correlata minuti dopo. Poi passi tre giorni ad analizzare una funzione innocente.
Lo special pool cambia tutto questo. Ogni allocazione ottiene una pagina propria con una guard page adiacente, e le pagine liberate vengono marcate come no-access invece di essere riciclate. Il risultato è che il dereference colpevole genera un fault **nell'istruzione che lo esegue**, non a valle:```
!verifier 0x1 win32kfull.sys ; special pool on the target driver
!verifier 0x8 win32kfull.sys ; pool tracking
Combinato con il filtraggio per tag di pool, questo è ciò che trasforma "bugcheck intermittente sotto carico" in un guasto riproducibile e attribuibile.
Se devi portare via una sola cosa da questo post: abilita il pool speciale prima di iniziare, non dopo che sei rimasto bloccato.
Una volta che hai un guasto, la domanda è se stai osservando corruzione o un bug di durata. I due casi richiedono report diversi. I metadati del pool danno la risposta:``` kd> !pool
Un blocco che è *allocato* con un tag plausibile e contenuti spazzatura indica **corruzione**. Un blocco che è *liberato*, o che si trova in una pagina special-pool senza accesso, indica un **bug di lifetime** — qualcosa che ha mantenuto un puntatore oltre la morte dell'oggetto. Questa è la distinzione tra *"l'attaccante ha scritto qui"* e *"questo oggetto non avrebbe dovuto essere raggiungibile"*, ed è la differenza tra un report di heap-corruption e un report CWE-362.
Controlla incrociato il tipo di oggetto prima di impegnarti in una delle due ipotesi. Un `PWND` ha una forma riconoscibile; se la memoria su cui hai generato il fault conserva ancora i resti di uno, sei quasi certamente in un problema di lifetime di finestra piuttosto che in una sovrascrittura casuale.
### Debug del kernel live sull'interleaving
Anche con lo special pool, una race è un problema di scheduling, e **il debugger cambia lo scheduling.** Questa è la frustrazione centrale del lavoro sulle race: lo strumento perturba ciò che misura. Un breakpoint sul percorso di teardown serializza esattamente i due thread che stai cercando di sovrapporre, e il bug sparisce educatamente.
La soluzione è smettere di cercare di catturare la race con un breakpoint e invece:
- **allargare artificialmente la finestra** — qualsiasi cosa allunghi l'intervallo tra il rilascio del riferimento e il teardown rende la collisione raggiungibile con i normali tassi di scheduling,
- **aumentare i tentativi di collisione piuttosto che la precisione** — esegui i due percorsi in continuazione e lascia che la probabilità faccia il suo lavoro,
- **usare breakpoint condizionali e one-shot** che si attivano solo quando esiste lo stato interessante, invece di fermarsi a ogni ingresso,
- **confermare l'interleaving dopo il fault**, dallo stato dei thread e dagli stack, piuttosto che cercare di osservarlo dal vivo.
Il fault stesso, una volta ottenuto, è poco affascinante — una dereferenza di un `PWND` sul percorso di message-dispatch dove l'oggetto ha già subito il teardown su un altro thread, con `!pool` che conferma che il blocco è stato rilasciato piuttosto che sovrascritto:```
kd> !analyze -v
EXCEPTION_CODE: (NTSTATUS) 0xc0000005 - Access violation
FAULTING_MODULE: win32kfull
(Offset, indirizzi e dettagli di riproduzione sono omessi nell'ambito della divulgazione coordinata.)
L'impatto non è dimostrato da un crash. È dimostrato da chi può causare il crash. Ogni esecuzione del trigger è stata effettuata da un account utente standard, non amministratore, sulla macchina target, perché un guasto del kernel raggiungibile solo da un contesto già privilegiato è un bug di stabilità, non di sicurezza. Controllare il livello di integrità del processo che ha causato la collisione è un passaggio da trenta secondi che determina se hai un caso per la bounty o una segnalazione a Windows Feedback Hub.
La disciplina fondamentale: non ho creduto a un singolo crash. Un singolo crash su una race condition è rumore. Ciò che lo ha reso segnalabile è stata la ripetibilità con tempistiche controllate — poter dire "questi due percorsi, questo ordine, questa finestra" e ottenere lo stesso guasto. È questa la differenza tra un report su cui MSRC può intervenire e uno che chiudono come non riproducibile.
Ho usato il triage assistito dall'IA per scorrere più velocemente l'output decompilato, e dirò chiaramente a cosa serviva e a cosa no.
Utile per: coprire la superficie di attacco. Leggere un grande volume di HLIL e segnalare "queste funzioni toccano un oggetto condiviso senza sincronizzazione visibile" è pattern-matching, e il pattern-matching su larga scala è esattamente ciò che questi strumenti sanno fare bene. Ha compresso settimane di lettura veloce in giorni.
Inutile per: il giudizio. Narrerà con sicurezza una catena di attacco che non esiste, affermerà una raggiungibilità che non ha stabilito e produrrà un writeup splendidamente strutturato per un bug che non c'è. Ogni singola conclusione doveva superare la verifica manuale in WinDbg prima ancora di avvicinarsi a un report.
La modalità di errore da temere non è lo strumento che sbaglia. È lo strumento fluente mentre sbaglia, alle 2 di notte, quando vuoi che abbia ragione.
Una scoperta inventata inviata a MSRC costa tempo reale ai loro ingegneri e ti costa una reputazione che non puoi ricostruire in fretta.
| Data | Evento |
|---|---|
| 7 maggio 2026 | Inviato — VULN-186460 |
| 7 maggio 2026 | Aperto il caso — MSRC Case 11xxxxx |
| 11 giugno 2026 | Comportamento confermato da Microsoft; aperta la revisione della bounty |
| 27 giugno 2026 | Fix pianificato per la release di luglio; assegnato CVE-2026-54107 (pre-release) |
| 30 giugno 2026 | Bounty assegnata — 8.000 US$, Windows Insider Preview Bounty Program |
| 14 luglio 2026 | Patch rilasciata; CVE pubblicato |
Cinque settimane di silenzio tra l'invio e la conferma. È la parte che ti mette alla prova. Hai messo per iscritto un'affermazione sul kernel di qualcun altro e non hai ancora idea se il tuo ragionamento regge, se è un duplicato o se si riproduce persino sulla loro build. L'email di conferma è il momento in cui smette di essere una teoria che possiedi e diventa una vulnerabilità che esiste.
Una piccola cosa che mi ha fatto ridere di me stesso: il 14 luglio, col mio fuso orario, ho scritto sul caso chiedendo perché il CVE non fosse stato pubblicato. La risposta, educata: è il 13 luglio a Seattle. Il calendario delle release di Microsoft segue il fuso del Pacifico. Ora lo so.
| Campo | Dettaglio |
|---|---|
| CVE | CVE-2026-54107 |
| Caso MSRC | 11xxxxx (VULN-186460) |
| Componente | win32kfull.sys — ciclo di vita dell'oggetto finestra |
| Classe | Race condition → use-after-free |
| CWE | CWE-362 |
| Impatto | Elevazione dei privilegi |
| Gravità | Importante (MSRC) |
| CVSS v3.1 | 8.8 (Alta) |
| Vettore | Locale, autenticato |
| Programma | Windows Insider Preview Bounty Program |
| Premio | 8.000 US$ |
| Corretto | Aggiornamento di sicurezza di luglio 2026 |
Leggi cercando gli invarianti, non i bug. "Dove questo codice assume qualcosa che non impone?" trova più di "dov'è l'overflow?" — soprattutto in componenti maturi e pesantemente sottoposti ad audit, dove le classi di bug facili sono sparite.
Una race condition è un'affermazione interprocedurale. Non puoi stabilirla o confutarla da una singola funzione. Se la tua analisi si ferma al confine della funzione, genererai candidati che non potrai mai chiudere.
Il tuo ambiente di debugging è il lavoro. Ho perso più ore per un collegamento seriale COM instabile che per la caccia vera e propria, e una pipeline rotta produce falsi negativi che sembrano identici a "non c'è niente qui." Ho quasi abbandonato questo target per un'impostazione della porta COM.
Segnala la causa, non il crash. MSRC riceve crash. Ciò che fa avanzare un caso è una storia coerente su quale invariante si è rotto e perché non veniva applicato.
La conferma non è la fine. Tra la conferma e la patch rilasciata ci sono il follow-up sulla riproducibilità, il comportamento su Canary e la revisione. Resta coinvolto.
Stessa metodologia, superfici diverse — tcpip.sys, afd.sys, clfs.sys. Preferisco essere conosciuto per un corpus di lavoro che per un singolo colpo di fortuna, e l'unico modo perché accada è continuare a eliminare candidati più velocemente di quanto ne generi.
Se sei dove ero io un anno fa — vieni dalle bounty web, sei curioso del lavoro sul kernel, non sei sicuro di essere il tipo di persona che può farcela — lo scopri facendolo. Scegli un driver. Collega un debugger. Leggi lentamente. Continua a chiederti cosa succede se viene eseguito due volte.
È davvero da lì che si comincia.
Scritto da Pravin Choudhary (@pr4v1nx) — ricercatore indipendente di sicurezza offensiva. Segnalato a Microsoft tramite divulgazione coordinata. Dettagli di sfruttamento, offset e codice di riproduzione sono omessi intenzionalmente.