####################################################################### Titolo: Buffer overflow: spiegazione tecnica ed esempio pratico Autore: Luigi Auriemma e-mail: aluigi@autistici.org web: aluigi.org ####################################################################### ============ Introduzione ============ Oramai il termine "buffer overflow" e' entrato nel vocabolario di chiunque abbia una minima conoscenza di sicurezza informatica e soprattutto della storia della sicurezza, visto che tale problema e' diventato davvero quasi un simbolo. La spiegazione veloce a questo problema e che tutti conosciamo e' piu' o meno la seguente: "Il buffer-oveflow si presenta quando una stringa in input e' piu' grande del buffer ove dovra' essere immagazzinata e cio' comporta la sovrascrittura di parti di memoria circostanti al buffer che sono necessarie all'esecuzione del codice macchina" In quest'articolo invece di limitarmi a riproporre la classica frase appena vista, voglio spiegare meglio nel dettaglio cosa accade ad una macchina (x86 nel nostro caso) quando si viene a verificare un BOF (abbreviazione di buffer-overflow) e soprattutto le conseguenze che questo problema trascina con se. Per l'esempio che mostrero' mi affidero' ad un sistema Win solo per comodita', ma ricordate che non ci sono differenze tra i sistemi operativi in quanto il buffer overflow interessa appunto la macchina. Le uniche differenze che si possono incontrare riguardano la direzione dello stack che puo' essere di tipo *BSD (come su Linux) oppure di direzione contraria come accade su Win ed altri sistemi, ma cio' non ci interessa molto per i BOF. L'articolo non necessita di particolari conoscenze tecniche ma senza dubbio aver avuto a che fare con l'Assembly aiuta molto in questi casi. Comunque nella sezione successiva daro' una breve spiegazione di cosa incontreremo durante l'articolo. ####################################################################### =================== Cosa bisogna sapere =================== Ovviamente bisogna conoscere le basi dell'Assembly e soprattutto come viene gestita ed e' composta la memoria nei processori x86. Per ovviare a qualche lacuna o qualche ruggine ecco una breve intro di cio' con cui avremo a che fare: ----- STACK ----- Lo stack e' una zona di memoria adibita al contenimento dei dati dei programmi. Esso viene spesso paragonato ad una pila, invece a me sembra di piu' un semplice contenitore che contiene tanta roba ma noi possiamo inserire o prelevare gli oggetti al suo interno soltanto uno per volta. Ad aiutarci comunque nell'operazione di prelevamento ed inserimento c'e' il puntatore allo stack il cui compito e' proprio quello di eseguire operazioni ad un "livello" specifico. Lo stack puo' quindi essere visto cosi': STACK |-----------| | oggetto 0 | | ... | | oggetto 7 | | oggetto 8 | | oggetto 9 | | ... | |-----------| Lo stack ha una sua direzione che varia a seconda del sistema operativo, difatti Win e Linux utilizzano 2 direzioni opposte. Comunque questa e' solo una nota e non ci interessa. --- EBP --- EBP e' un registro x86 ed e' il puntatore alla base dello stack, serve per sapere da dove inizia lo stack che stiamo utilizzando (ad esempio da dove iniziano i dati per la funzione corrente che stiamo eseguendo): STACK |-----------| <-- qui e' dove punta EBP (ossia dove inizia lo stack) | oggetto 0 | | ... | | oggetto 7 | | oggetto 8 | | oggetto 9 | | ... | |-----------| --- ESP --- ESP invece e' un puntatore ad un indirizzo dello stack. In pratica mentre EBP ci ricorda da dove inizia lo stack, ESP invece ci permette di scorrerlo a nostro piacimento per prelevare od inserire dati in un punto preciso della memoria: STACK |-----------| <-- qui e' dove punta EBP (ossia dove inizia lo stack) | oggetto 0 | | ... | | oggetto 7 | | oggetto 8 | <-- qui invece e' dove puo' puntare ESP ad esempio | oggetto 9 | | ... | |-----------| --- EIP --- Forse il registro piu' famoso nella sicurezza informatica. Esso e' semplicemente un puntatore all'istruzione successiva, ossia cio' che la CPU dovra' eseguire subito dopo l'istruzione corrente. E' proprio lui a permettere di eseguire codice tramite un programma buggato (vi dicono niente CodeRed ed altri worm o tutti gli exploit che permettono di diventare root su macchine remote o locali?). ---- CALL ---- CALL non e' un registro ma e' un'istruzione che svolge le seguenti operazioni: - Salvare EIP in memoria - Saltare alla funzione che vogliamo eseguire (modicando EIP) Questo e' in breve cio' che fa' CALL. --- RET --- Anche RET non e' un registro ma e' un'istruzione che si preoccupa solo di riassegnare ad EBP ed EIP i valori precedentemente immagazzinati nello stack. E' proprio quando viene chiamato RET che EIP puo' essere comandato a piacimento da chi ha creato il BOF. ####################################################################### =================== Spiegazione tecnica =================== Prima di passare all'esempio pratico e' meglio iniziare a capire per quale motivo ed in quale condizione avremo il verificarsi di un BOF. Se avete qualche dubbio durante o dopo aver letto questa sezione dell'articolo lanciatevi senza problemi all'esempio pratico nella sezione successiva in quanto vi schiarira' molto le idee e, se anche voi siete come me, preferirete senza dubbio un esempio che oltre a far capire la teoria dimostri con i fatti cio' che si sta' dicendo. Comunque ritornare in questa sezione e' senza dubbio utile se vi e' sfuggito qualcosa. Come detto nell'introduzione un BOF altro non e' che la sovrascrittura incondizionata di un buffer con dei dati che essendo molti di piu' del buffer stesso verranno quindi immagazzinati anche nelle zone di memoria adiacenti ad esso. In questa zona di memoria (lo stack appunto) c'e' tutto cio' che servira' alla funzione in esecuzione... una specie di banco di lavoro con tutto l'occorrente pronto all'uso 8-) Perche' parlo di funzione? Semplice, il BOF si verifica proprio con le funzioni. Ma continuiamo... In pratica ogni volta che c'e' una chiamata ad una funzione (CALL), il processore si occupera' di salvare in memoria il valore dell'EIP corrente in modo da potersi riposizione in quella stessa posizione al termine della funzione che si sta' chiamando. Il programma invece appena viene raggiunto l'inizio della funzione dovra' subito salvare il puntatore EBP che puntava all'inizio del precedente stack e dopodiche' lasciare dello spazio proprio prima di esso in modo che venga utilizzato dalle variabili. Da sottolineare che l'immagazzinamento di EIP e' tutto a carico del processore in quanto il programma NON puo' modificare od operare su tale registro direttamente. Questa semplice operazione che abbiamo appena visto permette infatti al programma di poter ripescare il puntatore all'istruzione che abbiamo lasciato prima di chiamare la funzione, non appena quest'ultima si concludera' (in gergo, "ritornare"). In Assembly, quando una funzione inizia, la prima cosa che fara' quindi e' questo: push ebp mov ebp, esp sub esp, MEMORIA_PER_LE_VARIABILI Semplice: immagazzina il vecchio EBP, dice ad EBP dove inizia il nuovo stack e successivamente alloca lo spazio per le variabili che appunto verranno posizionate prima di EBP ed EIP. Insomma un metodo semplice semplice che pero' puo' causare moltissimi problemi per via dei BOF. Da come chiunque puo' aver intuito, i problemi con il BOF non si vedranno subito dopo aver sovrascritto il buffer ed i 2 registri salvati, ma si avranno dopo che la funzione tentera' di ritornare alla vecchia posizione precedentemente salvata nello stack (dove si trovano le istruzioni che dovevano essere eseguite dopo la chiamata alla funzione). Invece di ritrovarsi al vecchio indirizzo, il programma arrivera' alla posizione indicata dal registro EIP che, tramite l'istruzione RET alla fine della funzione, si ritrovera' al suo interno i bytes che erano stati immessi precedentemente e che hanno causato la sovrascrittura della memoria adiacente al buffer di destinazione. Grosso modo questo e' uno stack "integro": [buffer1][EBP][EIP] E questo invece e' come si presenta appena avviene un BOF: [stringa][str][str][str....] dove str e' la stringa di dati immessa dall'utente o comunque da considerarsi come "sorgente" (mentre il buffer viene considerato la "destinazione"). Penso che sia chiaro ora che fine fanno i bytes in piu' quando si verifica un BOF... Beh la parte teorica puo' anche ritenersi conclusa, ora iniziamo seriamente con un bell'esempio pratico. ####################################################################### ========================== Cosa ci serve per iniziare ========================== Prima di passare all'esempio pratico avremo bisogno di alcuni tool che sono tutti disponibili come freeware od OpenSource. Innanzitutto abbiamo bisogno di un compilatore C e se non ne abbiamo uno, una buona scelta potrebbe proprio essere Lcc-win32 del francese Jacob Navia. http://www.cs.virginia.edu/~lcc-win32/ Dopodiche' ci serve un disassembler. La mia scelta personale per qualcosa di veloce ed OpenSource ricade su Disasm del coreano Sang Cho. http://www.geocities.com/SiliconValley/Foothills/4078/disasm.html Se vogliamo anche saperne di piu' riguardo al movimento dei registri o cosa c'e' in memoria durante l'esecuzione di una parte di un programma, un eccellente scelta puo' essere TD32, ossia il Turbo Debugger 5.5 di Borland rilasciato free. Vi risparmio tutte le rotture per poterlo prelevare dal sito della Borland in quanto l'ho messo a disposizione sulla mia pagina personale: http://aluigi.org/misc/td32-55.zip Se non avete mai usato un compilatore C ed avete optato per Lcc, il seguente file .bat vi potra' essere d'aiuto: ---lcc.bat--- @echo off c:\lcc\bin\lcc.exe -A -e20 -O -p6 -unused %1.c c:\lcc\bin\lcclnk.exe -s -subsystem:console %1.obj %2 %3 %4 %5 %6 %7 %8 %9 del %1.obj ------------- Quindi per compilare l'esempio che mostrero' nella sezione successiva non dovrete far altro che digitare: "lcc bof" e basta. Tutto qui. ####################################################################### =============== Esempio pratico =============== Il seguente sorgente in linguaggio C e' un classico esempio di BOF: ---BOF.C--- #include void leggistringa(void); int main(void) { leggistringa(); return(0); } void leggistringa(void) { long num = 0; char buff[8]; gets(buff); } ----------- Chi conosce il C sicuramente (o almeno spero) avra' iniziato a tremare e sudare freddo alla visione della funzione gets() che puo' essere considerata a tutti gli effetti come la funzione piu' pericolosa esistente nella libreria standard del linguaggio C e difatti molti compilatori visualizzano dei bei warning quando si cerca di utilizzarla. Tale funzione difatti legge dallo standard input (tastiera) la stringa che dopo verra' buttata nel buffer specificato con l'unica accortezza di sostituire il carattere line-feed (l'invio a capo che abbiamo digitato per terminare l'immissione dati) con un byte NULL. La particolarita' e la pericolosita' della funzione sta' nel fatto che se ne sbatte altamente di controllare se la stringa che ha immesso l'utente e' piu' grande del buffer ove verra' collocata. Pensate ad un autotreno che non frena allo stop ma continua la sua corsa e frenera' quando gli pare... questa e' la base dei BOF. Insomma se cercate grane con i buffer overflow, gets() e' cio che fa' per voi 8-) Ma veniamo a noi. Secondo i nostri calcoli nello stack dovranno essere tenuti in considerazione esattamente 12 bytes in quanto abbiamo gli 8 bytes di buff piu' i 4 bytes di num (un numero long in memoria infatti occupa appunto 4 bytes e comunque la logica a 32bit degli attuali processori divide tutto in 4 bytes alla volta). Da notare che spesso se si usano dei buffer o altre variabili non inizializzate, la memoria necessaria verra' allocata solo quando verranno effettivamente utilizzate. Una volta compilato tale codice avremo che la funzione leggistringa() contiene il seguente codice macchina: ------------chiamata a leggistringa()---------- :00401250 E803000000 call 00401258 :00401255 31C0 xor eax, eax :00401257 C3 ret -----------------leggistringa()---------------- :00401258 55 push ebp :00401259 89E5 mov ebp, esp :0040125B 83EC0C sub esp, 00C :0040125E 8D45F4 lea eax, dword[ebp-0C] :00401261 50 push eax :00401262 E829000000 call 00401290 ;;call CRTDLL.gets :00401267 59 pop ecx :00401268 C9 leave :00401269 C3 ret ----------------------------------------------- L'istruzione CALL all'indirizzo 00401250 fara' si che l'attuale EIP (00401255 appunto) venga immagazzinato in memoria all'indirizzo 0063fdd4, cosicche' esso potra' essere ripreso quando verra' invocata l'istruzione RET al termine della funzione leggistringa(). La prima istruzione di leggistringa() salva EBP nello stack, mentre la seconda copia su EBP il valore di ESP. Ricordiamoci che EBP puntava all'inizio del vecchio stack prima che entrassimo in leggistringa(). Esso serve appunto per riappropiarci del nostro vecchio stack appena terminata la funzione. Dopodiche' il programma alloca 12 bytes (00C) che verranno appunto usati per contenere le 2 variabili buff di 8 e num di 4 bytes rispettivamente. Dopo aver avviato il nostro programma, bof.exe, inseriremo la stringa "1234567" che occupera' alla perfezione il buffer di 8 bytes chiamato buff in quanto 7 numeri occuperanno i primi 7 bytes e l'ottavo sara' un NULL byte che serve a delimitare la stringa. Esattamente alla posizione 0063fdd4 del nostro stack (ossia il valore di ESP) la situazione "normale" dovrebbe essere la seguente: 0063fdd4: 31 32 33 34 35 36 37 00 1234 567. 0063fdda: 00 00 00 00 38 fe 63 00 .... [EBP] 0063fde4: 55 12 40 00 [EIP] Tutto cio' e' chiarissimo: 0063fdd4 ecco gli 8 bytes di buff appunto uguali a "1234567" + NULL 0063fdda ecco i 4 bytes di num uguale a 0 (NOTA: usando l'opzione di ottimizzazione del compilatore in realta' verra' solo allocato lo spazio per la variabile ma non verra' settata a 0 realmente perche' noi non ne faremo mai uso. Questo e' proprio il lavoro dell'ottimizzazione che ci fa' risparmiare cicli di CPU. Ok?) 0063fde0 ecco EBP che abbiamo salvato precedentemente 0063fde4 ecco infine il puntatore EIP salvato nello stack che punta esattamente al codice che c'e' dopo la chiamata alla nostra funzione leggistringa() (quello all'indirizzo 00401255 appunto) Tutto chiaro? In questi 20 bytes c'e' il nostro BOF quindi cerchiamo di comprenderli alla perfezione. Ora invece di inserire "1234567" inseriremo proprio 20 bytes, ossia: 8 per il buffer chiamato buff 4 per il numero long chiamato num 4 per il valore di EBP precedentemente salvato 4 per il valore di EIP precedentemente salvato La stringa da me scelta e' "123456781234aaaabbbb": 0063fdd4: 31 32 33 34 35 36 37 38 1234 5678 0063fdda: 31 32 33 34 61 61 61 61 1234 aaaa 0063fde4: 62 62 62 62 bbbb Wow! Indovinate un po' che fine hanno fatto EBP ed EIP??? EBP e' ora 0x61616161, ossia "aaaa" ed EIP e' diventato 0x62626262 che e' uguale a "bbbb". Bene bene, e' proprio cio' che volevo farvi vedere. Ora sicuramente il vostro sistema operativo vi avra' segnalato un errore critico in quanto che l'indirizzo 62626262 non e' una zona di memoria del programma bof.exe, quindi esso non e' autorizzato a leggere, scrivere o posizionarsi in quel punto. Ma la cosa importante e' che appunto la macchina ha provato a leggere ad un indirizzo che e' stato inserito da un possibile utente estraneo, il quale avrebbe potuto fare il bello ed il cattivo tempo sulla vostra macchina. Forse ora qualche sysadmin capisce perche' le patch vanno applicate il prima possibile e non dopo che un worm si e' divertito sulla sua macchina. Per chi e' maniaco dei dettagli riporto il valore che i registri assumono durante l'esecuzione di leggistringa() presi direttamente col debugger TD32: :00401250 EBP: 0063fe38, ESP: 0063fde4 CALL leggistringa() --- :00401258 ESP: 0063fde0 :00401259 EBP = ESP (0063fde0) :0040125B ESP: 0063fdd4 :0040125E EAX: 0063fdd4 :00401261 ESP: 0063fddd0 :00401262 ECX: 7fc1b3d4, EDX: 81a16d7c gets() "123456781234aaaabbbb" :00401267 ECX: 0063fdd4, ESP: 0063fdd4 :00401268 EBP: 61616161, ESP: 0063fde4 leave :00401269 ESP: 0063fde8, EIP: 62626262 ret Se qualcuno di voi avesse comunque ancora dei dubbi sul fatto che i BOF si presentano quando si ritorna da una funzione, vi consiglio di aggiungere al nostro programma di esempio alcune righe di codice dopo la riga "gets(buff);" Difatti possiamo ad esempio aggiungere qualcosa tipo: ... gets(bufF); printf("Se mi vedi non puoi avere dubbi 8-)\n"); } Provate a compilare il programma con questa nuova riga di C e vedrete che il crash avverra' proprio all'uscita da leggistringa() dopo che e' stata visualizzata la stringa che abbiamo appena aggiunto. Naturalmente un semplice printf() come quello che ho usato io non richiede altre variabili o spazio aggiuntivo nello stack, mentre altre operazioni piu' complesse lo possono modificare. ####################################################################### =========================== Effetti dei buffer overflow =========================== Oramai penso sia chiaro a tutti perche' i buffer overflow creano cosi' tanti problemi... semplicemente perche' permettono di eseguire codice sulla macchina che esegue il programma vulnerabile. Non e' compito di quest'articolo entrare nei dettagli e nei vari metodi esistenti per creare un exploit per buffer overflow, comunque la logica e' sempre quella di far puntare EIP ad un pezzo della stringa che e' stata immessa dall'attacker. In pratica e' un po' come se invece di "123456781234aaaabbbb" un attacker immetta del codice eseguibile prima o preferibilmente dopo il valore che andra' a sovrascrivere EIP e settare quest'ultimo valore all'indirizzo in cui verra' immagazzinata la sua stringa. Beh sembra piu' difficile a dirsi che a farsi 8-) Ho scritto un articolo riguardo la scrittura di un semplice exploit dimostrativo per un bug simile al BOF ma che consiste nella sovrascrittura dell'indirizzo di ritorno dopo una lettura incondizionata da un file, il che e' molto utile per semplificarci la vita e non avere limiti con il nostro exploit: http://aluigi.org/articles/expdem.txt ####################################################################### =========== Conclusione =========== Beh concludendo spero che ora il concetto di buffer overflow sia molto piu' chiaro soprattutto grazie all'utilizzo di un esempio pratico molto semplice. Ricordatevi sempre che certe cose sono piu' difficili da spiegare che da capire e che dopo la prima volta che si inizia ad ingranare con certi concetti tutto il resto non sara' piu' un problema. Prima di salutarci ricordatevi anche che la storia dell'informatica non e' scritta su nessun libro ma ce l'avete sotto gli occhi, se state leggendo quest'articolo sul monitor, in quanto nel vostro PC c'e tutto, dall'Assembly, alle protezioni dei softwares, dalle vulnerabilita' a qualsiasi altra cosa possiate mai leggere riguardo questo fantastico mondo creato molti anni fa' partendo da una costosa ed ingombrante calcolatrice. Commenti, correzioni, dettagli od altro sono sempre graditi! BYEZ