Buffer overflow: Windows
9 January 2009 da Marco
Nella parte introduttiva sull'overflow mi sembra di aver spiegato abbastanza chiaramente i fondamenti di questo tipo di errore, quasi un incubo per i programmatori. In questo articolo vedremo come sfruttare la suddetta vulnerabilità in ambiente Windows.
Il primo overflow
Passo subito alla pratica, proprio perché nel precedente articolo ho già trattato la parte teorica. Vediamo quindi un semplice programma vulnerabile all'overflow dello stack:
#include
int main(int argc, char **argv) {
char buf[20]; //inizializzo un buffer di 20 bytes
FILE* f=NULL;
int i=0;
printf("\nTest BOF");
f=fopen("input.txt","rb"); //apro il file...
while(!feof(f)) { //...e comincio a leggerne il contenuto
buf[i]=fgetc(f); //salvo i byte nel buffer
i++;
}
fclose(f); //chiudo il file
}
Si tratta di un esempio tipico di buffer overflow, dovuto all'uso incauto di un buffer nella lettura da file. Se infatti il file contiene dati per più di 20 bytes il programma andrà in crash, visualizzando il classico avviso di Windows.
Il codice, infatti, non fa altro che leggere un carattere alla volta dal file input.txt fino alla fine per poi copiarlo nella variabile buf. Naturalmente, poiché non c'è nessun controllo sulla dimensione del file, basta superare il limite del buffer per provocare la condizione di overflow. Nell'esempio, per far crashare il programma, ho inserito di proposito una stringa di 28 bytes (AAAABBBBCCCCDDDDEEEEFFFFGGGG) dove “FFFFGGGG” sono i byte eccedenti. Ma dove finiscono questi dati? Come detto la scorsa volta i dati che causano il traboccamento del buffer vanno a sovrascrivere lo stack della memoria. In questo caso, se si usa un debugger per visionare lo stato della memoria al momento dell'overflow, si noterà che il registro EIP ha assunto il valore 0x47474747 e quello EBP il valore 0x46464646, che rispettivamente equivalgono – in esadecimale – ai caratteri GGGG e FFFF.
Primi passi verso l'exploit
Modificare il registro EIP significa, in parole povere, riuscire a modificare l'esecuzione del programma, dirigendolo in una qualsiasi zona a nostro piacere. Una volta chiarito questo punto possiamo iniziare a progettare un exploit in grado di sfruttare l'overflow individuato per prendere il controllo del programma. Si procede studiando l'esecuzione del programma fino alla condizione di overflow, passo dopo passo, grazie ad un debugger. Vediamo ora la prima parte del programma. Dovete comunque tenere conto che se disassemblate un eseguibile da voi compilato l'output potrebbe differire dal mio, ciò è dovuto dalla differenza del compilatore, od anche da una versione diversa dello stesso:
00401000 push ebp //salva EBP nello stack 00401001 mov ebp,esp 00401003 sub esp,1Ch //riserva spazio per buffer 00401006 mov dword ptr [ebp-18h],0 //variabile FILE* f 0040100D mov dword ptr [ebp-1Ch],0 //variabile int i
Questa parte iniziale di codice viene generata dal compilatore e si “preoccupa” di riservare spazio nello stack utile per allocare buf (0x1C byte) e le altre variabili. Sono riportati i valori dei registri dello stack ESP e EPB prima e dopo l'esecuzione delle istruzioni. Successivamente il programma esegue la stampa a video con printf() della stringa “Test BOF”. In linguaggio assembly i prametri di una funzione vengono passati mediante salvataggio nello stack: prima della chiamata a printf() troviamo infatti un'istruzione PUSH che memorizza nello stack l'offset della stringa di testo.
00401014 push 407030h //offset stringa “\nTest” 00401019 call 00401114 //printf() 0040101E add esp,4 ...
Analizziamo ora il ciclo while che legge fino alla fine del file i byte memorizzandoli nella variabile buf:
00401033 mov dword ptr [ebp-18h], eax 00401036 mov eax, dword ptr [ebp-18h] 00401039 mov eax, dword ptr [eax-0Ch] 0040103C and ecx,10h 0040103F test ecx,ecx //test di fine file (EOF) 00401041 jne 00401061 00401043 mov edx, dword ptr [ebp-18h] 00401046 push edx 00401047 call 004010C7 //lettura da file fgetc() 0040104C add esp,4 0040104F mov ecx, dword ptr [ebp-1Ch] 00401052 mov byte ptr [ebp+ecx-14h],al //memorizza il byte in buf 00401056 mov edx, dword ptr [ebp-1Ch] 00401059 add edx,1 //incremento variabile i (i++) 0040105C mov dword ptr [ebp-1Ch],edx 0040105F jmp 00401036
l'istruzione alla riga 12 è quella che scrive nel buffer il dato letto da file mediante fget(). L'indirizzo del buffer è dato da [EBP+ECX-14h]; il valore vinere incrementato ad ogni iterazione grazie al registro ECX. E' tuttavia la parte finale del programma, quella che segue immediatamente all'istruzione fclose(), la più interessante: viene ripristinato il valore del registro EBP e per chiudere la procedura si esegue un'istruzione di ritorno RET. Tale istruzione ha l'effetto di estrarre una word (32 bit) dallo stack e di memorizzare in eip, modificando l'indirizzo dell'istruzione corrente e spostando così il flusso di esecuzione del programma. E' in questo momento che emergono i problemi dell'overflow e il programma raggiunge una condizione indefinita. Il ciclo while infatti, non si accorge di scrivere nel buffer più dati di quelli previsti (28 contro 20) e di conseguenza inizia a scrivere sopra lo stack alterando i valori in esso memorizzati.
00401061 mov eax, dword ptr [ebp-18h] 00401064 push eax 00401065 call 00401071 0040106A add esp,4 0040106D mov esp,ebp 0040106F pop ebp //ripristina EBP 00401070 ret //istruzione di ritorno
Tutto ciò si traduce con un errore che scatta nel momento in cui si eseguono le istruzioni POP e RET: i valori estratti dallo stack non sono quelli corretti, ma sono diventati parte dei dati letti da input; infatti, nel momento in cui si verifica il crash, ci si accorge che i registri EBP e EIP contengono i valori 0x46464646 e 0x47474747, che corrispondono proprio ai caratteri in eccedenza nel file input.txt (rispettivamente le stringe “FFFF” e “GGGG”).
Vediamo ora com'è possibile creare un exploit capace di sfruttare l'overflow del programma di esempio. Si è visto come sia possibile controllare l'andamento del programma fornendo input in eccedenza, ma è possibile andare oltre: se nell'input invece di fornire stringhe di testo, memorizziamo istruzioni assembly a nostro piacimento, possiamo iniettare il nostro codice direttamente nello stack affidato al programma e quindi modificare l'andamento del flusso d'esecuzione, dirottando il registro EIP nel punto in cui si trova il codice iniettato. L'unica difficoltà di questa fase è il calcolo degli indirizzi e degli offset di allineamento, che dovranno combaciare perfettamente per far sì che il programma ad un certo punto esegua le nostre istruzioni; analizzando la stringa di overflow ci si accorge che il valore GGGG pilota il registro EIP. La struttura dell'exploit prevede, quindi, un input di questa forma: in testa possiamo sfruttare i primi 20 byte del buffer per memorizzare il codice che vogliamo far eseguire, aiutandoci con eventuali istruzioni NOP (istruzione assembly che corrisponde a no-operation, quindi ininfluente) per allineare il codice esattamente alla dimensione di 20 byte. Seguono due word da 32 bit che corrispondono ai valori scritti dall'overflow nei registri EBP e EIP; infine, poiché per questo exploit ho pensto di visualizzare una semplice stringa di testo, memorizzeremo nella parte finale dell'input quest'ultima, terminata da 0.
|
Codice iniettato |
NOP (0x90) |
EBP |
EIP |
String\0 |
| 0... |
...20 |
1 word (4byte) |
1 word (4byte) | “Exploit works” |
Il registro EIP servirà per dirottare il flusso del programma verso l'inizio del codice da noi iniettato. Analizzando col debugger l'esecuzione del programma, si nota che il valore di EBP prima dell'overflow è 0x0012FF80, mentre lo stack usato per memorizzare i dati inizia all'offset 0x0012FF6C, che sarà proprio il punto in cui partirà il codice iniettato. Conosciamo quindi i valori delle word di EBP e di EIP, non resta che scrivere il codice da ineittare.
Come ho accennato prima, descriverò un exploit di esempio che non frà altro che stampare a schermo una stringa di testo. E' ovvio che ci si può spingere oltre e creare i cosiddetti shellcode, ovvero del codice assembly in grado di aprire una shell sul sistema vittima, solitamente con privilegi da amministratore. Alla fine di questo articolo accennerò alla costruzione di uno shellcode, ma un argomento del genere andrebbe approfondito non poco, e richiederebbe un articolo a parte.
Ritornando all'exploit, per stampare del testo abbiamo bisogno di conoscere l'offset in cui risiede la stringa e l'indirizzo di printf(), che come si è visto prima, è localizzata a 0x00401114. Il codice assembly da iniettare sarà il seguente, per un totale di 3+5+5=13 byte.
| OPCODES | ISTRUZIONE |
| 83 EC 20 | SUB ESP, 0x20 |
| 68 x1 x2 x3 x4 | PUSH offset(stringa) |
| E8 y1 y2 y3 y4 | CALL printf() |
Per raggiungere i 20 bytes richiesti dall'overflow, si aggiungono 7 istruzioni NOP alla fine del codice. Gli opcodes sono i codici esadecimali che corrispondono univocamente alle istruzioni assembly; ad esempio 83 EC identifica l'istruzione SUB ESP, che seguita dal valore 0x20, sottrae 20 byte dal registro EBP. Non rimane che calcolare i valori “x1 x2 x3 x4” e “y1 y2 y3 y4” della PUSH e della CALL. L'offset della stringa di testo si calcola partendo dall'indirizzo iniziale del nostro buffer (dove inizia il codice, 0x0012FF6C) a cui si aggiungono i 28 byte del buffer, ottenendo 0x0012FF88. Per calcolare il valore di y1 y2 y3 y4 occorre invece partire dall'indirizzo della funzione printf() 0x00401114 a cui bisogna sottrarre l'offset in cui si trova l'istruzione CALL, che è dato da 0x0012FF6C+3+5+5 = 0x0012FF79. Quindi otteniamo il valore di 0x002D119B. In definitiva:
| OPCODES | SITRUZIONE |
| 83 EC 20 | SUB ESP, 0x20 |
| 68 88 FF 12 00 | PUSH 0x0012FF88 |
| E8 9B 11 2D 00 | CALL 0x002D119B |
| 90 | NOP |
| 90 | NOP |
| 90 | NOP |
| 90 | NOP |
| 90 | NOP |
| 90 | NOP |
| 90 | NOP |
Va specificato che gli indirizzi e gli offset delle istruzioni assembly vanno scritti al contrario, cioè leggendoli da destra verso sinistra. Per creare un file di input di fatto in questo modo basta ricorrere ad un semplice programma in c++ che unisca vari pezzi dell'exploit scrivendoli su di un unico file “input.txt”. Usando il file così generato come input per il programma mostrato ad inizio articolo, l'exploit prenderà il controllo del programma visualizzando la stringa “Exploit works”. In ogni caso, al termine della funzione printf() il programma si trova in un punto indefinito, e quindi andrà in crash comunque. Ciò si può evitare aggiungendo una chiamata ad una funzione come exit().
Windows Shellcoding
Non approfondirò questo argomento, sarebbe troppo lungo e andrebbe aldilà dello scopo di questi articoli. Scrivere degli shellcode è complesso, e non cambia solo da sistema a sistema ma addirittura a seconda delle diverse versioni dello stesso. In generale uno shellcode dovrebbe aprire una shell sul sistema vittima, ma può essere usato per richiamare qualsiasi funzione di sistema approfittando dei privilegi del programma vulnerabile al buffer overflow. La difficoltà di scrivere shellcode universali è appunto dovuto al fatto che gli indirizzi delle varie funzioni cambiano a seconda del sistema. In windows è possibile risalire a tali indirizzi con un comodo programma di nome arwin. Esso permette di estrapolare l'indirizzo di una funzione presente in una determinata libreria di sistema (DLL).
C:\>arwin kernel32.dll GetProcAddressarwin
- win32 address resolution program - by steve hanna - v.01
GetProcAddress is located at 0x77e7b332 in kernel32.dll
In questo caso siamo riusciti a capire l'indirizzo della funzione GetProcAddres, che fra l'altro è molto utile per risalire a qualsiasi altra API di sistema. Il codice da iniettare dovrà essere scritto usando gli indirizzi così ricavati, avrete quindi capito che con uno shellcode si può fare qualsiasi cosa, capacità di programmazione assembly permettendo.
Pubblicato in Informatica | Commenti (1)

9 January 2009 alle 13:52
Finalmente ce l'hai fatta...
Bell'articolo, comunque