Faremo riferimento ad un'architettura del tipo IA32 di cui vedremo per comprendere meglio, prima di cominciare con l'argomento di memory corruption vero e proprio:
L'architettura è una versione a 32 bit del set di istruzioni x86.
Si tratta di un'architettura del tipo CISC (Complex Istruction Set Computer), il che vuol dire che la lunghezza delle istruzioni può variare e tendenzialmente sono richieste molte istruzioni per accedere in memoria.
Ci sono 8 registri a 32 bit: EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP.
Ci sono 6 registri a 16 bit: CS, DS, ES, FS, GS, SS
L'architettura è little endian, ovvero se deve essere scritta in memoria ABCD, viene scritta in memoria come DCBA, il byte più a destra (meno significativo, D) avrà indirizzo di memoria più basso, mentre il byte più a sinistra (più significativo, A) avrà indirizzo di memoria più alto.
Inoltre l'architettura usa ABI (Application Binary Interface), ovvero l'interfaccia tra i programmi binari (eseguibili) e il sistema operativo. Tale interfaccia definisce come i programmi devono interagire con il sistema operativo, inclusi aspetti come le convenzioni di chiamata delle funzioni e le chiamate di sistema.
In questo modello di dati, che specifica le dimensioni dei tipi di dati fondamentali in un ambiente di programmazione, l'intero, il long e i puntatori occupano ciascuno 32 bit di memoria.
Per comprendere bene come funzionano gli attacchi di tipo buffer overflow, abbiamo bisogno di comprendere come è organizzata la memoria dati all'interno di un processo. Quando un programma viene eseguito, ha bisogno di spazio in memoria per salvare dei dati.
La memoria di un programma C è divisa in 5 segmenti, ognuno con il suo scopo.
La figura mostra quali sono tali segmenti:
Vediamo un esempio:
int x = 100; // data segment
int main(){
int a = 2; // stack
float b = 2.5; // stack
static int y; // BSS
// Allocazione di memoria nello heap
int ptr* = (int *)malloc(2 * sizeof(int));
ptr[0] = 5; // heap
ptr[1] = 6; // heap
free(ptr);
return 1;
}
C
Lo stack è un array di memoria gestito direttamente dalla CPU, usando il registro esp
(stack pointer). esp
detiene un indirizzo a 32 bit di una qualche zona di memoria dello stack. Non manipoleremo mai direttamente il registro esp
, invece, indirettamente utilizzeremo delle istruzioni che ne modificheranno la zona di memoria puntata, come ad esempio: call
, ret
, push
e pop
.
Lo stack pointer esp
punta sempre alla cima dello stack, ovvero ad elementi che sono stati pushati (messi) sullo stack. Per mostrate come funziona ecco un esempio:esp
contiene il valore esadecimale 0x00001000 (la h alla fine nella figura indica che si tratta di un valore esadecimale), che è l'indirizzo di memoria in cui si trova un certo valore, che è 6 nel nostro caso. Nello stack vi è un solo elemento, che dunque è in cima allo stack ed è puntato da esp
.
push
Quando eseguita l'operazione di push
, lo stack decrementa di 4 byte (grandezza di ogni cella dello stack, ovvero 32 bit), e copia un valore nella zona di memoria puntata dallo stack pointer.
Ecco come avviene una operazione di push:
lo stack pointer punta sempre alla cima dello stack.
Si nota che lo stack cresce verso il basso, ovvero un elemento aggiunto allo stack, ha un indirizzo di memoria più basso rispetto all'elemento che era in precedenza sullo stack. Per questo motivo abbiamo detto sopra che lo stack decrementa di 4 byte, effettivamente vengono sottratti 4 byte all'indirizzo corrente (si guardino gli indirizzi: 0x00001000 è un indirizzo più alto di 0x00000FFC).
Vediamo lo stack dopo aver fatto push di altri due elementi:
pop
L'operazione di pop
, rimuove un valore dallo stack. Dopo che un valore viene rimosso dallo stack, lo stack pointer viene incrementato di 4 byte, poiché deve puntare ad un indirizzo di memoria più alto. Vediamo come avviene la pop di 2 dallo stack:
L'area di memoria sotto esp
è logicamente vuota, nel senso che verrà sovrascritta la prossima volta che sarà fatto un push sullo stack.
push
e pop
La push
prima di tutto decrementa lo stack pointer e poi copia un operando che viene passato all'istruzione di push, nello stack.push registro
La pop
prima di tutto copia il contenuto dello stack puntato da esp
in un operando passato a poi incrementa esp
.pop registro
C'è un altro modo di chiamare la pop
:pop
ovvero, senza passare alcun operando, in questo caso si sta semplicemente rimuovendo il valore che c'è in cima allo stack, senza salvarlo da nessuna parte.
call
e ret
L'istruzione call
chiama una procedura indicando il processore di cominciare ad eseguire in una nuova zona di memoria. L'istruzione ret
porta il processore di nuovo a puntare nel punto in cui la procedura è stata chiamata. Tecnicamente parlando, la call
mette sullo stack (push
) il suo indirizzo di ritorno e copia l'indirizzo della procedura chiamata nel registro eip
(l'instruction pointer). Quando la procedura deve ritornare, ret
rimuove dallo stack (pop
) il return address copiandolo in eip
e la CPU esegue l'istruzione in memoria puntata da eip
.
Il buffer overflow si può verificare sia nello stack che nello heap. Il modo in cui possono essere attaccati sono abbastanza differenti. In questa argomentazione ci preoccuperemo del buffer overflow basato sullo stack.
Lo stack è usato per salvare dati usati nell'invocazione di funzioni.
Un programma esegue una serie di chiamate di funzione. Quando una funzione viene chiamata, viene allocato dello spazio sullo stack per la funzione chiamata. Consideriamo il seguente pezzo di codice:
void func(int a, int b){
int x,y;
x = a + b;
y = a - b;
}
C
Quando func()
viene chiamata, un blocco di memoria viene allocato in cima allo stack, e viene chiamato stack frame. La disposizione dello stack è mostrato nella figura sotto:
Uno stack frame ha quattro regioni importanti:
func()
ci sono due argomenti. Se viene chiamata con gli argomenti func(5, 8)
, i valori degli argomenti vengono pushati sullo stack, costituendo l'inizio dello stack frame. Dovremmo notare che gli argomenti vengono pushati in ordine inverso, ma lo vedremo dopo nel dettaglio.return
, ha bisogno di sapere in quale punto ritornare, l'indirizzo di ritorno deve essere salvato da qualche parte. Prima di saltare all'inizio della funzione chiamata, il computer mette l'indirizzo della prossima istruzione (l'istruzione posizionata subito dopo l'istruzione che invoca la chiamata di un'altra funzione) in cima allo stack, che è la zona del return address nello stack frame.Dentro func()
, è necessario accedere agli argomenti e alle variabili locali.
L'unico modo pe farlo è conoscere i loro indirizzi. Sfortunatamente, gli indirizzi non possono essere determinati a tempo di compilazione, perché i compilatori non possono prevedere lo stato a tempo di esecuzione dello stack e non saranno in grado di sapere dove si troverà lo stack frame.
Per risolvere questo problema, introduciamo il concetto di frame pointer.
Vediamo un esempio per comprendere meglio: dall'esempio di codice che abbiamo visto sopra, la funzione ad un certo punto ha bisogno di eseguire x = a + b
. La CPU ha bisogno di andare a prendere i valori di a
e b
, sommarli e salvarli in x
; la CPU ha bisogno di conoscere gli indirizzi di queste variabili.
La prima cosa che deve fare una funzione chiamata è salvare il valore del registro ebp
(base pointer), questo per preservare il suo attuale valore (nell'immagine previous frame pointer), poi ebp
viene fatto puntare alla cima dello stack esp
. Da questo momento in poi ebp
costituirà la base dello stack frame per la funzione chiamata. Quello che nelle figura è il frame pointer
non è altro che la zona di memoria in cui sta puntando ebp
. Nel frattempo, la funzione, avendo due variabili locali (x
e y
) ha dovuto allocare spazio per quelle due variabili nello stack. In questo momento esp
punta alla cima dello stack (la linea sotto y), mentre ebp
punta alla base dello stack frame per la funzione chiamata. esp
può continuare a estendersi e allocare variabili utili alla funzione chiamata, mentre ebp
resterà fermo alla base dello stack frame della funzione chiamata.
b
a a
sembrano avere ordine inversob
e a
sono i parametri che devono essere passati alla funzione da chiamare. La funzione è così fatta func(a, b)
il che vuol dire che prende prima a
e poi b
. Effettivamente, se ci si pensa, una volta che ebp
mette il suo vecchio valore sullo stack e viene fatto puntare a esp
il valore di a
è più vicino alla "nuova" base dello stack. Questa appena descritta è in realtà una convenzione di C, comunque basti sapere che i parametri da passare ad una funzione vengono pushati sullo stack in ordine inverso, in modo che quando lo stack pointer viene spostato si ha prima accesso all'ultima variabile messa (nel nostro caso la a
e poi alla b
).
I valori x
e y
vengono pushati sullo stack normalmente.
Come si vede nella figura sopra: il frame pointer (ebp
) punta ad una regione dove è salvato l'indirizzo del frame precedente (current frame pointer).
Dunque i valori di a
e b
si trovano rispettivamente a ebp + 8
e ebp + 12
.
Quindi il codice assembler per x = a + b
è il seguente:
mov 12(ebp), eax
mov 8(ebp), edx
add edx, eax
mov eax, -4(ebp)
come abbiamo già visto nella tabella sopra eax
e ebx
sono due registri con scopo generico, usati per salvare risultati temporanei.
L'istruzione mov u w
, copia il valore in u
in w
, mentre add edx eax
somma i valori dei due registri, e salva il risultato in eax
.12(ebp)
equivale a ebp + 12
(vale lo stesso per gli altri).
In un tipico programma, è possibile chiamare una funzione dall'interno di un'altra funzione. Ogni volta che entriamo in una funzione, viene allocato un stack frame in cima allo stack; quando si ritorna dalla funzione chiamata, lo spazio allocato per lo stack frame viene liberato. Nella figura in basso si vede come avvengono le chiamate di una funzione foo()
dalla funzione main()
, e di bar()
dalla funzione foo()
.
C'è solo un frame pointer (ebp
), e punta sempre allo stack frame della funzione corrente. Perciò, prima di entrare nella funzione bar()
, il frame pointer punta allo stack frame della funzione foo()
; quando entra in bar()
, il frame pointer punterà allo stack frame di bar()
. Se non ricordiamo a cosa punta il frame pointer prima di entrare in bar()
, una volta ritornati da bar()
, non saremo in grado di sapere dove dove si trova lo stack frame di foo()
. Per risolvere questo problema, prima di entrare nella funzione chiamata, il valore del frame pointer è salvato nel campo "vecchio frame pointer" dello stack. Quando la funzione chiamante ritorna, il valore in questo campo verrà usato per settare il valore del frame pointer, facendolo puntare allo stack frame del chiamante di nuovo.
Ogni funzione ha un prologo e un epillogo.
In base a ciò che abbiamo detto sopra è necessario che prima di chiamare una funzione:
ebp
ebp
a esp
Alla fine di una funzione:
esp
a ebp
(vecchio valore del frame pointer, elimina il frame)ebp
riportandolo in ebp
stesso (si ricordi che la pop reg
, rimuove l'elemento in cima allo stack e lo copia in un registro passato come operando)ret
(il cui funzionamento è stato spiegato sopra qui)Partiamo subito con un esempio, immaginiamo il seguente pezzo di codice:
int function_B(int a, int b){
int x, y;
x = a * a;
y = b * b;
return (x + y);
}
int function_A(int p, int q){
int c;
c = function_B(p, q);
return c;
}
int main(int argc, char** argv, char** envp){
int ret;
ret = funcion_A(1, 2);
return ret;
}
C
Ogni volta che viene eseguita una funzione viene creato un nuova stack frame per quella funzione.
Di seguito ecco un'immagine relativa alle funzioni scritte sopra, chiamate a partire dal main:
Il codice assembler della funzione B:
int function_B(int a, int b){
int x, y;
x = a * a;
y = b * b;
return (x + y);
}
C
è il seguente:
push ebp // viene salvato il vecchio valore di ebp
mov ebp, esp // ebp punta e esp
sub esp, 0x10 // esp viene esteso di 16 byte
// lo stack è compreso adesso tra ebp e esp
// ebp rappresenta la base dello stack frame mentre esp rappresenta la cima dello
// stack frame
mov eax, DWORD PTR [ebp+0x8]
imul eax, DWORD PTR [ebp+0x8]
mov DOWRD PTR [ebp-0x4], eax
mov eax, DWORD PTR [ebp+0xc]
imul eax, DWORD PTR [ebp+0xc]
mov DOWRD PTR [ebp-0x8], eax
mov eax, DWORD PTR [ebp+0x8]
mov edx, DWORD PTR [ebp+0x4]
add eax, edx
leave // l'istruzione leave fa quello che si dovrebbe fare all'epilogo di una funzione
// ovvero far puntare esp a ebp e fare una pop per rimuovere il vecchio valore di ebp
ret // rimuove il return address dallo stack per metterlo in eip, così da far puntare
// eip (instruction pointer) al punto in cui ha chiamata la funzione appena terminata
C
Esempio:
l'immagine sopra rappresenta l'allocazione di uno stack frame per una funzione che prende tre parametri. Chiamiamo tale funzione funzione(a, b, c)
.
La parte in azzurro è lo stack frame per tale funzione.
Cosa succede:
push
) i parametri da passare alla funzione, in ordine inverso, così che una volta spostato ebp
i parametri abbiano l'ordine corretto;call
, che mette sullo stack il return address, ovvero l'indirizzo in cui la funzione è stata chiamata, inoltre la call
, come abbiamo spiegato, mette in eip
l'indirizzo della funzione chiamata, così che si possano processare le istruzioni di quest'ultima;ebp
e si fa puntare ebp
a esp
;xx
, yy
, zz
e sum
ebp
(frame pointer) possiamo accedere ai parametri aggiungendo byte al suo indirizzo e alle variabili locali sottraendo byte al suo indirizzo.Per ripristinare il frame, si possono utilizzare due approcci:
esp
lo stesso valore di byte sottratto prima per estenderlo;esp
a ebp
, così da chiudere lo stack frame (modalità meno soggetta a errori per il programmatore: consigliata), infatti è questo che prevede l'epilogo (convenzione d'uscita dalla funzione), cioè:esp
a ebp
;ebp
dallo stack;ret
per ritornare all'indirizzo in cui la funzione è stata chiamata.Vediamo cosa succede riga per riga del codice che abbiamo visto prima:push ebp
: salva il valore corrente del registro base (EBP) nello stackmov ebp, esp
: imposta il registro EBP in modo che punti a ESP (EPS è l'attuale valore dello stack pointer, l'inizio della pila)sub esp, 0x10
: estensione dello stack pointer (ESP) di 16 byemov eax, DWORD PTR [ebp+0x8]
: viene caricato il valore situato all'indirizzo [ebp + 0x8] in eaximul eax, DWORD PTR [ebp+0x8]
: viene moltiplicato il valore in eax per il contenuto di [ebp + 0x8], ovvero per lo stesso valore, viene fatto a * a
mov DOWRD PTR [ebp-0x4], eax
: il contenuto di eax viene copiato in una zona di memoria [ebp + 0x4], viene fatto x = a * a
Queste tre operazioni:
mov eax, DWORD PTR [ebp+0xc]
imul eax, DWORD PTR [ebp+0xc]
mov DOWRD PTR [ebp-0x8], eax
fanno la stessa cosa, copiano in eax il contenuto di [ebp+0xC], viene moltiplicato eax per [ebp+0xC] e poi viene salvato in una posizione di memoria
Con queste tre operazioni invece:
mov eax, DWORD PTR [ebp+0x8]
mov edx, DWORD PTR [ebp+0x4]
add eax, edx
viene messo il valore di [ebp+0x8] in eax, quello di [ebp+0x4] in edx e viene fatta la somma tra di essi.
L'istruzione leave
fa puntare ESP e EBP e fa la pop di EBP:
mov esp, ebp
pop ebp // la pop estrae l'emento in cima allo stack e lo piazza sul registro usato
// come operando in questo caso ebp
ret
Navigazione: