L'overflow del buffer è una delle tecniche più avanzate
di hacking del software: se utilizzato a dovere può agevolare
l'accesso a qualsiasi sistema che utilizza un programma vulnerabile.
Il termine (abbreviato in BOF) capita talmente spesso sugli schermi
dei nostri computer che probabilmente ci suona ormai familiare. Buona
parte degli exploit che troviamo sui siti specializzati
infatti sfruttano diverse varianti dell'overflow per raggiungere i loro
scopi; questo articolo si prefigge di far capire i meccanismi che ne
regolano il funzionamento tramite esempi pratici.
Prima di tutto, consideriamo una semplice definizione: parliamo di buffer
overflow quando una stringa di input è più grande
del buffer (memoria) che la dovrà contenere. Questo comporta
un trabocco (overflow) che finisce per sovrascrivere porzioni
di memoria destinate ad altre istruzioni. Dato che nessun sistema è
immune, questa tecnica può colpire senza alcuna differenza le
applicazioni di Linux e Windows.
Durante l'esecuzione di un programma, le funzioni hanno la necessità
di archiviare i dati che sono oggetto dell'elaborazione. La zona di
memoria fornita dal sistema per salvare i dati è detta stack
(pila). Immaginiamo lo stack come un contenitore.
Quando è necessario, lo stack assume le dimensioni richieste
dalla funzione per contenere i dati. Può capitare che lo spazio
non sia sufficiente: se la funzione non si accorge dell'errore, i dati
vengono memorizzati comunque finendo per sovrascrivere e corrompere
lo stack. Ecco dunque una attività ricorrente negli attacchi
al buffer: colpire lo stack, in inglese smashing the stack.
Come vedremo in seguito, nel linguaggio di basso livello Assembler
esistono dei puntatori che definiscono l'andamento dell'applicazione.
Quando una funzione richiama quella successiva, il puntatore alla prima
funzione viene salvato nello stack sotto forma di indirizzo, in modo
che il programma possa ritornare alla funzione principale.
Lo scopo dell'overflow dello stack è proprio di
sovrascrivere questo indirizzo di ritorno (Fig. 1.1). Dato che, come abbiamo
detto, il flusso di dati trabocca oltre lo spazio definito dallo stack,
anche il puntatore viene corrotto e sostituito dal codice preparato ad
hoc dal cracker.
Per poter capire a fondo gli effetti dell'overflow è necessario
introdurre alcune nozioni sull'architettura dei processori Intel. Abbiamo
già parlato dello stack, raffigurato come un contenitore
dove possiamo memorizzare temporaneamente delle informazioni per poi estrarle
quando ne abbiamo bisogno. È molto importante avere un puntatore
che tiene traccia della posizione dei dati immessi; a questo proposito
vediamo quali registri vengono utilizzati dal sistema:
- EBP, o Base Pointer. È il puntatore alla base
dello stack, serve per definire dove iniziano i dati memorizzati nello
stack; - ESP, o Stack Pointer. Punta all'attuale posizione
nello stack, serve a inserire o estrarre dati nella posizione desiderata; - EIP, o Instruction Pointer. Punta all'istruzione
da eseguire, cioè a quella successiva rispetto alla posizione
corrente.
La conoscenza delle istruzioni di base dell'Assembler è
essenziale per capire gli esempi che seguono. Ecco quelle più utili
alla nostra "causa":
- PUSH, aggiunge informazioni nello stack;
- POP, rimuove i dati dallo stack (secondo una struttura
LIFO, gli ultimi dati aggiunti sono i primi ad essere rimossi ->
Last In First Out); - CALL, effettua un salto incondizionato a una funzione
e inserisce l'indirizzo dell'istruzione successiva (EIP) nello stack; - RET, estrae un indirizzo di ritorno dallo stack
per ritornare alla funzione principale
Ogni routine ha inizio con un CALL e termina con un RET. Se l'aggressore
è riuscito a sovrascrivere l'indirizzo di ritorno, nella fase di
RET può ottenere il totale controllo del processore.
Dentro il codice
Esaminiamo una semplice applicazione in C che utilizza una funzione vulnerabile
ad attacchi di overflow del buffer (Tab. 2.1).
01 void func(void) |
02 { |
03 char bof[20]; |
04 gets(bof); |
05 } |
Tab 2.1 - Funzione vulnerabile
Nel listato troviamo la definizione di una variabile bof di
tipo char alla terza riga, alla quale vengono riservati 20 bytes,
e la chiamata alla funzione gets incaricata di raccogliere
l'input dell'utente e salvarlo nella variabile. Quando viene invocata
la funzione (CALL), il processore salva in memoria il valore contenuto
in EIP; in questo modo, dopo il RET può riprendere l'esecuzione
dall'istruzione immediatamente successiva alla chiamata.
Una porzione del codice Assembler corrispondente (Tab. 2.2) mostra come
avviene invece il salvataggio del puntatore EBP (che, ricordiamo, si riferisce
alla base dello stack utilizzato finora) e l'allocazione dello spazio
destinato alle variabili.
01 push ebp |
02 mov ebp, esp |
03 sub esp, 20 |
Tab 2.2 - Codice Assembler
01. Le istruzioni della prima riga salvano il valore del registro EBP
nello stack;
02. La posizione attuale nello stack (ESP) viene memorizzata in EBP, così
da diventare la nuova base (dove iniziano i dati della funzione);
03. Riserva la memoria necessaria alla variabile bof (per chi
preferisce i linguaggi di alto livello, la riga equivale a "esp =
esp - 20").
Dopo l'esecuzione, il "contenitore" ha l'aspetto che vediamo
in Fig. 2.1. Il problema ovviamente si presenta quando la stringa di input
è maggiore dei 20 bytes allocati e l'overflow corrompe lo stack
(cfr. Fig. 1.1). Ora che sappiamo riconoscere le applicazioni a rischio
possiamo tuffarci in un'esercitazione pratica, molto più stimolante
della pura teoria.
Prima di iniziare con l'esercitazione pratica assicuriamoci di avere
tutti gli strumenti necessari a portata di mano. Per questo esempio bastano
un compilatore e un debugger; le immagini nel tutorial si riferiscono
al compilatore Dev-C++ e al debugger GoVest, entrambi scaricabili gratuitamente:
1. Dev-C++ 4.0 - http://ftp1.sourceforge.net/dev-cpp/devcpp4.zip
2. GoVest 0.9 - http://www.geocities.com/govest/govest.zip
Dopo aver installato il compilatore, eseguiamolo e apriamo un nuovo progetto
(File > New Project > Console application) selezionando
"C project". Diamo l'ok e inseriamo un nome per il progetto
(es: test), quindi salviamo il file come ci viene richiesto. Nella finestra
principale scriviamo il codice della nostra applicazione (Tab. 3.1).
#include <stdio.h> |
#include <string.h> |
void func(char *p) |
{ |
char stack_temp[20]; |
strcpy(stack_temp, p); |
printf(stack_temp); |
} |
|
int main(int argc, char* argv[]) |
{ |
func("QUESTO TESTO PROVOCA UN OVERFLOWxxxxyyyy"); |
return 0; |
} |
Tab 3.1 - Listato di test.c
La funzione vulnerabile in questo caso è strcpy
perchè non controlla se la stringa inserita dall'utente è
più grande del buffer riservato (20). L'input dell'utente viene
simulato dalla stringa che abbiamo preparato, cioè
"QUESTO TESTO PROVOCA UN OVERFLOWxxxxyyyy". È evidente a tutti
che la stringa supera i 20 caratteri (si conteggiano anche gli spazi),
però come facciamo a sapere dove vanno a finire i caratteri in
più e come creare una stringa adeguata allo scopo?
Prima di tutto compiliamo il listato ed eseguiamo il programma che abbiamo
creato (da Execute > Compile and Run). Se tutto è andato
per il verso giusto, Windows ci avviserà che si è verificato
un errore in test.exe e quindi l'applicazione dovrà essere terminata.
Notiamo i dettagli della segnalazione (Fig. 3.1): il programma è
terminato bruscamente quando ha cercato di leggere l'offset (indirizzo)
79797979.
Un indirizzo formato dallo stesso numero che si ripete è abbastanza
curioso...infatti qualsiasi editor esadecimale potrà confermarvi
che "79" equivale a "y", e ovviamente "yyyy"
è la parte terminale della nostra stringa. L'applicazione ha eseguito
il codice che noi abbiamo passato come input, ha interpretato i caratteri
in più come offset e - cosa che non dovrebbe mai succedere - ha cercato di leggere quell'indirizzo. Complimenti:
il vostro primo buffer overflow!
Una istantantea
Per comprendere meglio cosa è successo all'interno dei registri,
aiutiamoci con il debugger. Questa applicazione ci serve per eseguire
il nostro programma passo passo, così da visualizzare le operazioni
compiute in Assembler e i valori contenuti nei registri in qualsiasi momento.
Entriamo in GoVest e apriamo test.exe selezionandolo
da File > Load process, quindi eseguiamolo con F5 (oppure
Debug > Run). Non lasciamoci intimorire dalla quantità di informazioni
visualizzate. L'area di lavoro è organizzata molto semplicemente:
il codice Assembler a sinistra, il contenuto dei registri e l'editor esadecimale
a destra.
Continuiamo l'esecuzione tramite Debug > Step over, quindi premiamo
F10 per avanzare di un passo per volta. Saremo interrotti
da un avviso di errore simile al precedente, sempre riguardo all'offset
79797979. A differenza di prima, però, abbiamo a disposizione una
istantanea dei registri al momento del "crash" (Fig. 3.2).
Come abbiamo visto nella parte teorica lo stack riserva
lo spazio alle variabili, quindi al puntatore della base e infine all'indirizzo
di ritorno. Ora abbiamo la conferma tangibile: la stringa "xxxx"
è stata memorizzata in EBP (78 equivale a x in esadecimale),
mentre i caratteri successivi "yyyy" in EIP (indirizzo di
ritorno). Questi offset non esistono e il programma si blocca...ma immaginiamo
di passare degli indirizzi validi come stringa: a quel punto saremmo
in grado di far eseguire del codice arbitrario al programma e ottenere
il controllo del sistema!
Prima di passare dall'altra parte della barricata, ossia la protezione
dagli attacchi, chiariamo un ultimo dubbio: visto che la memoria riservata
alla variabile è di 20 caratteri, come mai i
registri vengono sovrascritti dai valori passati solo a partire dalla
fine del carattere n°32? Dove finiscono i rimanenti 12 caratteri?
Per capire di cosa stiamo parlando, provate a contare il numero di caratteri
della stringa utilizzata, spazi compresi. Questo è molto importante
per imparare a creare codici di lunghezza adeguata.
La risposta è semplice: possiamo vedere dal listato che sono
presenti altre variabili, oltre a quella da 20 bytes. Per convenzione
vengono riservati 4 byte per ciascuna, se non indicato diversamente.
Nella creazione dell'input per l'overflow dovremo quindi tenere conto
di questi valori:
- 20 bytes riservati a stack_temp
- 4 bytes per la variabile p
- 4 bytes per argc
- 4 bytes per argv
- 4 bytes per il registro EBP
- 4 bytes per l'indirizzo di ritorno in EIP
Facendo la somma dei primi 4 valori considerati (20+4+4+4=32), appare
evidente che i registri inizieranno a essere sovrascritti proprio a partire
dal carattere successivo al 32! Sapendo questo, abbiamo impostato la stringa
di modo che la prima "x" fosse alla posizione n°33.
Programmazione sicura
Per poterci proteggere dagli attacchi di buffer overflow siamo costretti
ad affidarci alla professionalità dei programmatori. Selezioniamo
bene i programmi da utilizzare, dunque, e cerchiamo di restare al passo
con gli updates che risolvono i problemi delle applicazioni. Le normali protezioni
contro gli attacchi, come i firewall, in questo caso servono a poco. Anzi,
il firewall stesso potrebbe essere soggetto a un buffer overflow.
Se siamo programmatori, invece, ricordiamoci di effettuare maggiori controlli
sull'input degli utenti e non utilizziamo le funzioni a rischio (Tab.
4.1).
strcpy
|
lstrcpy
|
lstrcpyA
|
lstrcpyW
|
lstrcpyn
|
lstrcpynA
|
lstrcpynW
|
wstrcpy
|
strncpy
|
wstrncpy
|
sptrintf
|
swptrinf
|
gets
|
getws
|
strcat
|
lstracat
|
lstrcatW
|
wcscat
|
strncat
|
wstrncat
|
memcpy
|
memmove
|
scanf
|
wscanf
|
fgets
|
Tab. 4.1 - Funzioni a rischio