Contromisure

Panoramica
...

Faremo prima una panoramica generale delle contromisure disponibili oggi per difendersi dal buffer overflow.

Funzioni più sicure
...

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.

Compilatori
...

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:

  • stackshield
  • stack guard
    L'idea di stackshield è di salvare una copia del return address in una zona di memoria sicura. Quando si utilizza questo approccio, all'inizio della funzione, il compilatore inserisce istruzioni per copiare il return address in una locazione di memoria non soggetta a overflow. Prima di ritornare dalla funzione chiamata, altre istruzioni confrontano il valore del return address corrente con quello salvato potendo determinare se è il return address è stato sovrascritto oppure no.
    Stack guard, invece, inserisce una guardia (guard) tra il return address e il buffer, in modo che se il return address viene modificato con buffer overflow, la guarda viene modificata pure. Quando si utilizza questo approccio, all'inizio della funzione, il compilatore aggiunge valori casuali sotto il return address e salva una copia di questo valore casuale (chiamato canarino, canary) in un posto sicuro che non sia lo stack. Prima di ritornare, il valore canary viene controllato con il valore salvato nel posto sicuro. L'idea è che se si è verificato overflow, il valore di canary è stato sovrascritto.
Sistemi operativi
...

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.

Architettura hardware
...

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.

Address Randomization
...

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.

Address Randomization in Linux
...

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
Efficacia dell'indirizzamento casuale
...

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 bit, ciò implica che su tale sistema ci sono possibili posizioni base (di partenza) per quella regione di memoria. L'entropia dipende dal tipo di ASLR implementato nel kernel. Per esempio, nei sistemi Linux a 32 bit, quando viene utilizzata ASLR statica (in cui le regioni di memoria eccetto quelle che contengono le istruzioni del programma sono randomizzate), l'entropia disponibile è 19 bit per lo stack e 13 per lo heap. In implementazioni in cui l'entropia disponibile per la randomizzazione non è abbastanza gli attaccanti possono risolvere con un attacco di forza bruta. Certe implementazioni di ASLR forniscono dei metodi per rendere gli attacchi di forza bruta impossibili. Un approccio è quello di prevenire che un eseguibile venga eseguito dopo un certo numero di volte che è crashato.

StackGuard
...

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

Aggiunta manuale del codice alla funzione
...

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.

Implementazione di StackGuard in 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à:

  • deve essere casuale
  • non può risiedere nello stack, questo secondo risultato viene raggiunto copiando il canarino in una zona di memoria puntata dal registro gs, che una zona di memoria speciale, diversa dallo stack, dallo heap, da BSS e dal segmento del testo. gs è fisicamente isolato dallo stack,