Patch Tuesday → Exploit Wednesday: compromettere il driver di funzione accessoria di Windows per WinSock (afd.sys) in 24 ore

Illustrazione del leadspace dell'ufficio della privacy e della tecnologia responsabile che mostra lo scudo della privacy

Autori

Valentina Palmiotti

Head of X-Force Offensive Research (XOR)

IBM

Ruben Boonen

CNE Capability Lead, Adversary Services

IBM X-Force

"Patch Tuesday, Exploit Wednesday" è un vecchio detto degli hacker che si riferisce alla strumentalizzazione delle vulnerabilità il giorno successivo alla pubblicazione delle patch di sicurezza mensili. Con il miglioramento della sicurezza e il progressivo perfezionamento delle misure di mitigazione degli exploit, è aumentata la quantità di ricerca e sviluppo necessaria per utilizzare un exploit. Questo è particolarmente rilevante per le vulnerabilità di corruzione della memoria.

Schermata realizzata per il post sul blog

Figura 1: Cronologia dello sfruttamento

Tuttavia, con l'aggiunta di nuove caratteristiche (e codice C non sicuro per la memoria) nel kernel di Windows 11, possono essere introdotte nuove superfici di attacco. Concentrandoci su questo codice appena introdotto, dimostriamo che le vulnerabilità che possono essere sfruttate in modo banale si verificano ancora frequentemente. In questo post sul blog, analizziamo e sfruttiamo una vulnerabilità nel driver di funzione accessoria di Windows per Winsock, afd.sys, per l'escalation dei privilegi locali (LPE) su Windows 11. Sebbene nessuno di noi avesse alcuna esperienza precedente con questo modulo del kernel, siamo stati in grado di diagnosticare, riprodurre e armare la vulnerabilità in circa un giorno. Puoi trovare il codice da utilizzare qui.

Analisi delle differenze e causa principale

Basandoci sui dettagli di CVE-2023-21768 pubblicati dal Microsoft Security Response Center (MSRC), la vulnerabilità si trova nel driver di funzione accessoria (AFD), il cui file binario è afd.sys. Il modulo AFD è il punto di ingresso del kernel per l'API Winsock. Utilizzando queste informazioni, abbiamo analizzato la versione del driver del dicembre 2022 e l'abbiamo confrontata con la versione appena rilasciata nel gennaio 2023. Questi campioni possono essere ottenuti individualmente da Winbindex senza il processo lungo di estrazione delle modifiche dalle patch Microsoft. Le due versioni analizzate sono mostrate di seguito.

  • AFD.sys / Windows 11 22H2 / 10.0.22621.608 (dicembre 2022)
  • AFD.sys / Windows 11 22H2 / 10.0.22621.1105 (gennaio 2023)

Ghidra è stato utilizzato per creare esportazioni binarie per entrambi questi file in modo che potessero essere confrontati in BinDiff. Di seguito è mostrata una panoramica delle funzioni abbinate.

Schermata realizzata con il confronto binario di AFD.sys

Figura 2: Confronto binario di AFD.sys

Sembra che solo una funzione sia stata modificata, afd!AfdNotifyRemoveIoCompletion . Questo ha accelerato notevolmente la nostra analisi della vulnerabilità. Abbiamo quindi confrontato entrambe le funzioni. Le schermate qui sotto mostrano il codice modificato prima e dopo la patch, osservato nel codice decompilato con Binary Ninja.

Prima della patch, afd.sys version 10.0.22621.608 .

Schermata realizzata per il post sul blog

Figura 3: afd!AfdNotifyRemoveIoCompletion prima della patch

Dopo la patch, afd.sys versione 10.0.22621.1105.

Schermata realizzata per il post sul blog

Figura 4: afd!AfdNotifyRemoveIoCompletion dopo la patch

Questa modifica mostrata sopra è l'unico aggiornamento della funzione identificata. Un'analisi rapida ha mostrato che viene effettuato un controllo basato su PreviousMode . Se PreviousMode è zero (indicando che la chiamata proviene dal kernel), un valore viene scritto su un puntatore specificato da un campo in una struttura sconosciuta. Se, d'altra parte, PreviousMode non è zero, allora viene chiamato ProbeForWrite per assicurarsi che il puntatore indicato nel campo sia un indirizzo valido che risieda in modalità utente.

Questo controllo manca nella versione pre-patch del driver. Poiché la funzione contiene un'istruzione switch specifica per PreviousMode , l'ipotesi è che lo sviluppatore avesse intenzione di aggiungere questo controllo, ma se ne sia dimenticato (a tutti noi manca il caffè, a volte ☕!).

Da questo aggiornamento, possiamo dedurre che un aggressore può raggiungere questo percorso di codice con un valore controllato infield_0x18 della struttura sconosciuta. Se un aggressore riesce a popolare questo campo con un indirizzo kernel, allora è possibile creare una primitiva Write-Where del kernel arbitraria. A questo punto, non è chiaro quale valore venga scritto, ma qualsiasi valore potrebbe potenzialmente essere usato per una primitiva di escalation dei privilegi locali.

Il prototipo della funzione stessa contiene sia il valore PreviousMode che un puntatore alla struttura sconosciuta, rispettivamente come primo e terzo argomento.

Schermata realizzata con il prototipo della funzione afd!AfdNotifyRemoveIoCompletion

Figura 5: Prototipo della funzione afd!AfdNotifyRemoveIoCompletion

Reverse engineering

Ora conosciamo la posizione della vulnerabilità, ma non come attivare l'esecuzione del percorso di codice vulnerabile. Faremo un po' di reverse engineering prima di iniziare a lavorare su una Proof-of-Concept (PoC).

Innanzitutto, la funzione vulnerabile è stata confrontata per capire dove e come veniva utilizzata.

Schermata realizzata con riferimenti incrociati a afd!AfdNotifyRemoveIoCompletion

Figura 6: Riferimenti incrociati afd!AfdNotifyRemoveIoCompletion

Una singola chiamata alla funzione vulnerabile viene effettuata in afd!AfdNotifySock .

Ripetiamo il processo, cercando riferimenti incrociati a AfdNotifySock . Non troviamo chiamate dirette alla funzione, ma il suo indirizzo appare sopra una tabella di puntatori di funzione denominata AfdIrpCallDispatch .

Schermata realizzata con afd!AfdIrpCallDispatch

Figura 7: afd!AfdIrpCallDispatch

Questa tabella contiene le routine di dispatch per il driver AFD. Le routine di dispatch vengono utilizzate per gestire le richieste provenienti dalle applicazioni Win32 chiamando DeviceIoControl. Il codice di controllo per ogni funzione si trova in AfdIoctlTable .

Tuttavia, il puntatore sopra non rientra nella tabella AfdIrpCallDispatch  come ci aspettavamo. Dalle slide del talk di Steven Vittitoe al Recon abbiamo scoperto che esistono in realtà due tabelle di dispatch per AFD. La seconda è AfdImmediateCallDispatch . Calcolando la distanza tra l'inizio di questa tabella e il punto in cui è memorizzato il puntatore a AfdNotifySock  , possiamo calcolare l'indice nella AfdIoctlTable  che mostra che il codice di controllo della funzione è 0x12127 .

Schermata realizzata con afd!AfdIoctlTable

Figura 8: afd!AfdIoctlTable

Vale la pena notare che si tratta dell'ultimo codice di controllo input/output (IOCTL) nella tabella, il che indica che AfdNotifySock è probabilmente una nuova funzione di dispatch aggiunta di recente al driver AFD.

A questo punto avevamo un paio di opzioni. Potevamo effettuare il reverse engineering della corrispondente API Winsock in uno spazio utente per comprendere meglio come è stata chiamata la funzione kernel sottostante, oppure effettuare il reverse engineering del codice kernel e richiamarlo direttamente. In realtà non sapevamo a quale funzione Winsock corrispondesse AfdNotifySock , quindi abbiamo optato per la seconda opzione.

Ci siamo imbattuti in un codice pubblicato da x86matthew che esegue operazioni di socket chiamando direttamente il driver AFD, senza usare la libreria Winsock. Questo è interessante dal punto di vista stealth, ma per i nostri scopi è un buon modello per creare un handle per un socket TCP per fare richieste IOCTL al driver AFD. Da lì, siamo riusciti a raggiungere la funzione target, come dimostrato dal raggiungimento di un breakpoint impostato in WinDbg durante il debug del kernel.

Schermata realizzata con breakpoint su afd!AfdNotifySock

Figura 9: Breakpoint afd!AfdNotifySock

Ora, torniamo al prototipo della funzione DeviceIoControl , tramite cui chiamiamo il driver AFD dallo spazio utente. Uno dei parametri, lpInBuffer , è un buffer in modalità utente. Come menzionato nella sezione precedente, la vulnerabilità si verifica perché l'utente è in grado di passare un puntatore non validato al driver all'interno di una struttura dati sconosciuta. Questa struttura viene passata direttamente dalla nostra applicazione in modalità utente tramite il parametro lpInBuffer. Viene passato a AfdNotifySock  come quarto parametro e a AfdNotifyRemoveIoCompletion  come terzo parametro.

A questo punto, non sappiamo come popolare i dati in lpInBuffer, che chiameremo AFD_NOTIFYSOCK_STRUCT , per superare i controlli richiesti per raggiungere il percorso del codice vulnerabile in AfdNotifyRemoveIoCompletion . Il resto del nostro processo di reverse engineering consisteva nel seguire il flusso di esecuzione ed esaminare come raggiungere il codice vulnerabile.

Esaminiamo ciascuno dei controlli.

Il primo controllo che incontriamo è all'inizio di AfdNotifySock :

Schermata realizzata con il controllo delle dimensioni di afd!AfdNotifySock

Figura 10: Controllo delle dimensioni di afd!AfdNotifySock

Questo controllo ci dice che la dimensione di AFD_NOTIFYSOCK_STRUCT  dovrebbe essere uguale a 0x30  byte, altrimenti la funzione fallisce con STATUS_INFO_LENGTH_MISMATCH .

Il controllo successivo convalida i valori in vari campi della nostra struttura:

della convalida della struttura afd!AfdNotifySock

Figura 11: Validazione della struttura afd!AfdNotifySock

Al momento non sapevamo a cosa corrispondessero nessuno dei campi, quindi abbiamo passato un array di 0x30  byte riempito con 0x41  byte (AAAAAAAAA... ).

Il controllo successivo che incontriamo avviene dopo una chiamata a ObReferenceObjectByHandle. Questa funzione prende come primo argomento il primo campo della nostra struttura di input.

Schermata realizzata per il post sul blog

Figura 12: afd!AfdNotifySock chiama nt!ObReferenceObjectByHandle

La chiamata deve avere esito positivo per procedere al percorso di esecuzione del codice corretto, il che significa che dobbiamo passare un handle valido a un IoCompletionObject . Non esiste un modo ufficialmente documentato per creare un oggetto di quel tipo tramite l'API Win32. Tuttavia, dopo alcune ricerche, abbiamo trovato una funzione NT non documentata, NtCreateIoComplet, che ha fatto il suo lavoro.

Dopodiché, raggiungiamo un ciclo il cui contatore era uno dei valori della nostra struttura:

Schermata realizzata con il ciclo afd!AfdNotifySock

Figura 13: Ciclo afd!AfdNotifySock

Questo ciclo controlla un campo della nostra struttura per verificare che contenga un puntatore valido in modalità utente e vi copia i dati. Il puntatore viene incrementato dopo ogni iterazione del ciclo. Abbiamo inserito i puntatori con indirizzi validi e impostato il contatore a 1. Da qui, siamo finalmente riusciti a raggiungere la funzione vulnerabile AfdNotifyRemoveIoCompletion .

Schermata realizzata con la chiamata a afd!AfdNotifyRemoveIoCompletion

Figura 14: Chiamata di afd!AfdNotifyRemoveIoCompletion

Una volta entrati in AfdNotifyRemoveIoCompletion , il primo controllo riguarda un altro campo della nostra struttura. Deve essere diverso da zero. Viene poi moltiplicato per 0x20 e passato a ProbeForWrite  insieme a un altro campo della nostra struttura come parametro puntatore. Da qui possiamo completare la struttura con un puntatore valido in modalità utente (pData2 ) e il campo dwLen = 1 (in modo che la dimensione totale passata a ProbeForWrite  sia uguale a 0x20), superando così i controlli.

Schermata realizzata con il controllo del campo in afd!AfdNotifyRemoveIoCompletion

Figura 15: Controllo del campo afd! Afd!AfdNotifyRemoveIoCompletion

Infine, l'ultimo controllo da superare prima di raggiungere il codice target è una chiamata a IoRemoveCompletion che deve restituire 0 (STATUS_SUCCESS ).

Questa funzione si bloccherà finché:

  • Un record di completamento diventa disponibile per il parametro IoCompletionObject .
  • Scade il timeout, che viene passato come parametro della funzione

Controlliamo il valore di timeout tramite la nostra struttura, ma semplicemente impostare un timeout pari a 0 non è sufficiente affinché la funzione restituisca un risultato positivo. Affinché questa funzione venga restituita senza errori, deve essere disponibile almeno un record di completamento. Dopo alcune ricerche, abbiamo trovato la funzione non documentata NtSetIoCompletion, che incrementa manualmente il contatore delle I/O pendenti su un IoCompletionObject . Chiamare questa funzione sul IoCompletionObject creato in precedenza assicura che la chiamata a IoRemoveCompletion restituisca STATUS_SUCCESS .

Schermata realizzata per il post sul blog

Figura 16: Controllo in afd!AfdNotifyRemoveIoCompletion e ritorno a nt!IoRemoveIoCompletion

Attivazione di una scrittura arbitraria in posizione scelta

Ora che possiamo raggiungere il codice vulnerabile, possiamo riempire il campo appropriato nella nostra struttura con un indirizzo arbitrario a cui scrivere. Il valore che scriviamo all'indirizzo proviene da un intero il cui puntatore viene passato alla chiamata a IoRemoveIoCompletionIoRemoveIoCompletion  imposta il valore di questo intero al valore di ritorno di una chiamata a KeRemoveQueueEx .

Schermata realizzata per il post sul blog

Figura 17: Valore restituito da nt!KeRemoveQueueEx

Schermata realizzata per il post sul blog

Figura 18: Uso del valore restituito da nt!KeRemoveQueueEx

Nel nostro proof of concept, questo valore di scrittura è sempre uguale a 0x1 . Abbiamo ipotizzato che il valore di ritorno di KeRemoveQueueEx rappresenti il numero di elementi rimossi dalla coda, ma non abbiamo approfondito. A questo punto avevamo la primitiva necessaria e abbiamo proseguito con il completamento della catena di exploit. Successivamente abbiamo confermato che questa ipotesi era corretta e il valore di scrittura può essere incrementato arbitrariamente con ulteriori chiamate a NtSetIoCompletion sul IoCompletionObject .

LPE con IORING

Avendo la possibilità di scrivere un valore fisso (0x1) a un indirizzo arbitrario del kernel, abbiamo trasformato questa condizione in una completa capacità di lettura/scrittura arbitraria nel kernel. Poiché questa vulnerabilità interessa le ultime versioni di Windows 11 (22H2), abbiamo scelto di utilizzare una corruzione dell'oggetto Windows I/O ring per creare la nostra primitiva. Yarden Shafir ha scritto diversi eccellenti articoli sui Windows I/O ring e ha inoltre sviluppato e divulgato la primitiva che abbiamo utilizzato nella nostra catena di exploit. Per quanto ne sappiamo, questa è la prima volta che questa primitiva viene impiegata in un exploit pubblico.

Quando un I/O ring viene inizializzato da un utente, vengono create due strutture separate, una nello spazio utente e una nello spazio kernel. Queste strutture sono mostrate di seguito.

L'oggetto nel kernel corrisponde a nt!_IORING_OBJECT  ed è mostrato qui sotto.

Schermata realizzata per il post sul blog

Figura 19: Inizializzazione nt!_IORING_OBJECT

Si noti che l'oggetto nel kernel ha due campi, RegBuffersCount  e RegBuffers che vengono azzerati al momento dell'inizializzazione. Il conteggio indica il numero di operazioni di I/O che possono essere accodate per l'I/O ring. L'altro parametro è un puntatore a un elenco delle operazioni attualmente in coda.

Dal lato dello spazio utente, quando si chiama kernelbase!CreateIoRing si ottiene un handle I/O ring in caso di successo. Questo handle è un puntatore a una struttura non documentata (HIORING). La nostra definizione di questa struttura è stata ottenuta dalla ricerca condotta da Yarden Shafir.

typedef struct _HIORING {

    HANDLE handle;

    NT_IORING_INFO Info;

    ULONG IoRingKernelAcceptedVersion;

    PVOID RegBufferArray;

    ULONG BufferArraySize;

    PVOID Unknown;

    ULONG FileHandlesCount;

    ULONG SubQueueHead;

    ULONG SubQueueTail;

};

Se una vulnerabilità, come quella trattata in questo post sul blog, consente di aggiornare i campi RegBuffersCount  e RegBuffers  , allora è possibile utilizzare le API standard dell'I/O ring per leggere e scrivere memoria del kernel.

Come abbiamo visto in precedenza, siamo in grado di utilizzare la vulnerabilità per scrivere 0x1 in qualsiasi indirizzo del kernel che desideriamo. Per impostare la primitiva dell'I/O ring possiamo semplicemente attivare la vulnerabilità due volte.

Nel primo trigger impostiamo RegBufferCount  a 0x1 .

Schermata realizzata con nt!_IORING_OBJECT che attiva il bug per la prima volta

Figura 20: nt!_IORING_OBJECT attiva per la prima volta il bug

E nel secondo trigger impostiamo RegBuffers a un indirizzo che possiamo allocare nello spazio utente (come 0x0000000100000000).

Schermata realizzata con nt!_IORING_OBJECT che attiva il bug per la seconda volta

Figura 21: nt! _IORING_OBJECT attiva per la seconda volta il bug

Non resta che accodare le operazioni di I/O scrivendo puntatori a strutturent!_IOP_MC_BUFFER_ENTRY  contraffatte all'indirizzo nello spazio utente (0x100000000 ). Il numero di voci dovrebbe essere uguale a RegBuffersCount . Questo processo è evidenziato nel diagramma seguente.

Diagramma realizzato per il post sul blog

Figura 22: Configurazione dello spazio utente per la primitiva di lettura/scrittura (R/W) del kernel tramite I/O ring

Uno di queste strutture nt!_IOP_MC_BUFFER_ENTRY  è mostrata nella schermata qui sotto. Si noti che la destinazione dell'operazione è un indirizzo kernel (0xfffff8052831da20 ) e che la dimensione dell'operazione, in questo caso, è 0x8  byte. Dalla struttura non è possibile capire se si tratta di un'operazione di lettura o di scrittura. La direzione dell'operazione dipende da quale API è stata utilizzata per mettere in coda la richiesta di I/O. L'uso di kernelbase!BuildIoRingReadFile risulta in una scrittura arbitraria nel kernel, mentre kernelbase!BuildIoRingWriteFile  comporta una lettura arbitraria dal kernel.

Schermata realizzata per il post sul blog

Figura 23: Esempio di operazione contraffatta su I/O ring

Per eseguire una scrittura arbitraria, un'operazione di I/O è incaricata di leggere i dati da un handle file e scriverli in un indirizzo del kernel.

Diagramma realizzato per il post sul blog

Figura 24: Scrittura arbitraria tramite I/O ring

Al contrario, per eseguire una lettura arbitraria, un'operazione di I/O viene incaricata di leggere dati da un indirizzo del kernel e scrivere tali dati in un handle di file.

Diagramma realizzato con lettura arbitraria tramite I/O ring

Figura 25: Lettura arbitraria tramite I/O ring

Demo

Con la primitiva configurata, non resta che utilizzare alcune tecniche standard di post-sfruttamento del kernel per ottenere il token di un processo elevato come System (PID 4) e sovrascrivere il token di un altro processo.

Sfruttamento in natura

Dopo il rilascio pubblico del nostro codice exploit, Xiaoliang Liu (@flame36987044) di 360 Icesword Lab ha annunciato pubblicamente per la prima volta di aver scoperto un campione che sfrutta questa vulnerabilità in natura (ITW) all'inizio di quest'anno. La tecnica utilizzata dal campione ITW era diversa dalla nostra. L'aggressore attiva la vulnerabilità usando la corrispondente funzione API di Winsock, ProcessSocketNotifications , invece di chiamare direttamente il driver afd.sys , come nel nostro exploit.

La dichiarazione ufficiale di 360 Icesword Lab è la seguente:

"360 IceSword Lab si concentra sul rilevamento e sulla difesa APT. Basandoci sul nostro sistema radar per vulnerabilità zero-day, abbiamo scoperto un campione di exploit di CVE-2023-21768 in natura a gennaio di quest'anno, che differisce dagli exploit annunciati da @chompie1337 e @FuzzySec in quanto sfrutta meccanismi di sistema e caratteristiche della vulnerabilità. L'exploit è correlato a NtSetIoCompletion e ProcessSocketNotifications , ProcessSocketNotifications rileva il numero di volte in cui NtSetIoCompletion viene chiamato, e noi sfruttiamo questo dato per modificare il conteggio dei privilegi."

Conclusione e riflessioni finali

Potresti notare che in alcune parti del reverse engineering la nostra analisi è superficiale. A volte è utile osservare solo alcuni cambiamenti di stato rilevanti e trattare alcune parti del programma come una black box, per evitare di perdersi in un tunnel irrilevante. Questo ci ha permesso di utilizzare rapidamente un exploit, anche se massimizzare la velocità di completamento non era il nostro obiettivo.

Inoltre, abbiamo condotto una revisione delle differenze delle patch su tutte le vulnerabilità segnalate inafd.sys indicate come "Exploitation More Likely". La nostra revisione ha rivelato che tutte le vulnerabilità tranne due erano il risultato di una validazione impropria dei puntatori passati dalla modalità utente. Questo dimostra che avere una conoscenza storica delle vulnerabilità passate, in particolare all'interno di un bersaglio specifico, può essere fruttuoso per individuare nuove vulnerabilità. Quando la base di codice viene ampliata, gli stessi errori tendono a ripetersi. Ricorda, nuovo codice C == nuovi bug 😀. Come dimostrato dalla scoperta dello sfruttamento in natura della vulnerabilità sopra menzionata, si può affermare con certezza che gli aggressori stanno monitorando attentamente anche le nuove aggiunte alla base di codice.

La mancanza di supporto per Supervisor Mode Access Protection (SMAP) nel kernel di Windows ci lascia numerose possibilità per costruire nuove primitive di exploit solo dati. Queste primitive non sono realizzabili in altri sistemi operativi che supportano SMAP. Ad esempio, consideriamo la CVE-2021-41073, una vulnerabilità nell'implementazione dei buffer pre-registrati di I/O ring in Linux (la stessa funzionalità che sfruttiamo in Windows per una primitiva di lettura/scrittura). Questa vulnerabilità può permettere di sovrascrivere un puntatore kernel per un buffer registrato, ma non può essere usata per costruire una primitiva R/W arbitraria perché se il puntatore viene sostituito con un puntatore utente e il kernel cerca di leggerlo o scrivervi, il sistema si bloccherà.

Nonostante i migliori sforzi di Microsoft per eliminare le amate primitive di exploit, è inevitabile che ne vengano scoperte di nuove che ne prendano il posto. Siamo riusciti a utilizzare l'ultima versione di Windows 11 22H2 senza incontrare alcuna mitigazione o vincolo dovuto a caratteristiche di sicurezza basate sulla virtualizzazione come HVCI.

Mixture of Experts | 12 dicembre, episodio 85

Decoding AI: Weekly News Roundup

Unisciti al nostro gruppo di livello mondiale di ingegneri, ricercatori, leader di prodotto e molti altri mentre si fanno strada nell'enorme quantità di informazioni sull'AI per darti le ultime notizie e gli ultimi insight sull'argomento.