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 firstprog
Ora 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 0×80483dc è presente un salto incondizionato jmp alla locazione 0×80483ee. 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 0×80483ee, 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 0×80483de. 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 0×80483de è presente un’istruzione che essenzialmente muove il valore dell’indirizzo 0×80484d0 nell’indirizzo esp. Verifichiamo quindi cosa contiene l’indirizzo 0×80484d0:
(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 0×6c6c6548 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 0×80483ea 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 0×80483f4 e non più 0×80483de 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.