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:
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:
Salvare EIP in memoria
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.
push ebp
mov ebp, esp
sub esp, BYTES_NECESSARI_PER_LE_VARIABILI
salva sullo stack il vecchio EBP
imposta in EBP l'inizio dello stack stack per la funzione
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:
#include <stdio.h>
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) 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:
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:
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:
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:
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/.