Articoli Manifesto Tools Links Canali Libri Contatti ?
Sicurezza / Exploits

Buffer overflow: spiegazione tecnica ed esempio pratico

Abstract
Cos'è un buffer overflow? In questo articolo verrà analizzata una delle vulnerabilità più comuni nel software.
Data di stesura: 19/02/2003
Data di pubblicazione: 15/04/2003
Ultima modifica: 04/04/2006
di Luigi Auriemma Discuti sul forum   Stampa

Introduzione

Oramai il termine "buffer overflow" è entrato nel vocabolario di chiunque abbia una minima conoscenza di sicurezza informatica e soprattutto della storia della sicurezza, visto che tale problema è diventato davvero quasi un simbolo.

La spiegazione veloce a questo problema e che tutti conosciamo è più o meno la seguente:
"Il buffer-overflow si presenta quando una stringa in input è più grande del buffer ove dovrà essere immagazzinata e ciò 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 mostrerò farò riferimento ad un sistema Windows, ma solo per comodità, ricordate che non ci sono differenze tra i sistemi operativi in quanto il buffer overflow interessa la macchina a basso livello.
Le uniche differenze che si possono incontrare riguardano la direzione dello stack che può essere di tipo *BSD (come su Linux) oppure di direzione contraria come accade su Windows ed altri sistemi, ma ciò non interessa molto per l'introduzione ai BOF.

Cosa bisogna sapere

L'articolo non necessita di particolari conoscenze tecniche, risulta sicuramente d'aiuto avere qualche nozione di Assembly.
Oltre alle basi dell'Assembly è importante conoscere come viene gestita ed è composta la memoria, nello specifico quella dei processori x86.

Per ovviare a qualche lacuna o qualche ruggine verranno presentati alcuni elementi fondamentali per ciò che concerne i BOF.

STACK

Lo stack è una zona di memoria adibita al contenimento dei dati dei programmi.
Esso viene spesso paragonato ad una pila, invece a me sembra di più un semplice contenitore che raccoglie dei dati che noi possiamo depositare o prelevare al suo interno soltanto uno per volta.
Per aiutarci nell'operazione di prelevamento ed inserimento esiste un puntatore allo stack il cui compito è quello di tenere traccia della posizione corrente e permettere così di eseguire operazioni ad un "livello" specifico.

Lo stack può essere rappresentato in questo modo:

+-----------+
| 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 direzioni opposte. Comunque questa è solo una nota e non è rilevante per la comprensione del funzionamento di un BOF.

EBP

EBP è un registro x86, rappresenta il puntatore alla base dello stack. Serve per sapere da dove inizia la porzione di stack che stiamo utilizzando (ad esempio da dove iniziano i dati locali ad una funzione in esecuzione):
+-----------+ <-- qui e` dove punta EBP (ossia dove inizia lo stack)
| oggetto 0 |
|    ...    |
| oggetto 7 |
| oggetto 8 |
| oggetto 9 |
|    ...    |
+-----------+

ESP

Anche ESP è un registro che contiene un indirizzo dello stack.
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:
+-----------+ <-- 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 più famoso nella sicurezza informatica. Esso è semplicemente un puntatore all'istruzione successiva, ossia ciò che la CPU dovrà eseguire subito dopo l'istruzione corrente.
EIP viene sfruttato per l'esecuzione di codice "maligno" inserito sfruttando errori di programmazione.

CALL

CALL non è un registro ma è un'istruzione che svolge sostanzialmente queste operazioni:
  1. Salvare EIP in memoria
  2. Saltare alla funzione che vogliamo eseguire (modificando EIP)

RET

RET è un'istruzione che si preoccupa di riassegnare ad EBP ed EIP i valori precedentemente immagazzinati nello stack in occasione di una CALL.
È proprio quando viene chiamato RET che EIP può essere alterato a piacimento manipolando i dati contenuti nello stack.

Spiegazione tecnica

Prima di passare all'esempio pratico è meglio iniziare a capire per quale motivo ed in quali condizioni possa verificarsi un BOF.

Come detto nell'introduzione un BOF altro non è che la scrittura di un buffer con una quantità di dati superiore a quella contenibile dal buffer stesso, ciò provocherà la sovrascrittura di zone di memoria adiacenti.
In questa zona di memoria (lo stack appunto) c'è tutto ciò che servirà alla funzione in esecuzione, una specie di banco di lavoro con tutto l'occorrente pronto all'uso.
È importante notare che il contesto in cui si può verificare un BOF è fondamentalmente quello di una funzione.

Ogni volta che avviene una CALL, il processore si occuperà di salvare in memoria il valore dell'EIP corrente in modo da poter riprendere, una volta effettuata la RET, la computazione dal punto immediatamente successivo alla chiamata.
L'immagazzinamento e ripristino del valore di EIP è tutto a carico del processore in quanto il programma NON può modificare od operare su tale registro direttamente.

È invece cura del software, all'inizio del blocco di istruzioni della funzione, salvare il puntatore EBP che puntava all'inizio del precedente stack e lasciare abbastanza spazio prima di esso in modo che venga utilizzato dalle variabili. A meno di non programmare direttamente in assembler, questa operazione viene solitamente fatta in automatico dal compilatore.
In questo esempio si può osservare la sequenza delle istruzioni assembler usate per l'operazione.

  1. push ebp 
  2. mov ebp, esp 
  3. sub esp, BYTES_NECESSARI_PER_LE_VARIABILI 
  1. salva sullo stack il vecchio EBP
  2. imposta in EBP l'inizio dello stack stack per la funzione
  3. alloca lo spazio per le variabili che verranno posizionate nell'area di stack compresa fra EBP ed EIP

Questo semplice meccanismo si rivela estremamente fragile quando si verifica un BOF.

Come può essere facilmente intuito, i problemi con il BOF non si vedranno subito dopo aver sovrascritto il buffer ed i 2 registri salvati, ma quando la funzione tenterà di ritornare alla vecchia posizione precedentemente salvata nello stack (dove si trovano le istruzioni che dovevano essere eseguite subito dopo la chiamata alla funzione).
Invece di ritrovarsi al vecchio indirizzo, il programma arriverà alla posizione indicata dal registro EIP che, tramite l'istruzione RET alla fine della funzione, si ritroverà 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 è uno stack "integro":

[buffer1][EBP][EIP]
E questo invece è come si presenta appena avviene un BOF:
[stringa][str][str][str....]
dove str è 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 più quando si verifica un BOF...

Beh la parte teorica può 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, se non ne avete uno, una buona scelta potrebbe essere Lcc-win32 del francese Jacob Navia. http://www.cs.virginia.edu/~lcc-win32/

Dopodiché 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 più riguardo al movimento dei registri o cosa c'è in memoria durante l'esecuzione di una parte di un programma, un eccellente scelta può essere TD32, ossia il Turbo Debugger 5.5 di Borland rilasciato free. Una copia è disponibile anche sulla mia pagina personale: http://www.pivx.com/luigi/misc/td32-55.zip

Se non avete mai usato un compilatore C ed avete optato per Lcc, il seguente file .bat vi potrà essere d'aiuto:

@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 mostrerò nella sezione successiva non dovrete far altro che digitare: "lcc bof" e basta. Tutto qui.

Esempio pratico

Il seguente sorgente in linguaggio C è un classico esempio di BOF:
  1. #include <stdio.h> 
  2.  
  3.  
  4. void leggistringa(void); 
  5.  
  6.  
  7. int main(void) { 
  8. 	leggistringa(); 
  9. 	return(0); 
  10.  
  11.  
  12. void leggistringa(void) { 
  13. 	long	num = 0; 
  14. 	char	buff[8]; 
  15.  
  16. 	gets(buff); 
Chi conosce il C sicuramente (o almeno spero) avrà iniziato a tremare e sudare freddo alla visione della funzione gets() che può essere considerata a tutti gli effetti come la funzione più 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 verrà 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 particolarità e la pericolosità della funzione sta nel fatto che non controlla se la stringa che ha immesso l'utente è più grande del buffer dove verrà collocata.
Pensate ad un autotreno che non frena allo stop ma continua la sua corsa e frenerà quando gli pare... questa è la base dei BOF.
Insomma se cercate grane con i buffer overflow, gets() è ciò che fa per voi!

Tornando all'esempio, secondo i nostri calcoli nello stack dovranno essere tenuti in considerazione esattamente 12 bytes in quanto abbiamo gli 8 bytes di "buff" più 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 verrà 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 farà si che l'attuale EIP (00401255 appunto) venga immagazzinato in memoria all'indirizzo 0063fdd4, cosicché esso potrà essere ripreso quando verrà 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 riappropriarci del nostro vecchio stack appena terminata la funzione.
Dopodiché 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 occuperà alla perfezione il buffer di 8 bytes chiamato buff in quanto 7 numeri occuperanno i primi 7 bytes e l'ottavo sarà un NULL byte che serve a delimitare la stringa.

Osservando la memoria del programma, 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]
Nei seguenti indirizzi di memoria troviamo quindi:
0063fdd4 Gli 8 bytes di buff appunto uguali a "1234567" + NULL.
0063fdda I 4 bytes di num uguale a 0 (NOTA: usando l'opzione di ottimizzazione del compilatore in realtà verrà solo allocato lo spazio per la variabile ma non verrà impostata a 0 realmente perché noi non ne faremo mai uso. Questo è proprio il lavoro dell'ottimizzazione che ci fa risparmiare cicli di CPU e/o spazio in memoria).
0063fde0 Il valore di EBP salvato precedentemente.
0063fde4 Il puntatore EIP salvato nello stack che si riferisce esattamente al codice che c'è dopo la chiamata alla nostra funzione leggistringa() (quello all'indirizzo 00401255 appunto).
In questi 20 bytes c'è il nostro potenziale BOF!

Ora, invece di digitare "1234567", inseriremo proprio 20 bytes di dati, 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 è "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
I valori dei registri sono stati sovrascritti, EBP è ora 0x61616161, ossia "aaaa" ed EIP è diventato 0x62626262 che è uguale a "bbbb".

A questo punto il vostro sistema operativo vi avrà segnalato un errore critico in quanto l'indirizzo 62626262 non è una zona di memoria del programma bof.exe, quindi esso non è autorizzato a leggere, scrivere o eseguire del codice in quel punto.
La cosa importante è che la macchina ha letto e tentato l'esecuzione ad un indirizzo che è stato inserito da un possibile utente estraneo!

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);"

Possiamo ad esempio aggiungere qualcosa tipo:

	...
	gets(buff);
	printf("Se mi vedi non puoi avere dubbi 8-)\n");
}
Ricompilato il programma con questa nuova riga di C, vedrete che il crash avverrà proprio all'uscita da leggistringa(), dopo che è 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 più complesse lo possono modificare.

Effetti dei buffer overflow

Oramai penso sia chiaro a tutti perché i buffer overflow creano così tanti problemi: semplicemente perché permettono di eseguire codice arbitrario sulla macchina che esegue il programma vulnerabile.

Non è compito di quest'articolo entrare nei dettagli e nei vari metodi esistenti per creare un exploit per buffer overflow, comunque la logica è sempre quella di far puntare EIP ad un pezzo della stringa che è stata immessa dall'attacker.

In pratica, invece di "123456781234aaaabbbb" un attacker può immettere del codice eseguibile prima o preferibilmente dopo il valore che andrà a sovrascrivere EIP ed impostare quest'ultimo valore all'indirizzo da cui cominciare ad eseguirlo.

È più difficile a dirsi che a farsi!

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 è molto utile per semplificarci la vita e non avere limiti con il nostro exploit: http://www.pivx.com/luigi/articles/expdem.txt

Conclusione

Spero che ora il concetto di buffer overflow sia molto più chiaro, grazie all'utilizzo di un esempio pratico molto semplice.

Ricordatevi sempre che certe cose sono più difficili da spiegare che da capire e che dopo la prima volta che si è iniziato ad ingranare con certi concetti tutto il resto non sarà più un problema.

Prima di salutarci ricordatevi anche che la storia dell'informatica non è scritta su nessun libro ma ce l'avete sotto gli occhi, se state leggendo quest'articolo sul monitor, in quanto nel vostro PC c'è tutto, dall'Assembly, alle protezioni dei softwares, dalle vulnerabilità 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!

Informazioni sull'autore

Luigi Auriemma, classe 80, vive in provincia di Milano. Appassionato di computers e "cacciatore di bugs" in qualsiasi applicazione soprattutto quando è annoiato. Sostenitore di tutto ciò che sia libero, dal software Open Source alla "Full Disclosure". Il suo sito web è http://aluigi.altervista.org/.

È possibile consultare l'elenco degli articoli scritti da Luigi Auriemma.

Altri articoli sul tema Sicurezza / Exploits.

Risorse

  1. Scrivere un exploit dimostrativo di esecuzione di codice su Windows.
    http://www.siforge.org/articles/2003/02/23-win-exploit.html
Discuti sul forum   Stampa

Cosa ne pensi di questo articolo?

Discussioni

Questo articolo o l'argomento ti ha interessato? Parliamone.