Memory corruption con buffer overflow dello stack

Come abbiamo detto nell'argomentazione precedente è possibile sfruttare le vulnerabilità relative sia allo stack che allo heap.
In questa trattazione tratteremo gli attacchi di tipo buffer overflow, noti anche come stack buffer overflow.

Introduzione agli attacchi di tipo stack buffer overflow
...

Nei programmi, è abbastanza comune che si verifichi scrittura sulla memoria, ovvero data una sorgente si copia il suo contenuto nella memoria, o da una zona di memoria ad un'altra. Prima di effettuare la scrittura su memoria, un programma ha bisogno che vi sia allocato lo spazio sufficiete di memoria per ospitare ciò che vi si vuole scrivere. A volte, i programmatori fanno degli errori e sbagliano nel calcolare lo spazio necessario da allocare, in tal modo vengono scritti più dati di quanto spazio è stato effettivamente allocate. Questo è ciò che chiamiamo overflow. Alcuni linguaggi di programmazione come Java, si accorgono in automatico quando un buffer è sovraccarico, ma in altri linguaggi come C o C++ no. Molte persone pensano che l'unico problema che un buffer overflow possa causare è il malfunzionamento del programma, tuttavia non si tratta solo di questo. Per esempio una grossa conseguenza è il fatto che un attaccante possa prendere il controllo del programma vulnerabile, acquisendo i privilegi con cui il programma è stato eseguito.

Copia di dati nel buffer
...

Ci sono molte funzioni C che consentono di copiare dati nel buffer: strcpy(), strcat(), memcpy(), ecc. Nell'esempio di seguito, useremo la funzione strcpy(), che è usata per copiare stringhe:

#include <string.h>
#include <stdio.h>

void main(){
	char src[40] = "Hello world \0 extra string";
	char dest[40];

	// copy to dest
	strcpy(dest, src);
}
C

la stringa sorgente src contiene nel mezzo della stringa un carattere, chiamato zero, che si indica con \0 ed è utilizzato come terminatore delle stringhe. Infatti la funzione str copia i dati fino a quel carattere lì, escludendo extra string. Se non ci fosse stato lo zero in mezzo, strcpy avrebbe copiato tutta la stringa in dest.

Buffer overflow
...

Cosa succede se la stringa che scriviamo nel buffer è più lunga dello spazio allocato per il buffer?

#include <string.h>

void foo(){
	char buffer[12];

	// La seguente riga di codice risulta in buffer overflow 
	strcpy(buffer, str);
}

int main(){
	char *str = "This is definitely longer than 12";
	foo(str);

	return 1;
}
C

Di seguito mostriamo lo stack per l'esecuzione di questa figura.
Il buffer locale buffer[12] in foo alloca uno spazio di 12 byte.
La funzione foo usa la strcpy per scrivere su quel buffer ricevendo come parametro il valore da inserire nel buffer. Dal momento che la stringa passata in questo caso è più lunga di 12 byte strcpy sovrascriverà una porzione dello stack sopra il buffer (buffer overflow).
Pasted image 20230922162117.png
Lo stack cresce verso il basso, tuttavia i buffer, crescono normalmente.
La posizione 0 di buffer si troverà in cima allo stack, con un indirizzo più basso, le posizioni successive avranno indirizzi più alti. La scrittura di dati superiori alla dimensione del buffer andranno a sovrascrivere dati come il precedente frame pointer, il return address, ecc.

Conseguenze
...

La regione sopra il buffer include valori critici, come abbiamo menzionato sopra. Se il campo per il return address viene modificato a causa di un buffer overflow, quando la funzione deve ritornare, ritornerà ad una posizione diversa da quella a cui doveva effettivamente ritornare.
Possono succedere diverse cose:

  1. l'indirizzo piazzato al posto del vero return address non è mappato in nessun indirizzo fisico, quindi il ritorno fallirà e il programma crasherà;
  2. l'indirizzo piazzato al posto del vero return address è mappato in un indirizzo fisico, ma tale indirizzo è protetto dall'accesso poiché per esempio è riservato solo al sistema operativo, il ritorno fallirà e il programma crasherà;
  3. l'indirizzo piazzato al posto del vero return address è mappato in un indirizzo fisico, ma i dati in quell'indirizzo non rappresentano un'istruzione valida, il ritorno fallirà e il programma crasherà;
  4. l'indirizzo piazzato al posto del vero return address è un'istruzione valida, in tal modo il programma continuerà l'esecuzione, ma la logica esecutiva del programma sarà differente rispetto l'originale.

Sfruttare (exploiting) una vulnerabilità di buffer overflow
...

Sfruttando il buffer overflow di un programma possiamo causare, la chiusura del programma o l'esecuzione di codice arbitrario.
Vediamo, prima per sommi capi, poi in modo dettagliato, come è possibile sfrutta buffer overflow per ottenere tale risultato. Nel programma precedente, non viene preso in input nulla dall'esterno, l'attaccante non può in alcun modo trarre vantaggio da questo. Nelle applicazioni reali, i programmi di solito prendono input dagli utenti. Vediamo il seguente esempio

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int foo(char* str){
	char buffer[100];

	strcpy(buffer, str);
	return 1;
}

int main(){
	char str[400];
	FILE* badfile;

	badfile = fopen("badfile", "r");
	fread(str, sizeof(char), 300, badfile); // mette in str 300 byte presi da badfile
	foo(str);

	printf("Returned properly\n");
	return 1;
}
C

I 300 byte letti dal file e poi copiati nel buffer di 100 byte (della funzione foo) causerà chiaramente overflow. I dati scritti nel buffer provengono da un file inviato dall'utente.
La domanda è:

Cosa scrivere in badfile affinché, dopo l'overflow, possiamo eseguire codice nostro, arbitrario?

Abbiamo bisogno di inserire il nostro codice in memoria del programma in esecuzione, prima di tutto. Questo non è difficile. Possiamo semplicemente scrivere il nostro codice in badfie, così che il programma legge dal file, il codice viene caricato nell'array str[400]; quando il programma copia str nel buffer di foo, il codice verrà scritto nello stack.
Pasted image 20230922165545.png
Si noti che l'inizio del file è in basso, e la fine è in alto.
Nel nostro file il codice è stato messo alla fine del file.
A questo punto dobbiamo forzare il programma a saltare al nostro pezzo di codice (malicious code). Per farlo, dobbiamo sovrascrivere il valore del campo di return address. In questo modo, quando foo ritorna, il programma salterà al nuovo indirizzo, dove si trova il nostro codice.

Di seguito sistemeremo un ambiente isolato (macchina virtuale), dove tenteremo di exploitare vulnerabilità relative a buffer overflow.

Setup dell'ambiente per fare gli esperimenti
...

Per effettuare attacchi di stack buffer overflow, utilizzeremo una macchina virtuale con sistema operativo Ubuntu. Poiché il buffer overflow ha una lunga storia, molti sistemi operativi hanno già sviluppato delle contromisure contro questo tipo di attacco. Per semplificare gli esperimenti, dobbiamo disattivare tali contromisure. Dopo le riattiveremo e mostreremo che alcune di queste contromisure rendono gli attacchi di buffer overflow più difficili e non impossibili.

Disabilitare randomizzazione degli indirizzi
...

Una delle contromisure contro il buffer overflow è ASLR (Address Space Layout Randomization). Questa funzione randomizza la disposizione in memoria di un processo. Ovvero, ogni qual volta un processo viene avviato, la spazio in cui vengono caricati i componenti del processo (stack, heap, area dati) cambia casualmente. Per disattivarlo:
sudo sysctl -w kernel.randomize_va_space=0
(Al riavvio avviene un reset di questo valore, dunque eventualmente sarà necessario modificarlo ad ogni avvio.)

Programmi vulnerabili
...

Il nostro obiettivo è sfruttare la vulnerabilità di buffer overflow con un Set-UID root. Un programma eseguito con tali privilegi dà ad un normale utente privilegi in più quando esegue il programma. Se il buffer overflow può essere sfruttato in un programma con privilegi di root, il codice malevolo, se eseguito, girerà con privilegi elevati. Utilizzeremo il programma vulnerabile sopra (stack.c) come target. Il programma può essere compilato e abilitato per l'esecuzione con Set-UID root, con i seguenti comandi:

gcc -o stack -z execstack -fno-stack-protector stack.c
sudo chown root stack
sudo chmod 4755 stack

Funzionamento dei comandi:

  • -z execstack: di default, lo stack non è eseguibile, questo previene l'esecuzione di codice iniettato. Questo contromisura è chiamata non-executable-stack. Un programma, tramite un marcatore speciale nel binario, comunica al sistema operativo se il suo stack dovrebbe essere eseguibile oppure no. Tale marcatore nel binario è applicata dal compilatore. Il compilatore gcc di default ha tali marcatori attivi, usando il comando sopra rendiamo lo stack esegubile. Questa contromisura può essere sconfitta utilizzato attacchi di tipo return-to-libc (che affronteremo più avanti).
  • fno-stack-protector: questo comando disattiva una contromisura chiamata Stack-Guard. L'idea principale è di aggiungere alcuni dati speciali e meccanismi di controllo del codice, in modo che quando si verifica buffer overflow venga rilevato. Anche questa è attiva di default nel codice, usando il comando sopra la disattiviamo.

Per capire il funzionamento del programma, mettiamo dei caratteri casuali in badfile. Ci accorgeremo che quando la dimensione del file è inferiore a 100 byte, il programma esegue senza problemi. Quando invece la dimensione del file è superiore a 100 byte, il programma potrebbe crashare. Questo è ciò che ci si deve aspettare in attacchi di tipo buffer overflow.
Vediamo i seguenti esperimenti:
Pasted image 20230925100638.png
con il comando: echo "aaaa" > badfile abbiamo semplicemente redirezionato (>) "aaaa" in un file con nome badfile. Come si vede usando meno di 100 caratteri va tutto bene, con più di 100 caratteri si verifica overflow.

Conduzione dell'attacco di buffer overflow
...

Il nostro obiettivo è sfruttare la vulnerabilità di buffer overflow nel programma stack.c che viene eseguita con privilegi di root. Dobbiamo costruire badfile in modo che quando il programma copia i contenuti del file nel buffer, avvenga overflow del buffer, e il codice malevolo iniettato, ci consenta di eseguire comandi shell con privilegi di root.

Trovare l'indirizzo del codice iniettato
...

Per essere in grado di saltare al nostro codice malevolo, abbiamo bisogno di conoscere l'indirizzo di memoria del codice malevolo. Sfortunatamente, non sappiamo con certezza dove si trova il codice malevolo. Sappiamo solo che il nostro codice è copiato nel buffer del programma sullo stack, ma non conosciamo l'indirizzo di memoria del buffer, poiché la sua posizione esatta dipende dall'uso che il programma fa dello stack.
Conosciamo l'offset del codice malevolo nel nostro file, ma abbiamo bisogno di conoscere l'indirizzo della funzione foo nello stack frame per calcolare esattamente dove in nostro codice deve essere posizionato. Sfortunatamente, il programma target non stampa i valori del suo frame pointer o l'indirizzo di qualsiasi variabile all'interno dello stack. L'unica cosa che possiamo fare è tirare ad indovinare. In teoria, l'area di ricerca è estesa indirizzi (per una macchina a 32 bit), ma in pratica, lo spazio è molto meno ampio di così. Due fatti restringono il campo di ricerca:

  • prima che le contromisure fossero introdotte, molti sistemi operativi piazzavano lo stack (ogni processo ne ha uno) ad un indirizzo iniziale fisato. Si noti che tale indirizzo è virtuale e che viene mappato ad un differente area di memoria fisica per ogni processo. Dunque, non vi è alcun conflitto per processi differenti nell'usare lo stesso indirizzo virtuale per il proprio stack.
  • Molti programmi non hanno uno stack molto profondo. Tipicamente la catena di chiamate di funzioni non è molto lunga.
    Combinando questi fatti, possiamo dire che lo spazio di ricerca è ridotto rispetto a , per cui indovinare l'indirizzo corretto è abbastanza semplice.
    Per verificare che lo stack comincia sempre allo stesso indirizzo, possiamo utilizzare il seguente programma che non stampa altro che l'indirizzo di una variabile locale.
#include <stdio.h>

void func(int* a1){
	printf("indirizzo di a1: 0x\%\n", (unsigned int)&a1);
}

int main(){
	int x = 3;
	func(&x);
	return 1;
}
C

Compilando il codice sopra, tenendo l'ASLR disattivata, vedremo che dopo aver compilato (gcc prog.c -o prog) ed eseguito il programma, l'indirizzo della variabile, ad ogni esecuzione sarà sempre lo stesso.
Pasted image 20230925104306.png

Migliorare la possibilità di indovinare l'indirizzo
...

Affinché la nostra ipotesi abbia successo, abbiamo bisogno di indovinare l'entry point esatto in cui il nostro codice viene iniettato. Se sbagliamo di un byte, falliamo. Quello che possiamo fare è creare diversi entry point per il codice iniettato, ovvero inserire delle istruzioni ancora prima del codice vero e proprio. L'idea è di aggiungere molte istruzioni No-Op (NOP) prima del vero entry point per il nostro codice. L'istruzione NOP non fa niente di significativo, se non che far avanzare il program counter alla posizione successiva. In questo modo, al posto di azzeccare un unico entry point per il codice malevolo, allarghiamo le chance di azzeccare un entry point qualsiasi. Beccata una NOP qualunque, il codice scorrerà fino a che non otterremo l'entry point effettivo del nostro codice. L'idea è illustrata nella figura sottostante:
Pasted image 20230925105149.png
Riempiendo la regione sotto il return address con valori NOP, possiamo creare diversi entry point per il nostro codice. Nella figura a sinistra abbiamo uno solo entry point per il nostro codice, il nuovo return address, sbagliare è molto probabile. Nel caso a destra abbiamo aggiunto diversi punti di ingresso per il nostro codice, dove prima o poi verrà eseguito.

Trovare l'indirizzo senza indovinare
...

Se gli attaccanti sono nella stessa macchina target, possono ottenere una copia del programma vittima, investigare un po', e conoscere l'indirizzo per il codice iniettato senza indovinare. Questo metodo non è applicabile per attacchi remoti, dove gli attaccanti provano ad iniettare codice da un'altra macchina. Gli attaccanti in remoto, potrebbero non avere una copia del programma vittima e non possono nemmeno investigare sulla macchina target.
Sfrutteremo il debugging per trovare il punto in cui lo stack frame risiede nello stack, e lo useremo per capire in quale punto il nostro codice si trova.
In questo esperimento abbiamo il codice sorgente del programma target, possiamo compilarlo con il flag di debug attivo.
gcc -z execstack -fno-stack-protector -g -o stack_dbg stack.c

  • i primi due comandi li abbiamo già discussi: disabilitano alcune protezioni
  • -g aggiunge informazioni di debugging al binario
  • il programma compilato stack_dbg (-o serve per dare un nome specifico al file ottenuto dalla compilazione) viene poi usato in un tool gdb per effettuare il debugging.
    Dobbiamo creare un file col nome badfile prima di eseguire il programma. Con il comando touch badfile creiamo un file vuoto (dobbiamo prima rimuovere quello che abbiamo creato prima con echo "aaaa...aaa">badfile).

In gdb possiamo impostare un breakpoint in foo usando b foo, e poi possiamo incominciare ad eseguire il codice usando run. Il programma si fermerà quando entrerà in foo. Si noti che possiamo impostare tale breakpoint poiché, avendo ispezionato il sorgente, sappiamo che esiste una funzione foo all'interno del programma. Arrivati in foo possiamo visualizzare il valore del frame pointer ebp e l'indirizzo del buffer usando il comando p in gdb.

1) Apertura gdb
...

Pasted image 20230925143438.png

2) Impostazione di breakpoint in foo ed esecuzione run
...

Pasted image 20230925143507.png
A questo punto gdb chiede e chiederà ad ogni uso se si vuole utilizzare un funzione chiamata debuginfo, io ho risposto si, ma potete anche rispondere no, tanto non è utile.

3) Stampa degli indirizzi del registro e del buffer
...

Pasted image 20230925143532.png

Analisi risultati
...

Dai risultati sopra, possiamo vedere che il valore del frame pointer è 0xbfffee88. Basandoci sulla figura:
Pasted image 20230925105149.png
possiamo dire che il return address è salvato in 0xbfffee88 + 4 e il primo indirizzo a cui possiamo saltare è 0xbfffee88 + 8 (ovvero proprio sopra il return address). Quello che possiamo fare è mettere l'indirizzo 0xbfffee88 + 8 nel campo del return address.

Dentro l'input, dov'è il campo return address?

Dal momento che il nostro input sarà copiato nel buffer a cominciare dal suo inizio. Abbiamo bisogno di sapere, in quale punto della memoria il buffer incomincia, e qual è la distanza tra il punto di inizio del buffer e il campo del return address. Sempre con gdb possiamo calcolare la distanza tra ebp e il punto di inizio del buffer.
Pasted image 20230925143919.png
/d serve per dire a gdb di stampare il risultato in formato decimale.
Il risultato che ci ha dato è 108.
Ricordiamo che tale risultato è la distanza tra l'inizio del frame pointer e l'inizio del buffer (da qualche parte nello stack).
Noi vogliamo la distanza tra il return address e l'inizio del buffer. Sappiamo che il return address è 4 byte sopra il frame pointer, dunque basta aggiungere 4 al risultato ottenuto: che è la distanza tra il return address e l'inizio del buffer.

Adesso costruiamo il file di input da passare al programma
...

Adesso possiamo costruire il contenuto per badfile. La figura di seguito illustra la struttura del file.
Pasted image 20230925144716.png
La parte a sinistra è la parte sotto il return address, mentre la parte a destra e la parte sopra.
Il frame pointer punta a 0xbfffee88, a 0xbfffee88 + 4 vi è il return address mentre a 0xbfffee88 + 8 vi è la regione sopra il return address (la prima posizione possibile in cui piazzare delle NOP). L'inizio del buffer è a 0xbfffee1c, la distanza da questo indirizzo al campo di return address è 112 (calcolata nel box giallo sopra).

A questo punto utilizzeremo quella che viene chiamata shellcode. Una shellcode non è altro che un insieme di istruzioni in formato binario. Si noti che con formato non intendiamo assembly, ma vere e proprie istruzioni macchina. Queste istruzioni non sono altro che la conversione in binario di istruzioni assembly, per effettuare una traduzione di questo tipo è necessario conoscere (attraverso una mappa magari), a quale codice esadecimale corrisponde ciascuna istruzione. Tuttavia, riuscendo a scrivere in linguaggio assembly è possibile effettuare la traduzione in automatico mediante dei tool online. Si noti però che il linguaggio assembly deve essere scritto in un certo modo. Di seguito spieghiamo come si scrivono le shellcode in un file dedicato: Come scrivere shellcode.

Dal momento che il file deve contenere dati binari che sono difficili da scrivere con un editor di testo, possiamo scrivere un programma in python chiamato exploit.py per generare il file. Il codice è mostrato di seguito:
Pasted image 20230926143819.png
La shellcode è l'insieme delle istruzioni assembly nella codifica esadecimale.

  • content = bytearray(0x90 for in range(300)), costruisce un array di byte, in cui piazza in tutte le posizioni il valore esadecimale per No-Op (0x90)
  • start = 300 - len(shellcode), calcola in quale punto del file dovrebbe essere copiato il codice malevolo, visto che vogliamo copiare il codice malevolo alla fine e visto che sappiamo quanto è grande il nostro file (300 byte) allora il punto in cui dovremmo cominciare a copiare è start = 300 - lunghezza(shellcode)
  • content[start:] = shellcode, copia della shellcode a partire da start
  • in base ai risultati ottenuti da gdb, il campo di return address comincia dal byte 112 e termina al byte 116 (non incluso). Quando mettiamo un numero con più byte nella memoria, abbiamo bisogno di considerare l'ordinamento dei byte dell'architettura in questione: in questo caso little endian. Quindi con la linea content[112:116] = (ret).to_bytes(4, byteorder='little'), abbiamo ordinato i byte contenuti in ret con l'ordinamento little(-endian) e abbiamo indicato che l'indirizzo è di 4 byte.
  • ret = 0xbfffee88 + 120 è l'indirizzo che abbiamo scritto nel punto [112:116] del file, che corrisponde con l'indirizzo di ritorno. L'indirizzo di ritorno che avremmo dovuto scrivere secondo i nostri risultati con gdb sarebbe dovuto essere: 0xbfffee88 + 8, mentre noi abbiamo scritto 0xbfffee88 + 120, qual è il motivo di ciò? Il motivo è che l'indirizzo 0xbfffee88 è stato identificato utilizzando il debugging, lo stack frame della funzione foo potrebbe essere differente quando eseguiamo il programma attraverso un debugger, gdb ha potuto mettere all'inizio, dei dati in più sullo stack, facendo si che lo stack sia più profondo di quando dovrebbe essere quando il programma è eseguito senza debugger. Quindi adesso, lo stack potrebbe essere più corto, per questo abbiamo aggiunto un numero di byte maggiore. (Ricordiamo che lo stack cresce verso il basso: quando si sommano dei byte, lo stack si accorcia (andiamo verso indirizzi più alti, verso l'alto), quando si sottraggono byte, lo stack si estende (andiamo verso indirizzi più bassi, verso il basso)). Un'altra cosa importante da notare è che, il numero di byte che aggiungiamo non deve contenere zeri, infatti, usando strcpy, verrebbe interrotta la copia del file sullo stack, e l'attacco potrebbe non riuscire, per esempio avendo un indirizzo come 0xbfffeaf8 + 8 = 0xbffeb00. I due zeri alla fine fanno un byte zero il che è un problema.

La navigazione permette di consultare altri casi di studio e la loro risoluzione passo dopo passo: