it-swarm.dev

Come funzionano ASLR e DEP?

Come funzionano la randomizzazione del layout dello spazio degli indirizzi (ASLR) e la prevenzione dell'esecuzione dei dati (DEP), in termini di prevenzione dello sfruttamento delle vulnerabilità? Possono essere esclusi?

115
Polynomial

La randomizzazione del layout dello spazio degli indirizzi (ASLR) è una tecnologia utilizzata per impedire il successo del codice shell. Lo fa compensando casualmente la posizione dei moduli e alcune strutture in memoria. La prevenzione dell'esecuzione dei dati (DEP) impedisce determinati settori di memoria, ad es. lo stack, dall'esecuzione. Se combinato, diventa estremamente difficile sfruttare le vulnerabilità nelle applicazioni usando tecniche di shellcode o di programmazione orientata al ritorno (ROP).

Innanzitutto, esaminiamo come potrebbe essere sfruttata una normale vulnerabilità. Salteremo tutti i dettagli, ma diciamo solo che stiamo usando una vulnerabilità di overflow del buffer dello stack. Abbiamo caricato una grande quantità di valori 0x41414141 Nel nostro payload e eip è stato impostato su 0x41414141, Quindi sappiamo che è sfruttabile. Abbiamo quindi utilizzato uno strumento appropriato (ad esempio pattern_create.rb Di Metasploit) per scoprire l'offset del valore caricato in eip. Questo è lo scostamento iniziale del nostro codice exploit. Per verificare, cariciamo 0x41 Prima di questo offset, 0x42424242 All'offset e 0x43 Dopo l'offset.

In un processo non ASLR e non DEP, l'indirizzo dello stack è lo stesso ogni volta che eseguiamo il processo. Sappiamo esattamente dove si trova nella memoria. Quindi, vediamo come appare lo stack con i dati di test che abbiamo descritto sopra:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 42424242   > esp points here
 000ff6b4  | 43434343
 000ff6b8  | 43434343

Come possiamo vedere, esp punta a 000ff6b0, Che è stato impostato su 0x42424242. I valori precedenti sono 0x41 E quelli successivi sono 0x43, Come abbiamo detto che dovrebbero essere. Ora sappiamo che l'indirizzo memorizzato in 000ff6b0 Verrà passato a. Quindi, lo impostiamo sull'indirizzo di qualche memoria che possiamo controllare:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 000ff6b4
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Abbiamo impostato il valore su 000ff6b0 In modo tale che eip sia impostato su 000ff6b4 - l'offset successivo nello stack. Questo farà eseguire 0xcc, Che è un'istruzione int3. Poiché int3 È un punto di interruzione del software, genererà un'eccezione e il debugger si arresterà. Questo ci consente di verificare che l'exploit abbia avuto successo.

> Break instruction exception - code 80000003 (first chance)
[snip]
eip=000ff6b4

Ora possiamo sostituire la memoria in 000ff6b4 Con shellcode, modificando il nostro payload. Questo conclude il nostro exploit.

Al fine di impedire il successo di questi exploit, è stata sviluppata la prevenzione dell'esecuzione dei dati. DEP impone che determinate strutture, incluso lo stack, siano contrassegnate come non eseguibili. Ciò è reso più forte dal supporto CPU con il bit No-Execute (NX), noto anche come bit XD, bit EVP o bit XN, che consente alla CPU di far valere i diritti di esecuzione a livello hardware. DEP è stato introdotto in Linux nel 2004 (kernel 2.6.8) e Microsoft lo ha introdotto nel 2004 come parte di WinXP SP2. Apple ha aggiunto il supporto DEP quando sono passati all'architettura x86 nel 2006. Con DEP abilitato, il nostro exploit precedente non funzionerà:

> Access violation - code c0000005 (!!! second chance !!!)
[snip]
eip=000ff6b4

Questo non riesce perché lo stack è contrassegnato come non eseguibile e abbiamo provato a eseguirlo. Per ovviare a questo, è stata sviluppata una tecnica chiamata Return-Oriented Programming (ROP). Ciò implica la ricerca di piccoli frammenti di codice, chiamati gadget ROP, in moduli legittimi all'interno del processo. Questi gadget consistono in una o più istruzioni, seguite da un reso. Concatenarli insieme con i valori appropriati nello stack consente l'esecuzione del codice.

Innanzitutto, diamo un'occhiata a come appare il nostro stack in questo momento:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 000ff6b4
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Sappiamo che non possiamo eseguire il codice su 000ff6b4, Quindi dobbiamo trovare un codice legittimo che possiamo usare invece. Immagina che il nostro primo compito sia quello di ottenere un valore nel registro eax. Cerchiamo una combinazione pop eax; ret Da qualche parte in qualsiasi modulo all'interno del processo. Una volta trovato, diciamo a 00401f60, Mettiamo il suo indirizzo nello stack:

stack addr | value
-----------+----------
 000ff6a0  | 41414141
 000ff6a4  | 41414141
 000ff6a8  | 41414141
 000ff6aa  | 41414141
>000ff6b0  | 00401f60
 000ff6b4  | cccccccc
 000ff6b8  | 43434343

Quando viene eseguito questo shellcode, avremo di nuovo una violazione di accesso:

> Access violation - code c0000005 (!!! second chance !!!)
eax=cccccccc ebx=01020304 ecx=7abcdef0 edx=00000000 esi=7777f000 edi=0000f0f1
eip=43434343 esp=000ff6ba ebp=000ff6ff

La CPU ha ora fatto quanto segue:

  • Passato all'istruzione pop eax Su 00401f60.
  • Estratto cccccccc dallo stack, in eax.
  • Eseguito ret, inserendo 43434343 In eip.
  • Ha generato una violazione di accesso perché 43434343 Non è un indirizzo di memoria valido.

Ora, immagina che, anziché 43434343, Il valore in 000ff6b8 Sia stato impostato sull'indirizzo di un altro gadget ROP. Ciò significherebbe che pop eax Verrà eseguito, quindi il nostro prossimo gadget. Possiamo mettere insieme i gadget in questo modo. Il nostro obiettivo finale è in genere trovare l'indirizzo di un'API di protezione della memoria, come VirtualProtect, e contrassegnare lo stack come eseguibile. Includeremmo quindi un gadget ROP finale per eseguire un'istruzione equivalente jmp esp Ed eseguire shellcode. Abbiamo bypassato con successo DEP!

Al fine di combattere questi trucchi, è stato sviluppato ASLR. ASLR comporta la compensazione casuale delle strutture di memoria e degli indirizzi di base dei moduli per rendere molto difficile indovinare la posizione dei gadget ROP e delle API.

Su Windows Vista e 7, ASLR rende casuale la posizione degli eseguibili e delle DLL in memoria, nonché lo stack e gli heap. Quando un eseguibile viene caricato in memoria, Windows ottiene il contatore del timestamp del processore (TSC), lo sposta di quattro posizioni, esegue la divisione mod 254, quindi aggiunge 1. Questo numero viene quindi moltiplicato per 64 KB e l'immagine eseguibile viene caricata a questo offset . Ciò significa che ci sono 256 posizioni possibili per l'eseguibile. Poiché le DLL sono condivise nella memoria tra i processi, i loro offset sono determinati da un valore di bias a livello di sistema che viene calcolato all'avvio. Il valore viene calcolato come TSC della CPU quando la funzione MiInitializeRelocations viene prima chiamata, spostata e mascherata in un valore a 8 bit. Questo valore viene calcolato una sola volta per avvio.

Quando le DLL vengono caricate, entrano in un'area di memoria condivisa tra 0x50000000 E 0x78000000. Il primo DLL da caricare è sempre ntdll.dll, che viene caricato in 0x78000000 - bias * 0x100000, Dove bias è il valore di polarizzazione dell'intero sistema calcolato all'avvio. Poiché sarebbe banale calcolare l'offset di un modulo se si conosce l'indirizzo di base di ntdll.dll, anche l'ordine in cui vengono caricati i moduli è randomizzato.

Quando vengono creati i thread, la loro posizione di base dello stack viene randomizzata. Questo viene fatto trovando 32 posizioni appropriate in memoria, quindi scegliendone una in base all'attuale TSC spostato mascherato in un valore a 5 bit. Una volta calcolato l'indirizzo di base, un altro valore a 9 bit viene derivato dal TSC per calcolare l'indirizzo di base dello stack finale. Ciò fornisce un elevato grado teorico di casualità.

Infine, la posizione degli heap e delle allocazioni degli heap sono casuali. Viene calcolato come un valore derivato TSC a 5 bit moltiplicato per 64 KB, fornendo un intervallo di heap possibile da 00000000 A 001f0000.

Quando tutti questi meccanismi sono combinati con DEP, ci viene impedito di eseguire shellcode. Questo perché non siamo in grado di eseguire lo stack, ma non sappiamo nemmeno dove verrà memorizzata alcuna delle nostre istruzioni ROP. Alcuni trucchi possono essere fatti con le slitte nop per creare un exploit probabilistico, ma non hanno del tutto successo e non sono sempre possibili da creare.

L'unico modo per aggirare in modo affidabile DEP e ASLR è attraverso una perdita di puntatore. Questa è una situazione in cui un valore nello stack, in una posizione affidabile, potrebbe essere utilizzato per individuare un puntatore a funzione utilizzabile o un gadget ROP. Una volta fatto ciò, a volte è possibile creare un payload che bypassa in modo affidabile entrambi i meccanismi di protezione.

Fonti:

Ulteriori letture:

153
Polynomial

Per completare l'auto-risposta di @ Polynomial: DEP può effettivamente essere applicato su vecchie macchine x86 (che precedono il bit NX), ma a un prezzo.

Il modo semplice ma limitato di eseguire DEP sul vecchio hardware x86 è utilizzare i registri di segmento. Con gli attuali sistemi operativi su tali sistemi, gli indirizzi sono valori a 32 bit in uno spazio di indirizzi piatto da 4 GB, ma internamente ogni accesso alla memoria utilizza implicitamente un indirizzo a 32 bit e uno speciale registro a 16 bit , chiamato "registro di segmento".

Nella cosiddetta modalità protetta, i registri di segmento puntano a una tabella interna (la "tabella descrittiva" - in realtà ci sono due di queste tabelle, ma questo è un tecnicismo) e ogni voce nella tabella specifica le caratteristiche del segmento. In particolare, i tipi di accesso consentito e il size del segmento. Inoltre, l'esecuzione del codice utilizza implicitamente il registro di segmento CS, mentre l'accesso ai dati utilizza principalmente DS (e accesso allo stack, ad es. Con i codici operativi Push e pop, utilizza SS). Ciò consente al sistema operativo di dividere lo spazio degli indirizzi in due parti: gli indirizzi inferiori sono nel range sia per CS che per DS, mentre gli indirizzi superiori sono fuori range per CS. Ad esempio, il segmento descritto da CS viene creato avere una dimensione di 512 MB. Ciò significa che qualsiasi indirizzo oltre 0x20000000 sarà accessibile come dati (letto o scritto usando DS come registro di base) ma i tentativi di esecuzione useranno CS, a quel punto il La CPU genererà un'eccezione (che il kernel convertirà in un segnale adatto come SIGILL o SIGSEGV, che di solito implica la morte del processo offensivo).

(Si noti che i segmenti vengono applicati nello spazio degli indirizzi; MMU è ancora attivo, su un livello inferiore, quindi il trucco spiegato sopra è per processo.)

Questo è economico da fare: l'hardware x86 lo fa applica segmenti, sistematicamente (e il primo 80386 lo stava già facendo; in realtà, l'80286 aveva già tali segmenti con confini, ma solo offset a 16 bit ). Di solito possiamo dimenticarli perché i sistemi operativi sani impostano i segmenti in modo che inizino con offset zero e siano lunghi 4 GB, ma impostarli altrimenti non implica alcun sovraccarico che non avevamo già. Tuttavia, come meccanismo DEP, non è flessibile: quando un blocco di dati viene richiesto dal kernel, il kernel deve decidere se questo è per il codice o meno per il codice, perché il limite è fisso. Non possiamo decidere di convertire dinamicamente una determinata pagina tra code-mode e data-mode.

Il modo divertente ma un po 'più costoso di fare DEP usa qualcosa chiamato PaX . Per capire cosa fa, è necessario entrare in alcuni dettagli.

MMU su hardware x86 utilizza tabelle in memoria, che descrivono lo stato di ogni pagina da 4 kB nello spazio degli indirizzi. Lo spazio degli indirizzi è di 4 GB, quindi ci sono 1048576 pagine. Ogni pagina è descritta da una voce a 32 bit in una tabella secondaria; ci sono 1024 sotto-tabelle, ognuna contenente 1024 voci, e c'è una tabella principale, con 1024 voci che puntano alle 1024 sotto-tabelle. Ogni voce indica dove l'oggetto puntato (una tabella secondaria o una pagina) si trova nella RAM, o se è presente e quali sono i suoi diritti di accesso. La radice del problema è che i diritti di accesso riguardano i livelli di privilegi (codice kernel vs userland) e solo un bit per il tipo di accesso, consentendo quindi "lettura-scrittura" o "sola lettura". "Esecuzione" è considerata una sorta di accesso in lettura. Quindi, il MMU non ha la nozione di "esecuzione" distinta dall'accesso ai dati. Ciò che è leggibile, è eseguibile.

(Dal momento che Pentium Pro, nel secolo precedente, i processori x86 conoscono un altro formato per le tabelle, chiamato PAE . Raddoppia la dimensione delle voci, che lascia spazio per indirizzare più RAM fisica e anche aggiungere un bit NX, ma quel bit specifico è stato implementato dall'hardware solo intorno al 2004.)

Tuttavia, c'è un trucco. RAM è lento. Per eseguire un accesso alla memoria, il processore deve prima leggere la tabella principale per individuare la tabella secondaria che deve consultare, quindi fare un'altra lettura a quella tabella secondaria e solo a quel punto il processore sa se l'accesso alla memoria dovrebbe essere consentito o meno, e dove in material RAM i dati realmente accessibili sono. Questi sono accessi di lettura con piena dipendenza (ogni accesso dipende dal valore letto dal precedente) in modo da pagare la latenza completa, che, sulla CPU moderna, può rappresentare centinaia di cicli di clock, pertanto la CPU include una cache specifica che contiene l'ultimo accesso MMU voci. Questa cache è Translation Lookaside Buffer .

A partire dall'80486, la CPU x86 non ha uno TLB, ma due. La memorizzazione nella cache funziona sull'euristica e l'euristica dipende dai modelli di accesso e i modelli di accesso per il codice tendono a differire dai modelli di accesso per i dati. Quindi le persone intelligenti di Intel/AMD/altro hanno trovato utile avere un TLB dedicato all'accesso al codice (esecuzione) e un altro per l'accesso ai dati. Inoltre, l'80486 ha un codice operativo (invlpg) che può rimuovere una voce specifica dal TLB.

Quindi l'idea è la seguente: fare in modo che i due TLB abbiano viste diverse della stessa voce. Tutte le pagine sono contrassegnate nelle tabelle (nella RAM) come "assenti", attivando così un'eccezione all'accesso. Il kernel intercetta l'eccezione e l'eccezione include alcuni dati sul tipo di accesso, in particolare se era per l'esecuzione del codice o meno. Il kernel quindi invalida la voce TLB appena letta (quella che dice "assente"), quindi riempie la voce in RAM con alcuni diritti che consentono l'accesso, quindi forza un accesso del tipo necessario ( o lettura dei dati o esecuzione del codice), che inserisce la voce nel TLB corrispondente e solo quello. Il kernel quindi imposta prontamente la voce in RAM torna su assente, e infine ritorna al processo (torna a riprovare il codice operativo che ha attivato l'eccezione).

L'effetto netto è che, quando l'esecuzione ritorna al codice di processo, il TLB per il codice o il TLB per i dati contiene la voce appropriata, ma l'altro TLB non! e will not dato che le tabelle in RAM dicono ancora "assente". A quel punto, il kernel è in grado di decidere se consentire l'esecuzione o meno, indipendentemente dal fatto che consente l'accesso ai dati o meno e può quindi applicare una semantica simile a NX.

Il diavolo si nasconde nei dettagli; in questo caso, c'è spazio per un'intera legione di demoni. Una tale danza con l'hardware non è facile da implementare correttamente. Soprattutto su sistemi multi-core.

Il sovraccarico è il seguente: quando viene eseguito un accesso e il TLB non contiene la voce pertinente, le tabelle in RAM e ciò implica da solo la perdita di alcune centinaia di cicli. costo, PaX aggiunge il sovraccarico dell'eccezione e il codice di gestione che riempie il TLB giusto, trasformando così i "poche centinaia di cicli" in "alcune migliaia di cicli". Fortunatamente, i mancati TLB hanno ragione. Le persone PaX affermano di avere misurato un rallentamento di appena il 2,7% su un grosso lavoro di compilazione (dipende comunque dal tipo di CPU).

Il bit NX rende tutto ciò obsoleto. Nota che il patchset PaX contiene anche alcune altre funzionalità relative alla sicurezza, come ASLR, che è ridondante con alcune funzionalità dei nuovi kernel ufficiali.

40
Thomas Pornin