Con questo articolo diamo un’occhiata a quelle che sono le principali tecniche di debugging in ambito unix per capire meglio il funzionamento del linguaggio assembly.
Due sono i principali strumenti impiegati per questo genere di operazioni:
- Objdump è un tool che viene utilizzato per esaminare i file binari compilati
- Gdb è un debugger per seguire passo-passo il flusso dei programmi compilati
Tramite questi programmi è facile interrogare i registri presenti nella CPU per visualizzarne il contenuto e capire come viene allocata la memoria. Nel nostro esempio ho adottato una classica architettura Intel i386, presente nella maggior parte dei pc. In questo ambiente i principali registri con i quali la CPU lavora sono i seguenti:
EAX: accumulatore ECX: contatore EDX: dati EBX: base EIP: puntatore alla istruzione successiva ESP: puntatore della fine dello stack EBP: puntatore al frame corrente SPF: riporta EBP al valore precedente ESI: indice di origine EDI: indice di destinazione
Per effettuare le interrogazioni adotteremo il comando x, che sta appunto per examine, specificando anche come visualizzare l’output del registro esaminato:
x/x = esamina in esadecimale x/o = esamina in ottale x/u = esamina in decimale senza segno x/d = esamina in decimale x/s = esamina in stringa x/i = esamina un istruzione
Inoltre digitando un numero subito dopo lo slash è possibile specificare quanti byte interrogare. Ricordo che ogni lettera ASCII corrisponde ad un byte (ad esempio la lettera “A” corrisponde 0×41 scritto in esadecimale).
Analizziamo ora il nostro primo programma in C firstprog.c:
#include int main() { int i; for (i=0; i < 10; i++) { printf("Hello, world!\n"); } return 0; }
Questo programma non fa altro che stampare 10 volte di seguito la stringa “Hello world”. Tale codice anche se risulta molto semplice e banale, è tuttavia utile a capire in prima battuta il funzionamento dell’assembly. Procediamo dunque con la compilazione:
# gcc -g firstprog.c -o firstprogOra che abbiamo l’eseguibile passiamo all’analisi con gdb:
# gdb -q ./firstprog (gdb) set dis intel (gdb) list 1 #include 2 3 int main() 4 { 5 int i; 6 for (i=0; i < 10; i++) 7 { 8 printf("Hello, world!\n"); 9 } 10 return 0; (gdb)
Con l’opzione -q evitiamo di stampare banner inutili, mentre con l’opzione set dis intel abbiamo un output dell’assembly molto più leggibile.
Proseguiamo disassemblando il main e dando una prima occhiata alla locazione della memoria:
(gdb) disassemble main Dump of assembler code for function main: 0x080483c4 : lea ecx,[esp+0x4] 0x080483c8 : and esp,0xfffffff0 0x080483cb : push DWORD PTR [ecx-0x4] 0x080483ce : push ebp 0x080483cf : mov ebp,esp 0x080483d1 : push ecx 0x080483d2 : sub esp,0x14 0x080483d5 : mov DWORD PTR [ebp-0x8],0x0 0x080483dc : jmp 0x80483ee 0x080483de : mov DWORD PTR [esp],0x80484d0 0x080483e5 : call 0x80482f4 0x080483ea : add DWORD PTR [ebp-0x8],0x1 0x080483ee : cmp DWORD PTR [ebp-0x8],0x9 0x080483f2 : jle 0x80483de 0x080483f4 : mov eax,0x0 0x080483f9 : add esp,0x14 0x080483fc : pop ecx 0x080483fd : pop ebp 0x080483fe : lea esp,[ecx-0x4] 0x08048401 : ret End of assembler dump.
Questo output ci mostra la traduzione della funzione main del nostro programma in assembly. Ad un primo sguardo è possibile capire la sintassi: locazione della memoria in esadecimale: operatore registro destinazione, registro d’origine. Tuttavia in questo momento nessun registro risulta visualizzabile, proprio perchè ancora il programma non è stato effettivamente lanciato. Poniamo dunque break al main, lanciamo l’eseguibile e analizziamo i registri:
(gdb) break main: Breakpoint 1 at 0x80483d5: file firstprog.c, line 6. (gdb) run Starting program: ./firstprog Breakpoint 1, main () at firstprog.c:6 6 for (i=0; i < 10; i++) (gdb) i r eax 0xbfe3fc04 -1075577852 ecx 0xbfe3fb80 -1075577984 edx 0x1 1 ebx 0xb800bff4 -1207910412 esp 0xbfe3fb50 0xbfe3fb50 ebp 0xbfe3fb68 0xbfe3fb68 esi 0x8048420 134513696 edi 0x8048310 134513424 eip 0x80483d5 0x80483d5 eflags 0x200286 [ PF SF IF ID ] cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51 (gdb) i r eip eip 0x80483d5 0x80483d5
In particolare analizziamo il registro l’eip, il quale punta alla prossima istruzione da eseguire, analizzando la memoria sulla quale sta puntando. Entrambi i comandi mostrano lo stesso output proprio perchè effettivamente stiamo effettuando la stessa richiesta.
(gdb) x/x 0x80483d5 0x80483d5 : 0x00f845c7 (gdb) x/x $eip 0x80483d5 : 0x00f845c7 (gdb) x/i $eip 0x80483d5 : mov DWORD PTR [ebp-0x8],0x0
Inoltre aggiugendo il numero 6 prima della i di instruction possiamo vedere le 6 successive istruzioni.
(gdb) x/6i $eip 0x80483d5 : mov DWORD PTR [ebp-0x8],0x0 0x80483dc : jmp 0x80483ee 0x80483de : mov DWORD PTR [esp],0x80484d0 0x80483e5 : call 0x80482f4 0x80483ea : add DWORD PTR [ebp-0x8],0x1 0x80483ee : cmp DWORD PTR [ebp-0x8],0x9
A questo punto si vede come al registro puntato di eip c’è una “DWORD PTR [ebp-0x8],0×0“. Tale operazione significa che il valore zero viene allocato alla locazione ebp – 8 ossia 0xbfff7d10 che al momento contiene 0xb7febf50:
(gdb) i r ebp ebp 0xbfff7d18 0xbfff7d18 (gdb) x/x $ebp - 8 0xbfff7d10: 0xb7febf50
Nella successiva locazione di memoria, ossia all’indirizzo 0x80483dc è presente un salto incondizionato jmp alla locazione 0x80483ee. Verifichiamo, proseguendo di una istruzione nexti, che l’eip successivo sia proprio quello:
(gdb) nexti 0x080483dc 6 for (i=0; i < 10; i++) (gdb) i r eip eip 0x80483dc 0x80483dc (gdb) x/i $eip 0x80483dc : jmp 0x80483ee
Infatti, ora l’eip contiene un salto incondizionato jmp.
Guardiamo adesso le successive 10 istruzioni:
(gdb) x/10i $eip 0x80483dc : jmp 0x80483ee 0x80483de : mov DWORD PTR [esp],0x80484d0 0x80483e5 : call 0x80482f4 0x80483ea : add DWORD PTR [ebp-0x8],0x1 0x80483ee : cmp DWORD PTR [ebp-0x8],0x9 0x80483f2 : jle 0x80483de 0x80483f4 : mov eax,0x0 0x80483f9 : add esp,0x14 0x80483fc : pop ecx 0x80483fd : pop ebp
Da questo listato si vede non troppo facilmente per i newbe come viene effettuato il ciclo for.
Inizialmente viene fatto un salto incondizionato sulla locazione 0x80483ee, nella quale viene effettuata una compare cmp, la quale dice che se il il numero presente alla locazione ebp – 8, dove precedentemente era stato inizializzato 0, è minore o uguale di 9 allora esegue l’istruzione successiva jle che risulta essere apppunto un salto condizionato, proprio per via della compare, all’istruzione 0x80483de. Siccome risulta vera, 0 è minore o uguale di 9, rinizia il ciclo.
Nel momento in cui risulterà falsa, ossia ebp – 8 vale 10, allora non effettuerà il salto e continuerà con l’istruzione successiva.
Ora controlliamo proprio che ebp – 8 sia 0 e che quindi jle faccia il salto condizionato
(gdb) x/i $ebp - 8 0xbfff7d10: add BYTE PTR [eax],al (gdb) x/x 0xbfff7d10 0xbfff7d10: 0x00000000 (gdb) x/d 0xbfff7d10 0xbfff7d10: 0 (gdb) print $ebp - 8 $2 = (void *) 0xbfff7d10 (gdb) x/x $2 0xbfff7d10: 0x00000000 (gdb) x/d $2 0xbfff7d10: 0
Sia con il metodo print che analizzando con x/x direttamente sulla locazione di memoria, che con x/d lo visualizziamo in decimale, controlliamo che ha valore 0 e che quindi il jle ha condizione positiva per effettuare il salto.
Ora invece cerchiamo di capire cosa fanno le altre operazioni. Analizzando la locazione 0x80483de è presente un’istruzione che essenzialmente muove il valore dell’indirizzo 0x80484d0 nell’indirizzo esp. Verifichiamo quindi cosa contiene l’indirizzo 0x80484d0:
(gdb) x/x 0x80484d0 0x80484d0: 0x6c6c6548 (gdb) x/6cb 0x80484d0 0x80484d0: 72 'H' 101 'e' 108 'l' 108 'l' 111 'o' 44 ',' (gdb) x/s 0x80484d0 0x80484d0: "Hello, world!"
Notiamo che contiene il valore 0x6c6c6548 che codificato in ASCII corrisponde ad “Hello,” infatti tramite l’opzione c effettua la codifica di ogni singolo byte in ASCII mentre con s converte proprio tutta la stringa.
Per ultimo l’istruzione alla locazione 0x80483ea non fa altro che incrementare di uno il valore all’interno di ebp – 8, la quale poi viene poi controllata dal salto condizionato.
Giusto per completare il discorso, procediamo con diversi nexti fino alla fine del del ciclo for ossia quando l’ebp – 8 contiene il valore 10. Infine controlliamo che l’eip punta all’uscita del ciclo:
(gdb) x/d 0xbfff7d10 0xbfff7d10: 10 (gdb) i r eip eip 0x80483ee 0x80483ee (gdb) x/10i $eip 0x80483ee : cmp DWORD PTR [ebp-0x8],0x9 0x80483f2 : jle 0x80483de 0x80483f4 : mov eax,0x0 0x80483f9 : add esp,0x14 0x80483fc : pop ecx 0x80483fd : pop ebp 0x80483fe : lea esp,[ecx-0x4] 0x8048401 : ret 0x8048402: nop 0x8048403: nop (gdb) nexti 0x080483f2 6 for (i=0; i < 10; i++) (gdb) i r eip eip 0x80483f2 0x80483f2 (gdb) nexti 10 return 0; (gdb) i r eip eip 0x80483f4 0x80483f4
Possiamo vedere dall’output come appunto l’eip punta 0x80483f4 e non più 0x80483de come nei precedenti casi, confermando appunto l’uscita dal ciclo for.
Bene ora che abbiamo appreso i concetti base del debugging immaginate cosa potrebbe capitare se un attaccante riesca a sovrascrivere l‘eip facendolo puntare ad una locazione di memoria arbitraria a lui congeniale!
Buon degugging a tutti.









