
Tutorial su CVE-2022-37969 incentrato sulla metodologia di sfruttamento del Kernel, non sulle cause interne della CVE.
Questo documento è stato creato per chiarire aspetti generali riguardanti lo sfruttamento di Windows. Spiega concetti di base applicati alla CVE-2022-37969. Il risultato finale è un PoC funzionante. Non chiarisce ogni aspetto della CVE, ma fornisce pezzi di codice riutilizzabili e spiega meccanismi che si possono trovare in molti exploit generali.
L'utente target è un reverse engineer principiante, uno sviluppatore di exploit che cerca un codice sorgente di proof of concept funzionante per testare e comprendere le nozioni fondamentali sui Windows Internals. Fornisce un punto di riferimento per approfondire.
Requisiti: conoscenze base di kernel debugging, Reverse Engineering di base, nozioni fondamentali sui Windows internals, capacità di programmazione in c/c++
Un programma è un pezzo di codice che gira su una macchina. Generalmente un programma riceve dati (input), esegue calcoli usando gli input e genera dati (output). La maggior parte dei programmi è scritta da esseri umani e quindi contiene bug. Un bug è generato da un codice sorgente che non è stato scritto correttamente (il programmatore voleva fare qualcosa con l'input, ma il codice risultante era diverso dall'intento originale). La maggior parte dei bug viene corretta prima del rilascio del prodotto, ma alcuni rimangono. Questo accade perché esistono diversi tipi di bug, alcuni più difficili da individuare di altri.
Windows è un programma per computer, è stato scritto da esseri umani e quindi contiene bug. Perché è importante? Perché i sistemi Windows possono eseguire programmi che trattano dati sensibili, come conti bancari, database sanitari e altri. Alcuni bug possono essere usati per ottenere illegalmente l'accesso a dati riservati (questo è un buon caso d'uso per un exploit).
Esistono diversi tipi di bug, alcuni utili e altri no. Generalmente i bug sono generati dagli input del programma che, in combinazione con le righe di codice scritto male, generano un output o un comportamento malformato del programma. Trovare quell'input è il lavoro dello specialista di sicurezza (o hacker). Il passo successivo è valutare il comportamento/output malformato risultante e rispondere alla domanda: "Può essere usato in modo utile?". È qui che i bug vengono classificati in diverse categorie. Per esempio, un bug può generare un comportamento che corrompe alcune strutture dati e causa il riavvio del computer target. La sua utilità è limitata. Un bug può far sì che l'input venga scritto in una zona di memoria che controlla i permessi di accesso a file riservati. Questo tipo di bug è più utile.
Quindi, dall'insieme di tutti i bug possibili, l'hacker cerca il sottoinsieme più utile al proprio scopo. In generale, il problema è: "Posso dare al programma target un input appositamente creato in modo da non rompere il sistema ma da poter elevare il mio livello di accesso e trarne vantaggio?"
Dopo questa introduzione non tecnica, l'obiettivo del tutorial può essere formulato così: Possiamo trovare un programma Windows che accetta input malformati e che, a causa di un errore del codice degli sviluppatori, possa elevare illegalmente i nostri permessi da utente normale ad amministratore?
Programma target: Windows CLFS (Common Log File System Driver)
Nome exploit: CVE-2022-37969
Tipo: Elevazione locale dei privilegi
DOWNLOAD ISO VULNERABILE: Scarica qui
Lo spazio degli indirizzi di Windows è approssimativamente diviso tra user-space (in cui girano i programmi generici) e kernel-space (in cui girano il sistema operativo stesso e i software dei componenti hardware --> driver). Un utente normale non deve poter accedere al kernel-space, ma esistono meccanismi con cui i programmi utente possono accedere a parti del codice del kernel (system call, procedure dei driver). Perché ci serve l'accesso? Per interagire con il sistema operativo in modo sicuro e controllato, come previsto dai progettisti del sistema operativo.
Alcuni driver usano input forniti dall'utente per operare su strutture dati del kernel. Se l'input genera un bug, il kernel potrebbe essere corrotto. Un caso è il Common Log File System Driver. Usando un input speciale possiamo forzare il driver ad alterare le strutture dati del kernel che contengono il livello di accesso privilegiato dell'utente e sovrascrivere utente normale con amministratore.
Cosa bisogna modificare per elevare il privilegio ad amministratore?
Iniziamo tenendo presente l'obiettivo finale. Windows memorizza all'interno di una struttura dati del kernel chiamata _EPROCESS le informazioni per ogni processo in esecuzione sul sistema. Esempio di _Eprocess
Un campo importante è struct _EX_FAST_REF Token. Questa è un'altra struttura dati che punta a dati che fanno riferimento al livello di privilegio del rispettivo processo. Nell'immagine seguente, il processo System ha un token di sistema e il processo Explorer ha un token utente normale.

Quindi, per elevare il privilegio di Explorer.exe dovremmo copiare il valore da _EPROCESS-->Token di System a _EPROCESS-->Token di Explorer. Realizzeremo qualcosa di simile copiando il Token di System nel Token del nostro programma e lanciando un Prompt dei comandi dal processo elevato (i processi figli ereditano il token del processo padre).
Per completare queste azioni ci servono meccanismi per:
Introduzione: la natura di Windows nel corso degli anni: Come con la scoperta di nuove vulnerabilità, Windows ha avuto bisogno di patch per mitigarle. Inoltre, con l'emergere di nuove tecnologie, Windows ha avuto bisogno di aggiornamenti per rimanere competitivo. Un requisito cruciale era la compatibilità all'indietro con le versioni precedenti. E a volte la sicurezza è stata ottenuta tramite l'oscurità. Strutture dati e definizioni di funzioni sono state rimosse dai manuali, ma la funzionalità è rimasta. Grazie al reverse engineering, i ricercatori sono riusciti a usare queste funzionalità per vari scopi.
Per trovare l'indirizzo nel kernel di _EPROCESS useremo una funzione non documentata: NtQuerySystemInformation (vedi il link per i parametri). Usando il parametro SystemInformationClass possiamo specificare che tipo di informazioni vogliamo recuperare. Recupereremo informazioni generali sui processi specificando il valore SystemExtendedHandleInformation (#define SystemExtendedHandleInformation 0x40).
Un'avvertenza sull'uso di NtQuerySystemInformation è che non sappiamo in anticipo la lunghezza dei dati restituiti, ma NtQuerySystemInformation ha un meccanismo che aiuta. Se viene chiamata con un array di dimensione sbagliata per i dati richiesti, restituisce ERROR e la dimensione corretta che sarebbe dovuta essere richiesta. Questo può essere usato per leggere correttamente le informazioni sui processi nel modo seguente:
NtQuerySystemInformation con un parametro SystemInformationLength fittizioReturnLength restituitoNtQuerySystemInformation con il valore corretto di SystemInformationLength restituito in precedenzaLa struttura dati restituita è di tipo PSYSTEM_HANDLE_INFORMATION_EX. Questa è una struttura dati non documentata (vedi link) che porta a una struttura dati SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, che contiene nel campo Object l'indirizzo nel kernel della struttura dati _Eprocess per il processo corrispondente.
Quindi la logica sarebbe: iterare su tutti gli elementi di PSYSTEM_HANDLE_INFORMATION_EX, confrontare il campo UniqueProcessId di SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX con il PID del processo desiderato e selezionare il campo Object corrispondente per trovare il suo indirizzo _Eprocess nel kernel.
Un frammento di codice:

NtQuerySystemInformation è dichiarato come puntatore a funzione e il suo indirizzo viene ottenuto dinamicamente a runtime tramite loadlibrary e getprocaddress.
typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);ntdll=LoadLibrary(L"ntdll.dll");MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");Per leggere e scrivere il Token, dovremo fare affidamento su vulnerabilità all'interno di clfsw32.sys e NamedPipes
Questo è in qualche modo una scatola nera, descritta in dettaglio in altri articoli, ma verrà spiegata la conoscenza minima necessaria per questa funzionalità al fine di acquisire una comprensione di base del processo di exploit. Le pipe sono meccanismi di comunicazione tra processi. I processi possono passarsi informazioni usando le pipe. Le pipe sono rappresentate come strutture dati del kernel che hanno alcuni campi che possono essere valorizzati dallo spazio utente. Un esempio sono gli attributi della pipe (Pipe Attributes).
(Più avanti collegheremo questo alla vulnerabilità CLSF per ottenere lettura/scrittura arbitraria dallo spazio utente nello spazio del kernel)
Per ulteriori letture, consulta fengshui-spraying-big-kids-pool. Una spiegazione semplificata del meccanismo sarebbe:
Il kernel ha 2 modi di allocare memoria in base alla dimensione che vogliamo allocare: small-pool per oggetti <4KB+header e big-pool per oggetti >4KB+header . Le pagine big-pool sono importanti perché possono essere enumerate dallo spazio utente. Questo significa che un utente normale può trovare tutti gli indirizzi di inizio nel kernel che contengono una pagina Big-Pool.
Come? Ogni pagina Big-Pool ha un campo chiamato Tag (che può essere usato per ottenere informazioni sul tipo di dati memorizzati lì). Tutte le pagine Big-Pool del sistema possono essere enumerate usando NtQuerySystemInformation con SystemBigPoolInformation come valore del parametro SystemInformationClass. Poi, da tutte le pagine, possiamo filtrare per Tag e ottenere l'indirizzo delle pagine Big-Pool che ci interessano. Per esempio, CLFS usa pagine Big-Pool con tag 'Clfs'. Possiamo ottenere l'indirizzo di tutte le pagine nel kernel in cui sono allocati oggetti CLFS.

Tornando alle pipe, possiamo operare allo stesso modo: allocare una pipe abbastanza grande da usare il meccanismo Big-Pool, enumerare le pagine big-pool, cercare il Tag specifico della pipe e filtrare quelle pagine.
Così possiamo far trapelare nello spazio utente il punto in cui il kernel ha allocato le nostre pipe. E per quanto riguarda il controllo dei dati che entrano nel kernel e la loro lettura?
Per questo facciamo affidamento sugli attributi della pipe (Pipe Attributes). Proprio come il tag Big-Pool, l'attributo della pipe è un array che può contenere informazioni che descrivono la pipe (compilato dall'utente). Usando la funzione non documentata NtFsControlFile possiamo leggere e scrivere arbitrariamente nella struttura dati degli attributi della pipe. Essendo non documentata, viene fornito solo un proof of concept che imposta un vettore di PipeAttribute e poi lo legge. L'unica modifica consentita alle primitive di lettura/scrittura è controllare il contenuto del buffer di input/output e la loro dimensione.
Qui allochiamo una pipe abbastanza grande da usare le pagine big-pool (0x2000), impostiamo un buffer di input e output a un valore controllato. Attenzione: i primi 2 byte del valore di input DEVONO essere 0x5a 0x00 affinché funzioni.

Qui rileggiamo ciò che abbiamo scritto in precedenza nel kernel, modificando ancora una volta solo il buffer di output.

E il risultato:

Ecco il contenuto della pagina big-pool della pipe nel kernel: Interrogando le pagine big-pool abbiamo trovato l'inizio della struttura dati della pipe nel kernel. All'indirizzo pipe_begin+0x20 troviamo un puntatore al nostro buffer di input+0x2.

Quando chiamiamo la funzione di lettura degli attributi della pipe, il sistema operativo farà quanto segue:
Perché è utile? Immagina se possiamo sostituire il puntatore all'indirizzo pipe_begin+0x20 con la posizione del token di sicurezza del processo System e chiamare la lettura degli attributi della pipe. Ne faremmo trapelare il valore nello spazio utente. La sostituzione è realizzata tramite la vulnerabilità di CLFS.SYS.
Capire questa tecnica è fondamentale per comprendere l'exploit.
La maggior parte degli exploit non è di natura deterministica ma probabilistica. Anche se il codice che sfrutta la vulnerabilità è corretto, l'exploit potrebbe non funzionare. Portando il programma target in uno stato instabile, lo sviluppatore dell'exploit deve assicurarsi che dopo l'esecuzione dell'exploit il sistema non vada in crash. Immagina un programma che, quando viene sfruttato, dà la capacità di leggere da un indirizzo che dipende dal valore di una variabile all'interno del programma.
Es.:
Diciamo che target=0x1000000+ var_1&0xff+ var_2&0xff00.
L'hacker non può controllare target, var_1 o var_2.
Ma l'exploit dà la capacità di leggere dall'indirizzo target.
Potremmo leggere ovunque tra 0x1000000 e 0x100FFFF, cosa che in alcuni casi potrebbe essere utile o meno.
Es. 2:
Diciamo che un exploit dà la capacità di leggere un QWORD da un indirizzo che corrisponde a un certo pattern e di mettere il contenuto nel valore del proprio token di sicurezza. Diciamo che abbiamo precedentemente ottenuto il valore del token di sicurezza per System.exe. Come possiamo sfruttare l'exploit per elevare i privilegi?
read_addr=0x1000000+alfa&0xFFFF00
Non possiamo controllare il parametro alfa.
FUORI TEMA MA MOLTO IMPORTANTE: Il kernel può accedere allo spazio utente corrispondente al processo che sta eseguendo codice kernel in quel momento.
Da quale indirizzo possiamo leggere? Beh, 0x1000000, 0x1000100(alfa=1),0x1000200(alfa=2),....,0x1FFFF00(alfa=ffff00).
Per essere sicuri che l'exploit abbia successo, il programmatore dovrebbe fare quanto segue:
memory=virtualalloc(dest=0x1000000,size=0x1000000,....)for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;Questo è di fatto memory spraying. Manipolazione della memoria che segue uno schema specifico che copre tutti i possibili valori di un'espressione probabilistica risultante da un exploit.
Un requisito affinché questo funzioni nel nostro caso è che la memoria possa essere allocata all'indirizzo 0x1000000. Questa è una limitazione con cui dobbiamo fare i conti anche nel caso dell'exploit reale.
Dopo aver chiarito alcuni aspetti tecnici, ora serve una conoscenza di base di CLFS. Link Microsoft. CLFS è usato per i log delle applicazioni, i log dei database, le transazioni, ecc.
Gli exploit generalmente richiedono un certo layout di memoria per funzionare. La forma del layout è dettata dai valori delle variabili all'interno del programma nel momento esatto dello sfruttamento.
Questo tutorial non spiegherà in dettaglio il codice vulnerabile, né il formato del sistema di file di log. Offrirà una comprensione di base dei processi che generano l'exploit.
I file di log sono un tipo speciale di file che hanno un certo formato e con cui si può interagire tramite il driver CLSF e le API delle DLL. All'interno di questo sistema esiste anche il concetto di contenitori di log. I contenitori di log sono anch'essi file di log, ma sono collegati in memoria a un file di log principale (il file di log a cui vengono aggiunti).
Questa costruzione funziona nel modo seguente:
Questa operazione forza l'allocazione di nuovo spazio di memoria all'interno del file di log principale e modificherà diversi oggetti nel layout di memoria del file di log principale. La modifica del layout di memoria, concettualmente, sarebbe così:

Come per ogni struttura dati/file importante, prima di usarlo il driver CLFS esegue controlli di integrità sul formato del file di log:
Il secondo punto è il punto di partenza della vulnerabilità. L'attaccante è in grado di modificare un file di log creato in precedenza, ricalcolarne l'hash e modificare il campo hash per far superare al driver il test di integrità. Le modifiche riguardano le lunghezze degli header del file. Un insieme di valori accuratamente costruito fa superare un controllo di intervallo-lunghezza che altrimenti fallirebbe e genererebbe un errore. Il driver accetta quindi la lunghezza fasulla come valida e continua l'esecuzione del codice normalmente. La lunghezza fasulla viene usata per calcolare un offset all'interno del file in cui verrà scritto un indirizzo hardcoded. Questo dà all'attaccante la capacità di controllare l'indirizzo in cui avverrà la precedente operazione di scrittura.
Questa vulnerabilità deve essere concatenata con la lettura-scrittura nel kernel tramite pipe per completare l'exploit. Prova visiva:

Nella figura precedente vediamo la funzione AllocSymbol all'interno di CLFS.sys, responsabile del falso controllo di intervallo. Il controllo di lunghezza vulnerabile è quello che restituirebbe il codice di errore 0xC0000023. (BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68)) Il suo input alla condizione è in parte controllato dall'attaccante. È possibile iniettare un valore nella formula che fa superare il controllo anche se normalmente fallirebbe. Controllando v8 e v9 possiamo manipolare il valore di verità della condizione rendendolo FALSO, impedire la restituzione dell'errore e portare al calcolo arbitrario del valore della variabile v10, usando il valore di v9 controllato dall'attaccante.
V10 viene ulteriormente usato per una memset con 0 come valore da impostare. Quindi l'attaccante ottiene la capacità di azzerare arbitrariamente la memoria. Non è tutto. Prima del ritorno finale, *a3=v10. A3 è un parametro passato per indirizzo alla funzione AllocSymbol. Il chiamante di AllocSymbol riceve quindi il valore di V10 dopo il ritorno da AllocSymbol.
Il chiamante di AllocSymbol è FindSymbol.

All'interno di FindSymbol, V33 è il parametro chiamato a3 in AllocSymbol. Quindi v33 riceve il valore di v10. Poi il valore all'indirizzo di v33 viene impostato a una costante (0xc1fdf006) e ulteriormente preceduto dal valore 0x30.

Quindi l'attaccante ottiene la capacità di impostare una posizione di memoria arbitraria con una costante.
Perché sarebbe importante? Se possiamo sovrascrivere il valore predefinito di un puntatore a funzione con un indirizzo costante accessibile sia dallo spazio utente sia dallo spazio del kernel, e abbiamo la garanzia che il puntatore verrà chiamato, otteniamo il controllo dell'esecuzione del codice. Anche se questa è l'idea generale, ci sono ostacoli tecnici che verranno spiegati più avanti.
CClfsContainer* pContainer;. Quando il rispettivo contenitore viene deallocato, nel file padre avvengono operazioni di pulizia che dereferenziano pContainer e usano i valori [[pContainer]+0x18] e [[pContainer]+0x8] come puntatori a funzione.Quindi la capacità di controllare pContainer ed eseguire le API specifiche del contenitore per l'aggiunta e l'eliminazione garantisce la redirezione del flusso di controllo.
Dove si trova pContainer?
La struttura del file CLFS non è documentata, ma esistono alcuni tentativi individuali di reverse engineering della struttura del file.
Una panoramica della struttura di un file di log:

E una vista dettagliata del blocco base:

Differenze tra la struttura e la vista in memoria:
I file CLFS sono allocati in pagine big-pool con un valore di tag Clfs. Quando l'indirizzo di un log CLFS viene ottenuto nello spazio utente (lo stesso metodo usato per ottenere l'indirizzo nello spazio kernel di un oggetto pipe), il kernel restituisce L'INDIRIZZO IN CUI INIZIA IL BLOCCO BASE. (all'offset 0x800 nell'immagine precedente). All'offset 0xb98 vediamo una struttura dati chiamata regContainers. Si tratta di un array di valori a 32 bit. Ogni valore è associato a un contenitore e rappresenta l'offset (contando da 0x870) in cui si trovano le strutture dati interne di un contenitore nel file del blocco base.
Il layout in memoria di un file contenitore è il seguente:
struttura CLFS_CONTAINER_CONTEXT
All'offset 0x18 della struttura CLFS_CONTAINER_CONTEXT si trova il nostro obiettivo pContainer.
A quanto pare l'offset di pContainer è costante, ed è il primo elemento dell'array regContainers. Il suo valore è 0x1468.
La dereferenziazione e l'accesso a pContainer come puntatore a funzione è il punto cruciale dell'exploit. Ciò avviene all'interno della funzione Remove container nel driver CLFS.SYS. Quindi l'exploit verrà innescato quando il contenitore viene rimosso.
Prova:

Rivediamo i passaggi della rimozione di un contenitore per mostrare che effettivamente il puntatore pContainer viene acceduto come puntatore a una funzione
GetBaseLogRecord che restituisce l'indirizzo kernel del blocco base + 0x70 (sposta il puntatore del file oltre l'header)BaseRecord_1)a4 viene inizializzata come v10LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); Se c'è un singolo contenitore aggiunto al file, allora StartingIndex_1 è zero. Il valore restituito da GetBaseLogRecord viene castato a vettore DWORD (32 bit) e indicizzato di 0xCA elementi. Nota che (_DWORD*)BaseLogRecord_1 + 0 + 0xCA non aggiunge semplicemente il valore 0xCA a BaseLogRecord_1. A causa del cast, equivale a BaseLogRecord_1[0xCA]. Questo si riflette nel codice disassemblato corrispondente, mov eax, [rdi+r12*4+328h] dove rdi è la base, r12 è StartingIndex_1 (nota che viene moltiplicato per 4, che è la lunghezza di un elemento a 32 bit (DWORD)) e sommato con 0x328 che è 0xCA*0x4.Il valore di a4 alla fine è BaseBlock+0x70+0+328=(BaseBlock+0x398), che ci porterebbe al primo valore di RgConrainers (0x398+0x800=0xB98).
pContainer.82 e 83 pContainer viene dereferenziato, sommato con 0x18 e 0x8, interpretato come un puntatore a funzione ed eseguito. (call cs:__guard_dispatch_icall_fptr è in realtà una chiamata a un'istruzione jmp eax)Questo dimostra che riscrivendo il valore di pConainter possiamo alterare il flusso di controllo del driver e puntarlo verso indirizzi controllati dall'attaccante.
È stato spiegato in precedenza che a volte è necessario riempire un'area di memoria con un pattern predeterminato di valori per garantire la corretta esecuzione di un exploit. Questo perché l'attaccante può controllare solo un sottoinsieme delle variabili che compongono lo stato del programma al momento dell'exploit. Nel semplice esempio di memory spraying di prima abbiamo usato solo valori scritti in un vettore per soddisfare una certa condizione.
Nel caso reale, però, le condizioni sono più complicate. Dobbiamo disporre i file di log nella memoria del kernel in un certo ordine, con un offset noto tra di loro.
Prima di tutto, creiamo molti file di log in un ciclo e studiamo come il sistema operativo alloca la memoria per essi. L'esperimento userà il seguente schema:
Il seguente frammento di codice fa tutto questo:

E il risultato:

Ordiniamo gli indirizzi:

Osservando l'offset tra gli indirizzi, emerge uno pseudo-pattern nelle allocazioni: ci sono allocazioni continue che hanno un offset costante tra loro. Ad esempio da ffffd80faf444000 a ffffd80faf4ee000 l'offset tra due allocazioni consecutive è 0x11000.
Questa assunzione è cruciale per il funzionamento dell'exploit. Su di essa faremo affidamento.
Un'altra assunzione: prendiamo alcune pagine distanti 0x11000 l'una dall'altra. Ad esempio: ffffd80faf444000,ffffd80faf455000,ffffd80faf466000,ffffd80faf477000,ffffd80faf488000,ffffd80faf499000. Tutte le pagine corrispondono a file CLFS aperti. Se chiudessimo un file, la pagina verrebbe deallocata e la memoria all'indirizzo corrispondente sarebbe libera.
In memoria apparirebbe così (supponiamo di chiudere il file corrispondente alla pagina a ffffd80faf466000):
ffffd80faf444000,ffffd80faf455000,xxxxxxxxxxxxxxxx,ffffd80faf477000,ffffd80faf488000,
SE riapriamo il file, il sistema operativo, con un alto grado di certezza, allocherà una pagina allo stesso indirizzo (ffffd80faf466000) per riempire il buco e rendere la memoria continua. --> Anche questo è cruciale per l'esecuzione dell'exploit.
Ora introduciamo uno schema della strategia che useremo per assicurarci di sovrascrivere il puntatore *pConainter con un indirizzo sotto il nostro controllo, nello spazio utente.


Nell'immagine precedente start(aux1)+0x11000=start(A),start(A)+0x11000=start(B),start(B)+0x11000=start(aux2)
Useremo Logfile A per sovrascrivere il puntatore *pcontainer di Logfile B, e innescheremo l'exploit chiudendo Logfile B usando un codice speciale di eliminazione dopo la chiusura.
Aggiungere un contenitore di log a Logfile B, per allocare e aggiornare i campi che segnalano che B ha un file contenitore corretto. Possiamo aggiungere aux2 come contenitore di B.
Chiudere LogfileA così da poterlo modificare su disco. È importante scegliere A e B nel mezzo di una sequenza massimale di file distanziati di 0x11000, in modo da creare un buco in memoria che il sistema operativo darà priorità nel riempire. Se non lo fa, l'exploit fallirà.
Ricalcolare l'hash di A, modificare il campo hash per mantenere l'integrità e riaprire A, sperando che il kernel lo collochi allo stesso indirizzo, altrimenti l'exploit fallirà.
Chiamare AddLogContainer su A con un normale file di log per innescare la sovrascrittura del puntatore *pContainer di B.
Eliminare il file B così che venga chiamata RemoveConainter e l'esecuzione venga trasferita al *pContainer di B, ora corrotto e che punta a codice allocato dall'utente.
La seguente immagine illustra i passaggi menzionati in precedenza.

Iniziamo allocando 50 file CLFS. Dopo ogni allocazione, interroghiamo tutte le pagine CLFS nella memoria del kernel e creiamo un elenco con tutti gli indirizzi allocati per ciascun file. Successivamente, identifichiamo 2 file distanti 0x11000 l'uno dall'altro (A e B nello schema) e li memorizziamo nelle variabili first e second.

Dopo aver identificato l'indirizzo, cerchiamo due file distanti 0x11000: A->first , B->second

Successivamente chiudiamo A (first), lo modifichiamo su disco (malformandone gli header) e lo riapriamo.

Questo passaggio richiede ulteriori spiegazioni perché dobbiamo calcolare alcuni valori esatti che, attivando il codice sfruttabile in AllocSymbol, porteranno alla sovrascrittura del puntatore *pConainter di B.
Dall'analisi precedente delle funzioni AllocSymbol e FindSymbol sappiamo che dobbiamo modificare il file A in modo tale da far valutare il valore logico della condizione IF come FALSE e iniettare un valore nella variabile v9 che porterà a una scrittura nella posizione di *pContainer di B.
Prima di tutto, a quale valore dovrebbe essere impostata v9?
AllocSymbol calcola v10 (indirizzo di destinazione della scrittura) come:
v10=BaseLogRecord + v9 + 0x1338. Questo viene calcolato nel contesto dello spazio degli indirizzi di A. Quindi BaseLogRecord è: Indirizzo della pagina kernel di A + 0x70. v9 è controllato dall'attaccante e 0x1338 è costante.
Dove si trova *pContainer di B rispetto al suo indirizzo di pagina kernel?
Questo è stato dettagliato in precedenza, quindi dall'indirizzo di pagina kernel di B dobbiamo aggiungere 0x398 per arrivare al vettore regContainter di B e indicizzarlo con il primo elemento per ottenere l'offset della prima struttura CONTEINER_CONTEXT. Come detto in precedenza, per il primo contenitore l'indice è stato determinato essere 0x1468 (contando da B BaseBlock +0x70 )
Quindi la posizione di *pContainer di B è B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)
B's Kernel_Address=A's Kernel Address+0x11000 (perché abbiamo costruito la memoria in questo modo)
A's BaseRecordAddress=A's Kernel Address+0x70
V10=A's Kernel Address+0x70+v9+0x1338
v10 deve sovrascrivere *pContainer di B
V10 deve essere: v10=B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)
Sostituendo l'indirizzo kernel di B: v10=A's Kernel Address+0x110000+x70 + 0x1468 + 0x18
Riducendo v10: A's Kernel Address+0x70+v9+0x1338=A's Kernel Address+0x11000+x70 + 0x1468 + 0x18
Risolvendo per v9 = 0x11000+0x70+0x1468+0x18-0x1338-0x70=0x11148
Ora, quali campi in A devono essere sovrascritti? --> Ci sono 2 categorie di campi:
IF in AllocSymbol a valutare FALSELa categoria 1 non verrà dettagliata. V9 corrisponde al campo all'offset 0x1b98 su disco all'interno del file A. Non verranno forniti ulteriori dettagli sul perché questi campi attivino l'exploit; tocca al lettore approfondire, se interessato.
Ecco i valori che devono essere modificati:

Nota che (little endian) il valore usato è 0x11149 invece di 0x11148. Questo perché dobbiamo controllare gli ottetti di ordine superiore in *pContainer. L'ottetto meno significativo sarà nella forma X0 (10 o 20 o 30...). Possiamo fare un memory spray su ciascuno di questi valori per tener conto di questa condizione.
Nota che v10 verrà usato anche per fare memset con 00 di una regione di memoria di lunghezza 0xa0

Poi allo stesso indirizzo viene eseguita la sovrascrittura con la costante

Nota il valore costante che ha la forma 0x30c1fdf006X0
Questo avviene quando aggiungiamo un contenitore di log al file A, ma non abbiamo ancora coperto il processo di ricalcolo dell'hash dopo la modifica dei campi di A.
La vista del file di log su disco è in qualche modo diversa da quella in memoria. Modifichiamo solo il contenuto del blocco base, che ha una lunghezza di 0x7a00 e inizia su disco all'offset di file 0x800. L'algoritmo di hash usato per l'hashing del blocco base è CRC32. Il campo che contiene il valore dell'hash è anch'esso memorizzato all'interno del blocco base all'offset 0x80c.
Procedura per ricalcolare l'hash:
0x80c0x800 con lunghezza 0x7a00Per instradare l'esecuzione del codice verso lo spazio utente dobbiamo:
*pConainter di B (second) con un valore costante della forma 0x30c1fdf006X0.RemoveContainer.
Ci sono alcune condizioni imposte alla memoria utente che è il bersaglio per redirigere il flusso di esecuzione usando *pContainer. Non possiamo semplicemente iniziare a eseguire istruzioni dallo spazio utente con il programma in modalità kernel.
Lo scopo di questa sezione è far trapelare il valore del SystemToken nello spazio utente, senza causare un crash di sistema (BSOD).
Come si può leggere da un indirizzo nel kernel e memorizzare il risultato nello spazio utente? Un rapido promemoria sulla sezione Pipe del tutorial:

Qui abbiamo:
L'idea che collega l'oggetto Pipe al nostro obiettivo di ottenere il valore del system token è usare NtFsControlFile PipeReadAttribute per leggere dall'indirizzo del system token invece che dall'inizio del buffer che contiene le informazioni passate al kernel usando PipeWrite Attribute.
Ciò significa che dobbiamo modificare il valore del puntatore all'indirizzo PIPE_BEGIN+=0x20 per fargli contenere l'indirizzo in cui si trova il system token.
L'indirizzo è noto, ottenuto nelle fasi precedenti del tutorial, quando abbiamo individuato e analizzato la struttura EPROCESS.
Qui dobbiamo trovare un meccanismo che ci permetta di scrivere nella struttura della pipe in una posizione fissa.
Per questo usiamo la redirezione del codice ottenuta sfruttando la funzione CLFS AddLogConainer. Si potrebbe pensare che basti scrivere uno shellcode che faccia la sostituzione grezza, ma (sebbene non sia stato testato) potrebbe sicuramente non funzionare. Questo perché il driver gira nel contesto del kernel ed esegue codice da un'area di un processo utente.
Per aggirare questa limitazione dobbiamo trovare ROP del kernel che realizzino una scrittura a un valore arbitrario. Cioè trovare alcuni pezzi di codice del kernel che si trovano alla fine di una funzione e terminano con un'istruzione ret, e fornire i loro indirizzi come destinazioni per la redirezione. In questo modo il codice viene ancora eseguito dal kernel.
Questa è l'idea principale, ma sorgono limitazioni dal modo in cui il driver CLFS chiama il codice all'interno di *pConainer:

Per creare un pattern di memoria utilizzabile dobbiamo studiare il modo in cui la funzione RemoveContiner accede al codice di *pContainer.
Nell'immagine precedente evidenziamo le porzioni di codice che dereferenziano *pContainer e lo usano come puntatore a funzione.
RDI è la costante che è stata scritta dentro *pcontainer sfruttando AllocSymbol. Come vedi, la costante non ha un valore del tutto fisso: varia nel primo byte (la forma è 6X0, dove X è qualsiasi valore).
mov rax, [rdi] dereferenzia il valore costante. Per non causare una lettura non valida (e un BSOD), dobbiamo assicurarci che rdi contenga un puntatore a un indirizzo valido. Per farlo dobbiamo fare spray della memoria utente da 0x30C1FDF00000 ad almeno 0x30C1FDF006FF. Questo viene fatto allocando un blocco di memoria con VirtualAlloc. Naturalmente, se il sistema non riesce ad allocare memoria per qualsiasi motivo, l'exploit fallisce.
LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);
Poi per ogni possibile valore di X memorizziamo da 0x30C1FDF000X0 a 0x30C1FDF00FX0 un altro valore che corrisponde a memoria utente valida, scegliendo un valore arbitrario, ad esempio 0x5000000. In questo modo ci assicuriamo che rax sarà uguale a 0x5000000 per qualsiasi rdi della forma 0x30C1FDF006X0
Dopo questo passaggio vediamo altre due operazioni di dereferenziazione: mov rax, [rax+0x18] e mov rax ,[rax+0x8]
Per garantire che la memoria sia ancora coerente a questi indirizzi, dobbiamo memorizzare a 0x5000008 e 0x5000018 puntatori a funzioni del kernel (ROP1 e ROP2, verranno calcolati in seguito).
Questo è l'algoritmo per generare il pattern dello spray.

Nota: il pattern si evolverà quando introdurremo i ROP perché anch'essi hanno parametri che verranno presi in considerazione, ma questo è il minimo finora per prevenire un indirizzamento di memoria non valido.
Ecco come appare il primo stadio della memoria spruzzata nel debugger (osserva dove viene posizionato il valore 0x5000000--> allineato a X0):

Ecco come appare la memoria a 0x5000000 (i ROP che verranno dettagliati)

E il processo di redirezione del codice nel debugger del kernel:

Nota il valore del registro RDI e il puntatore all'istruzione nel debugger. RDI dereferenziato una volta è 0x5000000 e viene memorizzato in RAX. [RAX+0x18] è il secondo ROP, e più avanti [RAX+0x8] sarà l'indirizzo del primo ROP.
In questa fase abbiamo i seguenti elementi dell'exploit che dobbiamo collegare tra loro:
L'obiettivo a questo punto è collegare la redirezione del codice con un pezzo di codice che sovrascriva il puntatore della Pipe al buffer degli attributi con l'indirizzo del token di sistema e usi Pipe read attribute per recuperare le informazioni nello spazio utente.Come abbiamo affermato, il codice che viene reindirizzato deve trovarsi anch'esso nel kernel. Inoltre abbiamo solo 2 funzioni da usare. Il tutorial non coprirà come queste 2 funzioni specifiche siano state trovate, ma presumibilmente esiste una lista di candidati ROP spesso utilizzati.
Studieremo 2 funzioni:
SeSetAccessStateGenericMapping in ntoskrnl.exe Chiamata per seconda ([rax+0x8])ClfsEarlierLsn in CLFS.SYS Chiamata per prima ([rax+0x18])Analisi di ClfsEarlierLSn:

L'unico ruolo di questa funzione, quando chiamata con parametri errati, è impostare EDX a 0xFFFFFFFF e ritornare. Il motivo per cui serve sarà chiaro analizzando la seconda ROP.
Analisi di SeSetAccessStateGenericMapping:

Questa va discussa riga per riga in dettaglio.
Input: quando si entra in questa funzione:
0x30C1FDF006X00x30C1FDF00YX8, è sempre allineato a 0x8 e non è in conflitto con 0x30C1FDF006X0 che è allineato a 0 e contiene 0x5000000Esecuzione del codice della funzione:
mov rax, [rcx+48h] esegue la dereferenziazione di 0x30C1FDF00YX8 e sposta il valore in RAXmovups xmm0, xmmword ptr [rdx] sposta 16 byte da RDX al registro XMM0. Ricorda che qui, da ClfsEarlierLSn, RDX è 0xFFFFFFFFmovdqu xmmword ptr [rax+8], xmm0 sposta in [RAX+0x8] il valore di XMMOIn sostanza legge da un indirizzo e memorizza all'interno del kernel. Può essere interpretato così:

Quindi, per sovrascrivere il puntatore al buffer degli attributi della pipe con un puntatore al token di sistema, dobbiamo allocare 16 byte all'indirizzo 0xFFFFFFFF e memorizzare lì l'indirizzo del token di sistema. Poi dobbiamo effettuare ripetutamente lo spraying della memoria all'indirizzo 0x30C1FDF00YX8 con il valore dell'indirizzo di destinazione -0x8. Cioè il valore dell'offset del buffer degli attributi della pipe -0x8. Ovvero PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8.
Ecco come questo viene realizzato nel codice effettivo:

Un altro aspetto non spiegato: come si ottengono gli indirizzi kernel delle ROP nello spazio utente?
È un trucco. Per prima cosa si può ottenere l'indirizzo base di qualsiasi modulo kernel usando NtQuerySystemInformation con i seguenti parametri: NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)
Quindi si filtra in base al valore di ModuleInfo->Modules[i].Name per corrispondere alla libreria richiesta. In questo modo si ottiene l'indirizzo base nel kernel.
Poi sfruttiamo il fatto che l'offset tra l'indirizzo base e l'indirizzo dell'export è costante, indipendentemente dallo spazio degli indirizzi in cui l'eseguibile è caricato.
Carichiamo il modulo nello spazio utente (puoi farlo) usando LoadLibrary e otteniamo l'indirizzo dell'export usando Getprocaddress. Calcoliamo il delta tra l'export e la base utente e lo aggiungiamo alla base del kernel (trovata usando NtQuerySystemInformation). In questo modo otteniamo nello spazio utente l'indirizzo dell'export caricato nello spazio kernel.
Una volta che il puntatore all'attributo è stato corrotto e impostato per puntare alla posizione del token di sistema, una chiamata a MyNtFsControlFile con i parametri giusti dovrebbe leggere dall'indirizzo e far trapelare il valore del token di sistema nello spazio utente.

Una volta ottenuto il valore corretto del token di sistema, per completare l'exploit dobbiamo scriverlo al posto del nostro processo. In altre parole, sovrascrivere il token del processo che esegue l'exploit con il valore del token di sistema.
I passaggi necessari per effettuare questa modifica non includono nuove tecniche o metodologie, ma si basano sul riutilizzo del codice precedente. L'algoritmo per sovrascrivere il valore del nostro token implica un'operazione di scrittura nel kernel a un determinato indirizzo. Questo viene realizzato dalle funzioni ROP SeSetAccessStateGenericMapping e ClfsEarlierLSn. Questo implica che È NECESSARIO TRIGGERARE L'EXPLOIT CLFS UNA SECONDA VOLTA. È corretto, dobbiamo eseguire l'allocazione del container e il memory spraying una seconda volta e sperare che il sistema operativo non vada in crash.
Passaggi per sovrascrivere il valore del nostro token:
Poiché la seconda volta non abbiamo bisogno di una lettura dal kernel, questa volta la pipe non ci serve.
Poiché questi passaggi non richiedono conoscenze aggiuntive, il tutorial può concludersi qui con la prova finale di una cmd.exe con nt authority\system

L'obiettivo principale di questo tutorial non erano i meccanismi interni della CVE in sé, ma piuttosto una descrizione user friendly del processo di creazione di un esempio di privilege escalation su Windows. Molti exploit condividono gli stessi metodi e gli stessi mattoni, come il memory spraying o il lavoro con determinate strutture dati di Windows. E il più delle volte c'è un grande divario tra l'avere una conoscenza teorica degli internals di Windows e lo scrivere effettivamente un algoritmo che sfrutta una vulnerabilità.
ffffd80faf499000