Memory corruption

Introduzione agli strumenti che utilizzeremo
...

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:

  • le istruzioni assembler
  • com'è fatto lo stack
  • e le convenzioni di chiamata delle funzioni

L'architettura IA32/x86
...

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.

I registri
...

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.

Tabella dei registri con i loro usi
...

Pasted image 20230919193203.png

ABI
...

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.

ILP32 Data Model (Int Long Pointer)
...

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.

Lo stack di IA32
...

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:
Pasted image 20230920171704.png

  • Text segment: salva il codice eseguibile del programma. Questo blocco di memoria è di solito di sola lettura.
  • Data segment: salva variabili locali o globali che sono state inizializzate dal programmatore.
  • BSS segment: salva le variabili locali o globali che non sono state inizializzate. Il segmento sarà riempito con degli zero dal sistema operativo, così che tutte le variabili non inizializzate siano inizializzate a zero.
  • Heap: lo heap è usato per fornire spazio per l'allocazione dinamica della memoria (in C attraverso le funzioni come malloc, calloc, realloc, free,...)
  • Stack: lo stack è utilizzato per salvare le variabili definite dentro le funzioni, nonché per memorizzare i dati relativi alle chiamate di funzione, come l'indirizzo di ritorno, gli argomenti, ecc. Lo vedremo meglio nel dettaglio in seguito.

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

Istruzioni di base dello stack
...

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:
Pasted image 20230921202851.png
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.

Operazione 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:
Pasted image 20230921203343.png
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:
Pasted image 20230921203702.png

Operazione 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:
Pasted image 20230921203851.png
L'area di memoria sotto esp è logicamente vuota, nel senso che verrà sovrascritta la prossima volta che sarà fatto un push sullo stack.

Vediamo le istruzioni di 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.

Istruzioni 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.

Lo stack e l'invocazione di funzioni
...

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.

Disposizione della memoria dello 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:
Pasted image 20230920172914.png
Uno stack frame ha quattro regioni importanti:

  • Argomenti: questa regione salva i valori degli argomenti che sono passati alla funzione. Nel caso della funzione 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 address: quando una funzione termina e passa per la sua istruzione di 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.
  • Frame pointer precedente: l'oggetto successivo che viene messo sullo stack frame dal programma è il frame pointer per il frame precedente. Ne parleremo nel dettaglio successivamente.
  • Local variables: la prossima zona server per salvare le variabili locali. L'organizzazione di questa zona, come l'ordine delle variabili locali, la dimensione di questa regione di memoria, ecc.. dipende dai compilatori. Alcuni compilatori possono randomizzare l'ordine delle variabili locali, o piazzare spazio extra in questa zona di memoria.

Frame pointer
...

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.
Pasted image 20230920172914.png
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.

Il motivo per cui b a a sembrano avere ordine inverso

b 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).

Frame pointer precedente e catena di chiamate di funzione
...

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().
Pasted image 20230920192815.png
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.

Convenzioni di chiamata

Ogni funzione ha un prologo e un epillogo.
In base a ciò che abbiamo detto sopra è necessario che prima di chiamare una funzione:

  • si metta sullo stack il registro ebp
  • si faccia puntare ebp a esp
  • si alloca lo spazio necessario per la funzione da chiamare

Alla fine di una funzione:

  • si faccia puntare esp a ebp (vecchio valore del frame pointer, elimina il frame)
  • si levi dallo stack il vecchio valore di 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)

Pasted image 20230921175125.png

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:
Pasted image 20230907174357.png

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:
Pasted image 20230908091850.png
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:

  • prima di tutto vengono messi sullo stack (push) i parametri da passare alla funzione, in ordine inverso, così che una volta spostato ebp i parametri abbiano l'ordine corretto;
  • viene usata l'istruzione 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;
  • qui entrano in gioco le convenzioni di chiamata:
    • viene messo sullo stack il vecchio valore di ebp e si fa puntare ebp a esp;
  • a questo punto si estende lo stack per eventuali variabili utili alla funzione chiamata: xx, yy, zz e sum
    A questo punto attraverso 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:

  • sommare a esp lo stesso valore di byte sottratto prima per estenderlo;
  • far puntare direttamente 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è:
    • far puntare esp a ebp;
    • rimuovere il vecchio valore di ebp dallo stack;
    • usare ret per ritornare all'indirizzo in cui la funzione è stata chiamata.
Adesso vediamo cosa succede nel codice assembler che abbiamo scritto sopra
...

Vediamo cosa succede riga per riga del codice che abbiamo visto prima:
push ebp: salva il valore corrente del registro base (EBP) nello stack
mov 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 bye
mov eax, DWORD PTR [ebp+0x8]: viene caricato il valore situato all'indirizzo [ebp + 0x8] in eax
imul 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: