
Sfruttamento remoto del kernel tramite IPv6
Sfruttare da remoto il kernel tramite IPv6
CVE-2024-38063 - Sfruttare da remoto il kernel tramite IPv6 Marcus Hutchins
Dall'ultimo aggiornamento di Windows rilasciato il 13 agosto, mi sono immerso a fondo in tcpip.sys (il driver del kernel responsabile della gestione dei pacchetti TCP/IP). Una vulnerabilità con un punteggio CVSS di 9.8 nella parte più facilmente raggiungibile del kernel di Windows era qualcosa a cui non potevo semplicemente rinunciare. Non avevo mai approfondito IPv6 (o i driver che lo analizzano), quindi sapevo che cercare di fare reverse engineering di questa vulnerabilità sarebbe stato estremamente impegnativo, ma una buona esperienza di apprendimento.
Per la maggior parte, tcpip.sys è largamente non documentato. Sono riuscito a trovare un paio di write-up di exploit per bug più vecchi: qui, qui, e qui, ma poco altro. Quando il primo risultato della mia ricerca su Google in inglese è scritto in cinese, capisco immediatamente di essere ben al di là delle mie competenze e di andare incontro a un brutto periodo, ma dobbiamo imparare. Nonostante Google Traduttore faccia un lavoro mediocre, il post ha fornito alcune informazioni incredibilmente dettagliate su come funziona la frammentazione IPv6 e mi ha dato un buon vantaggio.
Più tardi, googlando alcuni nomi di funzioni, mi sono imbattuto in un'altra analisi della stessa vulnerabilità del 2021, scritta da Axel Souchet (noto come 0vercl0k), che approfondiva ancora di più gli internals di tcpip.sys e mi dava abbastanza informazioni per definire diverse strutture non documentate. La più semplice analisi di patch di sempre
Di solito, anche solo fare reverse engineering della patch per capire quale modifica al codice corrisponde alla vulnerabilità può richiedere giorni o addirittura settimane, ma in questo caso è stato immediato. È stato così facile, infatti, che più persone sui social media mi hanno detto che mi sbagliavo e che il bug era da un'altra parte. Ho davvero ascoltato loro e poi sprecato un'intera giornata a fare reverse engineering del driver sbagliato? Forse non lo sapremo mai.
C'era esattamente una modifica nell'intero file del driver, che si è rivelata essere proprio il bug.
Una panoramica bindiff di tcpip.sys prima e dopo l'installazione della patch.
Solo una singola funzione nell'intero driver è stata modificata. Di solito, potrei passare un'intera giornata a esaminare 20 o più modifiche di funzione solo per capire quale sia quella su cui concentrarmi, ma non questa volta.
Ipv6pProcessOptions() prima della patch.
Ipv6pProcessOptions() dopo la patch.
Non solo è stata modificata una singola funzione, ma una singola riga di codice.
La funzione dal nome estremamente lungo Feature_2660322619__private_IsEnabledDeviceUsage_3() è qualcosa che Microsoft a volte aggiunge per consentire rollback parziali delle patch. La chiamata verifica la presenza di un flag globale o di un'impostazione di registro che, se impostata, farà sì che la funzione restituisca false, con conseguente esecuzione del codice originale invece della versione patchata.
Il motivo per cui Microsoft fa questo è che le patch di sicurezza a volte rompono involontariamente le cose, quindi questa impostazione consente a un amministratore di rimuovere la patch da una singola vulnerabilità, senza disinstallare l'intero aggiornamento cumulativo mensile e indebolire drasticamente la sicurezza del sistema.
Tenendo conto di ciò, è chiaro che questa patch si limita a sostituire una chiamata a IppSendErrorList() con IppSendError(), dandoci un indizio che il problema riguarda una sorta di lista. Il diff di patch più facile di sempre (o così pensavo). Vulnerabilità opzionali, sfruttamento obbligatorio
Fare reverse engineering della patch per trovare il codice alterato è solo metà della sfida (o in questo caso meno dello 0,1%). Il resto del processo consiste nel fare reverse engineering di abbastanza della base di codice per capire cosa sta succedendo, capire che tipo di vulnerabilità è stata patchata, come costruire una richiesta per raggiungere il codice bersaglio e quale stato porta a una condizione sfruttabile.
La prima parte è abbastanza semplice. La modifica è in Ipv6pProcessOptions(), il che ci dice che si tratta di IPv6 e coinvolge l'elaborazione delle opzioni. Quindi, una rapida chiamata all'RFC ci dice esattamente cos'è un'opzione IPv6 e dove trovarne una.
Il layout dell'header delle opzioni di destinazione da Wikipedia.
Ok, bello. Quello che stiamo cercando sembra essere l'header delle opzioni di destinazione, che si trova direttamente dopo l'header IPv6 principale. Usiamo la libreria Python 'scapy' per costruire un pacchetto IPv6 di test.
Nota: Per mitigare gli attacchi DDoS che utilizzano indirizzi IP falsificati, Windows limita la capacità di costruire pacchetti IP grezzi. Per questo motivo, ho scelto di usare Linux per sviluppare il mio proof-of-concept. Mentre Linux permette agli utenti di costruire e inviare pacchetti grezzi di livello 2 e livello 3, richiede che lo script Python venga eseguito come root.
import sys import struct from scapy.all import *
def send_ipv6_option_packet(dest_ip): ethernet_header = Ether() ip_header = IPv6(dst=dest_ip) options_header = IPv6ExtHdrDestOpt() sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2: print('Use: python3 script.py <target_ipv6_address>') exit(-1)
send_ipv6_option_packet(sys.argv[1])
Dopo aver impostato un breakpoint su tcpip!Ipv6pProcessOptions, poi eseguito lo script, era chiaro che tutto ciò che serviva per raggiungere la funzione vulnerabile era inviare un pacchetto IPv6 con una struttura di opzioni vuota. Ho poi provato ad aggiungere alcune opzioni non valide alla struttura per vedere se potevo raggiungere la chiamata a IppSendErrorList().
Una breve revisione del codice ha indicato che quasi qualsiasi formattazione di opzione non valida poteva innescare la chiamata a IppSendErrorList. Quindi, ho deciso di usare l'opzione Jumbo Packet con una lunghezza non valida (inferiore a 65535 byte).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
Quindi, cosa fa effettivamente IppSendErrorList()? Beh, il codice è piuttosto semplice.
L'intera funzione IppSendErrorList.
Il codice itera una lista concatenata e chiama IppSendError() su ogni elemento della lista. Ancora una volta, le stelle si sono allineate e le cose sono state facili finora. Se IppSendErrorList chiama semplicemente IppSendError per ogni elemento di una lista, e la patch sostituisce la chiamata a IppSendErrorList con IppSendError, allora il problema si verifica quando IppSendError viene chiamato su un elemento della lista diverso dal primo.
Quindi, di cosa è una lista questa, e come se ne crea una? Sta facendo una lista, la sta controllando... 52.567 volte
È qui che le cose sono passate da ovvie ad anormalmente difficili, anche se penso che gran parte di questo sia stato dovuto al fatto che una delle mie due cellule cerebrali disponibili era occupata a combattere una brutta infezione da covid. Ho perso un paio di giorni a capire parti del codice, addormentarmi, poi dimenticare ciò che avevo capito. L'intero processo ha richiesto più di una settimana di reverse engineering di parti di tcpip.sys per capire cosa stesse succedendo. Ma il blog post di Axel è stato estremamente utile.
Guardando le funzioni e la struttura che Axel ha fatto reverse engineering, e a quali altre funzioni vengono passate, è chiaro che l'unico argomento passato a Ipv6pProcessOptions() è la stessa struttura packet_t definita nell'articolo. Essenzialmente, il puntatore passato a Ipv6pProcessOptions, e iterato da IppSendErrorList, è una lista concatenata di pacchetti.
Quindi, ho impostato un breakpoint su Ipv6pProcessOptions() e ho ispezionato la lista.
L'elemento list->Next è NULL.
Ogni volta che il mio breakpoint veniva colpito, la lista conteneva un solo pacchetto. Ho passato molto più tempo di quanto vorrei ammettere cercando di capire perché e come ottenere che la mia lista fosse effettivamente una lista. Il mio primo pensiero è stato la frammentazione IPv6: IPv6 permette ai mittenti di dividere pacchetti grandi in pacchetti più piccoli separati, che avrebbe senso tenere insieme in una lista.
Dopo un esteso reverse engineering, ho confermato che le mie supposizioni erano corrette, anche se la lista dei frammenti non è correlata a quella con cui abbiamo a che fare qui.
In realtà ho finito per trovare la risposta completamente per caso. A volte, la lista si popolava, ma la ragione non era chiara. Dopo molti giri a vuoto, ho realizzato che quando il mio breakpoint del kernel viene attivato, mette in pausa l'intero kernel, causando l'accumulo di pacchetti da parte della scheda di rete. Quando il kernel riprende, questi pacchetti vengono passati lungo lo stack a tcpip.sys in una bella lista ordinata. Questo accadeva solo se i pacchetti venivano inviati mentre il kernel era in pausa, ma non elaborati prima che il breakpoint successivo venisse colpito.
Questo comportamento è probabilmente un'ottimizzazione delle prestazioni, dove a basso throughput, il kernel elabora i pacchetti individualmente, ma a volumi più alti, i pacchetti vengono organizzati in liste ed elaborati in batch. Molto probabilmente le liste sono separate in base a fattori come protocollo e indirizzo sorgente per velocizzare l'elaborazione, quindi la nostra lista dovrebbe contenere solo i pacchetti IPv6 che abbiamo inviato. Yo dawg, ho sentito che ti piace il DoS
Ora che sappiamo che i pacchetti vengono coalescenti in liste durante l'alto throughput, è chiaro quale sarebbe l'opzione più semplice. Il nostro PoC di DoS dovrà ironicamente usare un DoS per innescare la condizione di DoS. Se inondiamo il sistema con raffiche di pacchetti IPv6, dovremmo essere in grado di ottenere una bella lista grande passata a IppSendErrorList().
All'inizio, non importa quanti pacchetti inviassi, riuscivo ancora ad ottenere la lista n > 1 solo se mettevo in pausa il kernel. Ma... dato che stiamo usando Python (dolorosamente lento), in una VM (doppiamente dolorosamente lento), probabilmente avremo bisogno di modificare alcune impostazioni. Per contrastare la VM-ception in corso sul mio sistema di attacco, ho deciso di riconfigurare semplicemente la VM bersaglio per utilizzare solo un singolo core della CPU.
Bene! La lista dei pacchetti è ora una lista contenente molte voci!
Quindi, a quanto pare una VM dentro una VM non è l'opzione migliore per il DoS, chi l'avrebbe mai detto? Ma alla fine ce l'abbiamo fatta. Ora, dobbiamo solo capire cosa fa IppSendError() e in quale parte risiede il problema. Ancora reverse engineering... ancora... per sempre...
Dopo un po' di esteso reverse engineering, è diventato molto più chiaro cosa fa IppSendError. In circostanze normali, si limita a disabilitare il pacchetto impostando net_buffer_list->Status a 0xC000021B (STATUS_DATA_NOT_ACCEPTED). Poi, trasmette un errore ICMP contenente informazioni sul pacchetto erroneo al mittente.
Due parti rilevanti di IppSendError.
Il mio primo passo è stato vedere se ci fossero funzioni in tcpip.sys che ignorano il valore di net_buffer_list->Status. Questo porterebbe il driver a elaborare pacchetti in stati non definiti o inaspettati, portando probabilmente a una condizione di exploit.
Il ciclo principale responsabile dell'elaborazione dei pacchetti.
Poiché il ciclo responsabile della chiamata a tutte le funzioni di parsing è incapsulato in un controllo degli errori (il che significa che non possiamo andare da nessuna parte una volta impostato il codice di errore), ho pensato che questo fosse il tunnel sbagliato da seguire. Invece, ho deciso di tornare a IppSendError e vedere se ci sono percorsi di codice che modificano lo stato del pacchetto prima di impostare il codice di errore, il che potrebbe portare a una race condition.
Dopo molto altro reverse engineering, ho trovato il seguente codice vicino al fondo di IppSendError.
Un percorso di codice in IppSendError che imposta packet_size a zero.
Quando IppSendErrorList, e quindi IppSendError, viene chiamata con l'argomento always_send_icmp impostato a true, sembra che tenti di inviare l'errore ICMP a ogni pacchetto nella lista.
Poi, per ragioni probabilmente note solo a Dio, raggiunge un blocco di codice in cui il campo packet->packet_size viene impostato a zero.
Per impostare always_send_icmp a true, tutto ciò che dobbiamo fare è causare un errore specifico nell'elaborazione dell'header delle opzioni impostando il valore 'Option Type' a qualsiasi numero maggiore di 0x80.
def build_malicious_option(next_header, header_length, option_type, option_length): dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0) sendp(packet)
Ma, impostare packet_size a zero non dovrebbe rompere il parser?
Un frammento del ciclo principale responsabile dell'elaborazione dei pacchetti.
Il gestore dei pacchetti chiama semplicemente una funzione VTable basata sul valore di packet->next_header, che rimane invariato da quando è stato impostato durante la pre-analisi. Questo permette all'elaborazione del pacchetto di continuare e ci dà persino il controllo su quale elaborazione avviene.
Poiché il valore di packet->next_header viene ottenuto dal campo 'Next Header' del pacchetto IPv6, possiamo impostarlo a qualsiasi valore valido di header IPv6, e il ciclo chiamerà il parser corrispondente. Questo ci offre una grande superficie di attacco potenziale.
Il formato del pacchetto IPv6.
Non resta che trovare una parte raggiungibile del parser IPv6 che faccia qualcosa di stupido con il campo packet_size. Ritorno alla frammentazione
Il primo posto in cui ho deciso di guardare è il parser dei frammenti IPv6, perché è lì che si trovava la vecchia vulnerabilità CVE-2021-24086, quindi sembrava un buon posto per trovare altro codice strambo.
Eh... è così vicino, ma anche così lontano.
Abbiamo una vulnerabilità qui, ma non è un RCE.
Essenzialmente, sulla maggior parte delle CPU, i registri sono circolari. Se incrementi un registro oltre il suo valore massimo possibile, torna indietro a zero. Allo stesso modo, se lo decrementi al di sotto del suo valore minimo possibile, torna al valore massimo possibile. Questi sono chiamati rispettivamente integer overflow e integer underflow. Questo comportamento è leggermente diverso per gli interi con segno, ma non abbiamo a che fare con quelli qui.
La prima riga, fragment_size = LOWORD(packet->packet_size) - 0x30, consiste nel seguente codice ASM:
Il codice ASM che calcola la dimensione del frammento.
AX è i 16 bit bassi del registro EAX. Sebbene il registro EAX sia a 32 bit, AX opera come se fosse un proprio registro a 16 bit, quindi eventuali overflow o underflow sono confinati ad AX e non influenzeranno il resto del registro EAX. Questo è incredibilmente comodo perché un underflow sul registro EAX risulterebbe in un valore di 4 miliardi, che porterebbe a un tentativo di allocare 4 GB di memoria, che probabilmente fallirebbe.
Poiché il valore di packet->packet_size è zero, questo codice imposta ax a zero, poi sottrae 0x30 da esso.
In condizioni normali, l'header del pacchetto è di 0x30 byte, quindi packet_size - 0x30 è la dimensione dei dati del frammento.
Nel nostro caso, packet->packet_size è 0, quindi sottrarre anche solo 1 da esso farà sì che il registro torni al valore massimo possibile di intero a 16 bit (0xFFFF). Poiché stiamo sottraendo 0x30, il valore di AX andrà in underflow e diventerà MAX_VALUE - 0x2F, o 0xFFD0, che è 65.488.
Sfortunatamente, poiché lo stesso calcolo viene utilizzato sia per l'allocazione di memoria che per la copia dei dati, non otteniamo un buffer overflow. Credo che anche RtlCopyMdlToBuffer() esegua il bounds checking sul buffer sorgente, quindi non otteniamo nemmeno una lettura fuori dai limiti. Tuttavia, non usciamo a mani vuote.
Poiché ExAllocatePoolWithTagPriority() non azzera la memoria allocata, e RtlCopyMdlToBuffer() copia solo la quantità effettiva di dati disponibili, otteniamo circa 65kb di memoria del kernel non inizializzata. Poiché gli indirizzi di memoria vengono riciclati dopo la deallocazione, è probabile che il buffer sia riempito con ciò che era precedentemente memorizzato all'indirizzo prima della riallocazione. Se possiamo usare la frammentazione per costruire un pacchetto che ci viene restituito, come una richiesta Echo ICMP, potremmo potenzialmente far trapelare memoria casuale del kernel, portando a un bypass di ASLR.
Inoltre, il codice imposta anche reassembly->fragment_size all'intero a 16 bit sottofluito (65.488), quindi ora abbiamo due variabili separate che potremmo potenzialmente usare per causare un buffer overflow. Battuto, ma non sconfitto
Sfortunatamente (o fortunatamente, dato che probabilmente mi ha risparmiato un sacco di tempo), qualcuno mi ha battuto sul tempo. Prima che potessi trovare un posto dove utilizzare uno degli interi sottofluiti per innescare un buffer overflow, @ynwarcs ha trovato la risposta e ha pubblicato un PoC. Questo risolve l'ultimo pezzo del mio puzzle.
La soluzione (o almeno una di esse) è Ipv6pReassemblyTimeout(). Mentre non possiamo causare un overflow nella gestione iniziale del frammento, a quanto pare possiamo farlo durante la pulizia.
I frammenti IPv6 rimarranno in memoria fino a quando non si verifica una delle tre condizioni:
Sbagliamo la frammentazione abbastanza gravemente che il sistema ci dice di smettere.
Inviamo un frammento con il campo 'More' impostato a 0, che indica che questo è l'ultimo frammento, e il sistema inizierà il riassemblaggio.
Non inviamo l'ultimo frammento prima della scadenza del timeout (60 secondi), e il sistema rilascia i frammenti.
Ipv6pReassemblyTimeout() viene chiamato nella condizione 3, quindi esaminiamo come può essere sfruttato.
Questo è esattamente ciò di cui abbiamo bisogno!
In precedenza, il nostro problema era che il codice utilizzava lo stesso identico calcolo sia per l'allocazione di memoria che per l'operazione di copia. Questo codice, d'altra parte, non lo fa. Diamo un'occhiata più approfondita all'ASM per vedere come è sfruttabile.
Il codice assembly responsabile del calcolo della dimensione di allocazione.
Come si vede qui, la prima parte del calcolo (fragment_list->net_buffer_length + reassembly->packet_length + 8) viene eseguita utilizzando il registro DX a 16 bit.
Se ricordate da prima, abbiamo sottofluito reassembly->packet_length a 0xFFD0. Quindi il registro DX, dopo aver aggiunto gli 8 byte, è 0xFFD8. Se fragment_list->net_buffer_length è maggiore di 0x27 (39 byte), DX andrà in overflow e si azzererà.
fragment_list->net_buffer_length dovrebbe essere circa 0x38 byte, quindi risulterà in un overflow del registro DX a 8. Dopo aver aggiunto i 0x28 byte, otterremo un'allocazione di memoria di soli 48 byte.
Poiché le successive chiamate a memmove() utilizzano semplicemente il valore immutato di reassembly->packet_length per la dimensione, ciò comporterà la copia di 65.488 byte da reassembly->payload a un buffer di 30 byte. Un grande bonus aggiuntivo è che gran parte dei dati copiati proviene dal payload del frammento, che controlliamo, e può essere dati arbitrari di qualsiasi formato, quindi otteniamo un bel buffer overflow basato su pool del kernel abbastanza controllabile.
Per avere una possibilità di innescare la vulnerabilità, dobbiamo avere uno o più pacchetti frammentati situati dopo il pacchetto di opzioni malformato nella lista concatenata nel momento in cui IppSendErrorList viene chiamata. Tuttavia, dai miei test, questo non sembra garantire lo sfruttamento. Credo che ci siano anche altre condizioni che devono essere soddisfatte. Sospetto, ma non ho confermato, che il codice di sincronizzazione in IppSendError significhi che dobbiamo anche vincere una race condition.