L’overflow del buffer è una delle tecniche più avanzate di hacking del software: se utilizzato a dovere può agevolare l’accesso a qualsiasi sistema che utilizza un programma vulnerabile. Il termine (abbreviato in BOF) capita talmente spesso sugli schermi dei nostri computer che probabilmente ci suona ormai familiare. Buona parte degli exploit che troviamo sui siti specializzati infatti sfruttano diverse varianti dell’overflow per raggiungere i loro scopi; questa guida si prefigge di far capire i meccanismi che ne regolano il funzionamento tramite esempi pratici.

Prima di tutto, consideriamo una semplice definizione: parliamo di buffer overflow quando una stringa di input è più grande del buffer (memoria) che la dovrà contenere. Questo comporta un trabocco (overflow) che finisce per sovrascrivere porzioni di memoria destinate ad altre istruzioni. Dato che nessun sistema è immune, questa tecnica può colpire senza alcuna differenza le applicazioni di Linux e Windows.

Durante l’esecuzione di un programma, le funzioni hanno la necessità di archiviare i dati che sono oggetto dell’elaborazione. La zona di memoria fornita dal sistema per salvare i dati è detta stack (pila). Immaginiamo lo stack come un contenitore.

Quando è necessario, lo stack assume le dimensioni richieste dalla funzione per contenere i dati. Può capitare che lo spazio non sia sufficiente: se la funzione non si accorge dell’errore, i dati vengono memorizzati comunque finendo per sovrascrivere e corrompere lo stack. Ecco dunque una attività ricorrente negli attacchi al buffer: colpire lo stack, in inglese smashing the stack.

Come vedremo in seguito, nel linguaggio di basso livello Assembler esistono dei puntatori che definiscono l’andamento dell’applicazione. Quando una funzione richiama quella successiva, il puntatore alla prima funzione viene salvato nello stack sotto forma di indirizzo, in modo che il programma possa ritornare alla funzione principale.

Lo scopo dell’overflow dello stack è proprio di sovrascrivere questo indirizzo di ritorno. Dato che, come abbiamo detto, il flusso di dati trabocca oltre lo spazio definito dallo stack, anche il puntatore viene corrotto e sostituito dal codice preparato ad hoc dal cracker.

Per poter capire a fondo gli effetti dell’overflow è necessario introdurre alcune nozioni sull’architettura dei processori Intel. Abbiamo già parlato dello stack, raffigurato come un contenitore dove possiamo memorizzare temporaneamente delle informazioni per poi estrarle quando ne abbiamo bisogno. E’ molto importante avere un puntatore che tiene traccia della posizione dei dati immessi; a questo proposito vediamo quali registri vengono utilizzati dal sistema:

EBP, o Base Pointer. E’ il puntatore alla base dello stack, serve per definire dove iniziano i dati memorizzati nello stack;

ESP, o Stack Pointer. Punta all’attuale posizione nello stack, serve a inserire o estrarre dati nella posizione desiderata;

EIP, o Instruction Pointer. Punta all’istruzione da eseguire, cioè a quella successiva rispetto alla posizione corrente.

La conoscenza delle istruzioni di base dell’Assembler è essenziale per capire gli esempi che seguono. Ecco quelle più utili alla nostra "causa":

PUSH, aggiunge informazioni nello stack;

POP, rimuove i dati dallo stack (secondo una struttura LIFO, gli ultimi dati aggiunti sono i primi ad essere rimossi -> Last In First Out);

CALL, effettua un salto incondizionato a una funzione e inserisce l’indirizzo dell’istruzione successiva (EIP) nello stack;

RET, estrae un indirizzo di ritorno dallo stack per ritornare alla funzione principale

Ogni routine ha inizio con un CALL e termina con un RET. Se l’aggressore è riuscito a sovrascrivere l’indirizzo di ritorno, nella fase di RET può ottenere il totale controllo del processore.

 

DENTRO IL CODICE

Esaminiamo una semplice applicazione in C che utilizza una funzione vulnerabile ad attacchi di overflow del buffer

 

01 void func(void)

02 {

03 char bof[20];

04 gets(bof);

05 }

(Funzione vulnerabile)

Nel listato troviamo la definizione di una variabile bof di tipo char alla terza riga, alla quale vengono riservati 20 bytes, e la chiamata alla funzione gets incaricata di raccogliere l’input dell’utente e salvarlo nella variabile. Quando viene invocata la funzione (CALL), il processore salva in memoria il valore contenuto in EIP; in questo modo, dopo il RET può riprendere l’esecuzione dall’istruzione immediatamente successiva alla chiamata.

Una porzione del codice Assembler corrispondente mostra come avviene invece il salvataggio del puntatore EBP (che, ricordiamo, si riferisce alla base dello stack utilizzato finora) e l’allocazione dello spazio destinato alle variabili.

 

01 push ebp

02 mov ebp, esp

03 sub esp, 20

(Codice Assembler)

01. Le istruzioni della prima riga salvano il valore del registro EBP nello stack;
02. La posizione attuale nello stack (ESP) viene memorizzata in EBP, così da diventare la nuova base (dove iniziano i dati della funzione);
03. Riserva la memoria necessaria alla variabile bof (per chi preferisce i linguaggi di alto livello, la riga equivale a "esp = esp – 20").

Il problema ovviamente si presenta quando la stringa di input è maggiore dei 20 bytes allocati e l’overflow corrompe lo stack. Ora che sappiamo riconoscere le applicazioni a rischio possiamo tuffarci in un’esercitazione pratica, molto più stimolante della pura teoria.

Prima di iniziare con l’esercitazione pratica assicuriamoci di avere tutti gli strumenti necessari a portata di mano. Per questo esempio bastano un compilatore e un debugger, entrambi scaricabili gratuitamente:

1. Dev-C++ 4.0 – http://victorhacking.altervista.org/devcpp4.zip
2. GoVest 0.9 – http://victorhacking.altervista.org/govest.zip

Dopo aver installato il compilatore, eseguiamolo e apriamo un nuovo progetto (File > New Project > Console application) selezionando "C project". Diamo l’ok e inseriamo un nome per il progetto (es: test), quindi salviamo il file come ci viene richiesto. Nella finestra principale scriviamo il codice della nostra applicazione

#include <stdio.h>

#include <string.h>

 

void func(char *p)

      {

char stack_temp[20];

strcpy(stack_temp, p);

printf(stack_temp);

       }

 

int main(int argc, char* argv[])

       {

func("QUESTO TESTO PROVOCA UN OVERFLOWxxxxyyyy");

return 0;

       }

(Listato di test.c)

La funzione vulnerabile in questo caso è strcpy perchè non controlla se la stringa inserita dall’utente è più grande del buffer riservato (20). L’input dell’utente viene simulato dalla stringa che abbiamo preparato, cioè "QUESTO TESTO PROVOCA UN OVERFLOWxxxxyyyy". E’ evidente a tutti che la stringa supera i 20 caratteri (si conteggiano anche gli spazi), però come facciamo a sapere dove vanno a finire i caratteri in più e come creare una stringa adeguata allo scopo?

Prima di tutto compiliamo il listato ed eseguiamo il programma che abbiamo creato (da Execute > Compile and Run). Se tutto è andato per il verso giusto, Windows ci avviserà che si è verificato un errore in test.exe e quindi l’applicazione dovrà essere terminata. Notiamo i dettagli della segnalazione il programma è terminato bruscamente quando ha cercato di leggere l’offset (indirizzo) 79797979.

Un indirizzo formato dallo stesso numero che si ripete è abbastanza curioso…infatti qualsiasi editor esadecimale potrà confermarvi che "79" equivale a "y", e ovviamente "yyyy" è la parte terminale della nostra stringa. L’applicazione ha eseguito il codice che noi abbiamo passato come input, ha interpretato i caratteri in più come offset e – cosa che non dovrebbe mai succedere – ha cercato di leggere quell’indirizzo. Complimenti: il vostro primo buffer overflow!

 

UNA INSTANTANEA

Per comprendere meglio cosa è successo all’interno dei registri, aiutiamoci con il debugger. Questa applicazione ci serve per eseguire il nostro programma passo passo, così da visualizzare le operazioni compiute in Assembler e i valori contenuti nei registri in qualsiasi momento. Entriamo in GoVest e apriamo test.exe selezionandolo da File > Load process, quindi eseguiamolo con F5 (oppure Debug > Run). Non lasciamoci intimorire dalla quantità di informazioni visualizzate. L’area di lavoro è organizzata molto semplicemente: il codice Assembler a sinistra, il contenuto dei registri e l’editor esadecimale a destra.

Continuiamo l’esecuzione tramite Debug > Step over, quindi premiamo F10 per avanzare di un passo per volta. Saremo interrotti da un avviso di errore simile al precedente, sempre riguardo all’offset 79797979. A differenza di prima, però, abbiamo a disposizione una istantanea dei registri al momento del "crash"

Come abbiamo visto nella parte teorica lo stack riserva lo spazio alle variabili, quindi al puntatore della base e infine all’indirizzo di ritorno. Ora abbiamo la conferma tangibile: la stringa "xxxx" è stata memorizzata in EBP (78 equivale a x in esadecimale), mentre i caratteri successivi "yyyy" in EIP (indirizzo di ritorno). Questi offset non esistono e il programma si blocca…ma immaginiamo di passare degli indirizzi validi come stringa: a quel punto saremmo in grado di far eseguire del codice arbitrario al programma e ottenere il controllo del sistema!

Prima di passare dall’altra parte della barricata, ossia la protezione dagli attacchi, chiariamo un ultimo dubbio: visto che la memoria riservata alla variabile è di 20 caratteri, come mai i registri vengono sovrascritti dai valori passati solo a partire dalla fine del carattere n°32? Dove finiscono i rimanenti 12 caratteri? Per capire di cosa stiamo parlando, provate a contare il numero di caratteri della stringa utilizzata, spazi compresi. Questo è molto importante per imparare a creare codici di lunghezza adeguata.

La risposta è semplice: possiamo vedere dal listato che sono presenti altre variabili, oltre a quella da 20 bytes. Per convenzione vengono riservati 4 byte per ciascuna, se non indicato diversamente. Nella creazione dell’input per l’overflow dovremo quindi tenere conto di questi valori:

20 bytes riservati a stack_temp

4 bytes per la variabile p

4 bytes per argc

4 bytes per argv

4 bytes per il registro EBP

4 bytes per l’indirizzo di ritorno in EIP

Facendo la somma dei primi 4 valori considerati (20+4+4+4=32), appare evidente che i registri inizieranno a essere sovrascritti proprio a partire dal carattere successivo al 32! Sapendo questo, abbiamo impostato la stringa di modo che la prima "x" fosse alla posizione n°33.

PROGRAMMAZIONE SICURA

Per poterci proteggere dagli attacchi di buffer overflow siamo costretti ad affidarci alla professionalità dei programmatori. Selezioniamo bene i programmi da utilizzare, dunque, e cerchiamo di restare al passo con gli updates che risolvono i problemi delle applicazioni. Le normali protezioni contro gli attacchi, come i firewall, in questo caso servono a poco. Anzi, il firewall stesso potrebbe essere soggetto a un buffer overflow.

Se siamo programmatori, invece, ricordiamoci di effettuare maggiori controlli sull’input degli utenti e non utilizziamo le funzioni a rischio

Strcpy                wscanf

lstrcpyA              lstrcpy

lstrcpyn             lstrcpyW

lstrcpynW          lstrcpynA

strncpy              wstrcpy

sptrintf             wstrncpy

gets                 swptrinf

strcat                getws

lstrcatW            lstracat

strncat               wcscat

memcpy              wstrncat

scanf                memmove

fgets

(Funzioni a rischio)