Faremo prima una panoramica generale delle contromisure disponibili oggi per difendersi dal buffer overflow.
Alcune delle funzioni di copia su memoria fanno affidamento su un carattere speciale nei dati per decidere quando la copia in memoria deve terminare. Questo è pericoloso, perché la lunghezza che può essere copiata in memoria viene decisa da quanti dati vengono immessi, consentendo all'utente di copiare quanti dati vuole in memoria. Un approccio più sicuro è di mettere nelle mani degli sviluppatori la decisione di quanti dati debbano essere copiati, specificando la lunghezza dei dati che si devono copiare (nel codice).
Per funzioni di copia in memoria come strcpy
, sprinft
, strcat
e gets
esistono le loro versioni più sicure: strncpy
, snprintf
, strncat
e fgets
. La differenza è che le versioni più sicure richiedono agli sviluppatori di esplicitare la massima lunghezza dei dati che devono essere copiati nel buffer target. Queste funzioni sono relativamente sicure, dato che se uno sviluppatore specifica una lunghezza maggiore rispetto a quanto il buffer è grande si potrà comunque sfruttare il buffer overflow.
I compilatori sono responsabili di tradurre il codice sorgente in binario. Loro controllano quale sequenza di istruzioni vengono messe sul file binario. Questo fornisce ai compilatori l'opportunità di controllare lo stato dello stack. Inoltre i compilatori possono aggiungere delle istruzioni per verificare l'integrità dello stack. Due contromisure ben conosciute basate sui compilatori sono:
Prima che un programma venga eseguito, ha bisogno di essere caricato nel sistema e l'ambiente di esecuzione deve essere settato. Il procedimento di settaggio fornisce un opportunità per contrastare il buffer overflow perché può decidere come deve essere disposta la memoria del programma. Una contromisura comune implementata dai sistemi operativi è l'Address Space Layout Randomization (ASLR). Questo consente di ridurre la possibilità di buffer overflow, in particolare, l'attaccante deve essere in grado di indovinare l'indirizzo del codice iniettato. ASLR randomizza la disposizione della memoria del programma.
L'attacco di buffer overflow descritto nel file memory corrumption con buffer overflow dello stack dipende dall'esecuzione di una shellcode che abbiamo posizionato nello stack. Le moderne CPU supportano una feature chiamata NX bit. NX bit sta per No-eXecute, è una tecnologia usata nelle CPU per separare il codice dai dati. I sistemi operativi possono marcare certe aree di memoria come non-eseguibili, in questo modo attacchi come quello eseguito prima non sono possibili, se lo stack è marcato come non-eseguibile. Questa contromisura può essere abbattuta utilizzando una diversa tecnica chiamata return to libc che noi non affronteremo per gli scopi del corso.
Per avere successo in un attacco di buffer overflow, gli attaccanti hanno bisogno di modificare il return address del programma vulnerabile per iniettare il proprio codice. La maggior parte dei sistemi operativi in passato piazzavano lo stack in una posizione della memoria fissata rendendo facile all'attaccante indovinare la posizione del return address.
É davvero necessario far iniziare lo stack da una posizione fissata della memoria? La risposta è no.
Quando un compilatore genera il binario da un codice sorgente, per tutti i dati che sono salvati nello stack gli indirizzi non sono hard-coded, ovvero non vengono scelti prima, i loro indirizzi sono calcolati basandosi sul frame pointer ebp
e lo stack pointer esp
. Vale a dire che gli indirizzi dei dati sullo stack sono rappresentati come degli scostamenti (offset) relativamente a uno o di questi due registri, invece che dal punto in cui inizia lo stack. Per cui, anche se lo stack cominciasse da un'altra locazione di memoria, se ebp
e esp
sono ben settati, i programmi possono sempre accedere ai loro dati sullo stack senza alcun problema.
Gli attaccanti necessitano di indovinare l'indirizzo assoluto, invece che un offset, per cui conoscere l'esatta posizione dello stack è importante. Se il punto in cui comincia lo stack fosse casuale, si renderebbe difficile la vita agli attaccanti. Questa è l'idea di ASLR.
Per eseguire un programma, un sistema operativo deve caricare il programma in memoria per prima cosa. Durante il processo di caricamento, il loader prepara lo stack e lo heap per il programma. Per questo l'indirizzamento casuale dello stack è normalmente implementato nel loader. Per Linux, ELF
è un comune formato binario per i programmi, per cui per questo tipo di programmi binari, la randomizzazione è eseguito dall'ELF loader
.
Per vedere come funziona la randomizzazione, scriviamo un semplice programma con due buffer, uno sullo stack e uno sullo heap. Stamperemo i loro indirizzi per vedere dove lo stack e lo heap si trovano ogni volta che eseguiamo il programma:
#include <stdio.h>
#include <stdlib.h>
int main(){
char x[12];
char* y = malloc(sizeof(char)*12);
printf("Address of buffer x (on stack): 0x%x\n", x);
printf("Address of buffer y (on heap): 0x%x\n", y);
}
C
Dopo aver compilato questo pezzo di codice (a.out
), lo eseguiamo utilizzando diverse impostazioni di randomizzazione. Utenti privilegiati possono dire al loader che tipo di indirizzamento casuale vogliono settando un variabile del kernel chiamata: kernel.randomize_va_space
. Quando è impostata a 0, non vi è nessun indirizzamento casuale. Quando è impostata a 1 abbiamo indirizzamento casuale dello stack, quando è impostata a 2 abbiamo indirizzamento casuale sia per lo stack che per lo heap.
$ sudo sysctl -w kernel.randomize_va_space=0
kernel.randomize_va_space = 0
$ a.out
Address of buffer x (on stack): 0xbffff370
Address of buffer y (on heap): 0x804b008
$ a.out
Address of buffer x (on stack): 0xbffff370
Address of buffer y (on heap): 0x804b008
// Randomizing stack address
$ sudo sysctl -w kernel.address_va_space=1
Address of buffer x (on stack): 0xbf9deb10
Address of buffer y (on heap): 0x804b008
$ a.out
Address of buffer x (on stack): 0xbf8c49d0 (è cambiato)
Address of buffer y (on heap): 0x804b008
// Randomizing both stack and heap address
$ sudo sysctl -w kernel.address_va_space=2
Address of buffer x (on stack): 0xbf9c76f0
Address of buffer y (on heap): 0x87e6008
$ a.out
Address of buffer x (on stack): 0xbfe69700 (è cambiato)
Address of buffer y (on heap): 0xa020008 (è cambiato)
bash
L'efficacia dell'indirizzamento casuale, dipende da diversi fattori. Una completa implementazione di ASLR in cui tutte le aree del processo sono localizzate in posizioni casuali potrebbe causare problemi di compatibilità. Una seconda limitazione, a volte, è il ridotto range di indirizzi disponibili per la randomizzazione.
Un modo per misurare la disponibilità di randomizzazione nello spazio degli indirizzi è l'entropia. Se una regione di memoria ha entropia pari a
L'osservazione chiave di StackGuard è che per portare a termine un attacco di buffer overflow deve essere modificato il return address e, di conseguenza, la porzione di memoria compresa tra il buffer e il return address, proprio perché funzioni come strcpy
o memcpy
copiano i dati in locazioni contigue di memoria.
Se vogliamo preservare il valore in una particolare zona di memoria durante la copia di dati in memoria, come la zona evidenziata nell'immagine sopra Guard
, l'unico modo per farlo è sovrascrivere Guard
, con lo stesso valore che conteneva prima.
Basandoci su questa osservazione, possiamo mettere un valore non predicibile (chiamato guard
) tra il buffer e il return address. Prima di ritornare dalla funzione chiamata, controlliamo se il valore è stato modificato o no. Capire se il return address è stato modificato o no, si riduce a controllare se guard
è stato sovrascritto con un valore diverso da quello che c'era prima. Questi problemi sembrano essere gli stessi, ma non lo sono. Ma dal momento ce il valore di guard
è stato posizionato da noi, è facile capire se è stato modificato o no.
Guardiamo il codice seguente e pensiamo ai punti in cui possiamo aggiungere manualmente codice per capire se si è verificato buffer overflow.
void foo(char* str){
char buffer[12];
strcpy(buffer, str);
return;
}
C
Prima, posizioniamo una guardia (guard) tra il buffer e il return address. Possiamo facilmente ottenere questo risultato definendo una variabile locale all'inizio della funzione (prima del return address). Inizializziamo guard
con un valore segreto, il valore segreto è generato casualmente dal main
, così che tutte le volte che viene eseguito il programma, vi è un nuovo valore segreto. In questo modo l'attaccante, riempire il buffer è sapere, o azzeccare, il valore segreto della guardia.
Il valore segreto, non può essere salvato nello stack, altrimenti il suo valore può essere sovrascritto. Heap, segmento dei dati e il segmento BSS possono essere utilizzati per questo segreto. Dovremmo notare che il valore segreto non dovrebbe mai essere hard-coded (presente direttamente nel codice). Nel codice che segue definiamo una variabile globale chiamata secret
e la inizializziamo con valore randomico generato dal main
(non mostrato). Come abbiamo imparato, la variabili globali non inizializzate vengono messe nel segmento BSS.
int secret;
void foo(char* str){
int guard;
guard = secret; // assegnamento del valore segreto a guard
char buffer[12];
strcpy(buffer, str);
if(guard == secret)
return;
else
exit(1);
}
C
Dal codice sopra, possiamo solo vedere che prima di ritornare dalla funzione, dobbiamo sempre controllare se il valore nella variabile locale guard
è ancora lo stesso di quello delle variabile globale secret
. Se sono ancora uguali, il return address è sicuro; altrimenti c'è un'alta possibilità che è stato sovrascritto, dunque il programma deve essere terminato.
gcc
L'aggiunta manuale di codice descritta prima illustra il modo in cui StackGuard funziona. Dal momento che il codice aggiunto non dipende dalla logica del funzionamento del progamma, possiamo chiedere al compilatore di fare automaticamente questa procedura per noi.
Il compilatore gcc
ha implementato Stackguard
.
Il canarino per StackGuard deve avere due proprietà:
gs
, che una zona di memoria speciale, diversa dallo stack, dallo heap, da BSS e dal segmento del testo. gs
è fisicamente isolato dallo stack,