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.
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.
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
.
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).
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.
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:
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 è:
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.
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.
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.
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.)
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:
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.
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.
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
#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.
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:
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.
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
-g
aggiunge informazioni di debugging al binariostack_dbg
(-o
serve per dare un nome specifico al file ottenuto dalla compilazione) viene poi usato in un tool gdb
per effettuare il debugging.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
.
foo
ed esecuzione run
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.
Dai risultati sopra, possiamo vedere che il valore del frame pointer è 0xbfffee88
. Basandoci sulla figura:
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.
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./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:
Adesso possiamo costruire il contenuto per badfile
. La figura di seguito illustra la struttura del file.
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:
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
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: