GnomixLand




Torino, 20/11/2005

Sommario:
1. Abstract
2. Premessa
3. Nozioni sul formato ELF
4. Con o senza simboli?
5. E il main?
6. Call e Jump
7. Argomenti di funzioni e valori di ritorno
8. Un esempio semplice

1. Abstract

Questo documento parlerà di come riuscire a farsi un'idea del
funzionamento e del flusso di esecuzione di binari strippati, ovvero di
binari ai quali viene tolta la symbols table.
Debuggare questo tipo di binari risulta più difficile in quanto non
avremo i riferimenti e i nomi alle funzioni che compongono il programma.

2. Premessa

Questo documento non sarà una guida al cracking. 
Tutte le cose scritte in questo paper sono frutto della mia esperienza 
nel debugging e gli unici manuali citati saranno quelli del gdb e 
dell'asm sintassi AT&T per x86.
Nssuno mi ha mai spiegato come debuggare un programma privo della
symbols table, spero quindi che questo scritto possa esservi d'aiuto.
Si presuppone che i comandi gdb usati si conoscano.

3. Nozioni sul formato ELF

Il formato ELF definisce come debba essere strutturato un file
eseguibile, e prevede una struttura a livelli che riassumo brevemente in
questo schema per le sole sezioni (i segmenti non verranno trattati):

Elf Header (1)  -> Section Headers (n) -> Section contents (1*n)

Ovvero: all'inizio c'è un header che contiene informazioni globali
riguardo al programma come ad esempio l'indirizzo dell'entry point
principale, presente nella struttura Elf32_Ehdr come e_entry.
Attraverso questo elf header è possibile accedere, usando l'offset
contenuto in e_shoff, all'area del file in cui sono salvati gli header
delle sezioni contenute nel binario. 
Una volta letti gli header delle sezioni sarà quindi possibile accedere
al contenuto delle sezioni vero e proprio attraverso l'offset di sezione
contenuto nell'elemento sh_offset delle strutture Elf32_Shdr.
Esempi di sezioni sono la .text, il cui header sarà presente e contiguo
agli altri, ma il suo contenuto sarà indirizzato dall'offset contenuto
appunto in sh_offset, o la sezione .symtab, che contiene le informazioni
relative ai simboli locali, o ancora la .strtab che contiene ad esempio
i nomi dei simboli. 
All'interno della sezione .symtab, tra le informazioni sui simboli, sono
presenti anche il loro indirizzo virtuale al quale verranno caricati una
volta che il programma viene messo in esecuzione, i riferimenti per
ricostruire il nome del simbolo stesso, e la dimensione del simbolo.
Forti di queste informazioni sui simboli (nel seguito parlerò di simboli
come equivalente di funzioni, anche se non è assolutamente così:
esistono infatti simboli che non sono funzioni. Per semplicità adotterò
questa convenzione).
Ora che sappiamo quanto informazioni utili possiamo ricavare dalla
symtab, dobbiamo capire che tutte queste info non saranno presenti
durante il debug.


4. Con o senza simboli?

Debuggare un programma che contiene la symtab è davvero semplice. Il gdb
ci permette tranquillamente di disassemblare qualsiasi simbolo che
vogliamo, a partire dal main e ricostruire in questo modo l'esatta
sequenza di istruzioni, per poter quasi capire come poteva essere il
sorgente del programma stesso. Tutte le chiamate a funzione in gdb
riportano il nome della funzione chiamata, che non poche volte ci può
aiutare a capire il suo scopo.
Questo paper però tratterà di come riuscire a fare le stesse cose,
quando nel binario manca la symtab.
Al gdb non potremo quindi dire:

disass questa_funzione

o peggio:

disass main

per il semplice fatto che non c'è nessuno che dica al gdb dove la main
possa essere.
Inoltre se noi andassimo a disassemblare direttamente a partire da un
indirizzo di memoria, potremmo dire al gdb di iniziare a interpretare il
codice macchina come asm nel bel mezzo di una istruzione, falsando di
fatto tutto il listing successivo. Dobbiamo quindi sempre dare un
indirizzo al disass che sia l'inizio effettivo di una istruione
assembler.

5. E il main?

Ci sono diversi approcci al debugging come anche nella lettura dei
codici sorgenti fatti da altri. Ho provato un po' di tutto, partire
dalle funzioni, partire da un punto a caso, partire dai messaggi di
errore e non e partire dal main.
Tra tutte questi modi di iniziare, ho sempre sicuramente trovato più
utile partire dal main, ricostruendo il flusso di esecuzione del
programma, seguendo di volta in volta le varie chiamate a funzione e i
diversi jump. 
Il problema quindi è: come sapere dove inizia il main all'interno di un
binario in cui la symtab sia stata tolta?
E' molto semplice. In ogni programma è presente un simbolo, _start, che
inizia esattamente all'indirizzo dell'entry point presente nell'header
ELF. 
Usando ad esempio il programma 'readelf' possiamo ottenere tutte le
informazioni sull'header ELF in questo modo:

root@shantek:~/code/elf# readelf -h test
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x8048280
  Start of program headers:          52 (bytes into file)
  Start of section headers:          1668 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         6
  Size of section headers:           40 (bytes)
  Number of section headers:				 25
	Section header string table index: 24
root@shantek:~/code/elf#

Possiamo quindi vedere come l'Entry point address sia 0x8048280.
Questo corrisponde all'indirizzo al quale il simbolo _start si troverà
runtime. Proviamo a dare un

disass 0x8048280 0x804829f

per avere un lisato assembler abbastanza lungo.
In questo caso quello ceh otteniamo è il seguente codice:

(gdb) disass 0x8048280 0x804829f
Dump of assembler code from 0x8048280 to 0x804829f:
0x8048280 :  xor    %ebp,%ebp
0x8048282 :  pop    %esi
0x8048283 :  mov    %esp,%ecx
0x8048285 :  and    $0xfffffff0,%esp
0x8048288 :  push   %eax
0x8048289 :  push   %esp
0x804828a :  push   %edx
0x804828b :  push   $0x80483a0
0x8048290 :  push   $0x8048370
0x8048295 :  push   %ecx
0x8048296 :  push   %esi
0x8048297 :  push   $0x8048354
0x804829c :  call   0x8048258 <__libc_start_main>
End of assembler dump.
(gdb)

dove al fondo vediamo una chiamata ad una funzione di libreria, la
__libc_start_main.

A questa funzione, come si vede dalle righe sopra, vengono passati
diversi parametri attraverso delle push.
Ebbene, uno di questi è l'indirizzo del main del programma. Se andiamo a
guardare li prototipo di questa funzione:

int __libc_start_main(int *(main) (int, char * *, char * *), int argc,
char * * ubp_av, void (*init) (void), void (*fini) (void), void
(*rtld_fini) (void), void (* stack_end));

vediamo che l'indirizzo del main è il primo argomento, questo vuol dire
che sarà l'ultimo ad essere pushato sullo stack.
L'ultimo indirizzo in questo caso è: 0x8048354

La nostra main inizierà quindi a 0x8048354.

6. Call e Jump

Vediamo ora il codice del programma che abbiamo usato negli esempi del
paragrafo 5.:

nt foo() {
        printf("Hello mondo.n");
}


int main(int ac, char **av) {
        foo();
}

quindi disassemblando il main dovremo aspettarci una call alla funzione
foo.
Andiamo a vedere se è vero usando l'indirizzo trovato nel paragrafo 5:

Dump of assembler code from 0x8048354 to 0x80483ff:
0x8048354 : push   %ebp
0x8048355 : mov    %esp,%ebp
0x8048357 : sub    $0x8,%esp
0x804835a : and    $0xfffffff0,%esp
0x804835d : mov    $0x0,%eax
0x8048362 : sub    %eax,%esp
0x8048364 : call   0x804833c 
0x8048369 : leave
0x804836a : ret
...
End of assembler dump.

NOTA: quando si disassembla direttamente una regione di memoria è bene
sempre dare l'estremo superiore e in questo caso, non conoscendo a
priori la dimensione di main, ho sovrastimato, tagliando poi alla prima
istruzione ret incontrata.

Come si può vedere, all'indirizzo 0x8048364 viene fatta una call (non
lasciamoci fuorviare dal '' che non ha nessun senso, in
quanto il gdb cerca comunque di interpretare il simbolo a cui si sta
saltando, usando simboli della libreria c) senza nessun parametro e
null'altro, che sembra esattamente quello che fa la nostra main.
Effettivamente si vede anche come nelle prime due righe ci sia il solito
preludio:

0x8048354 : push   %ebp
0x8048355 : mov    %esp,%ebp

e come nella riga successiva si faccia spazio sullo stack per i due
parametri del main, int ac, char **av.

Ritorniamo alla call e al suo indirizzo target: 0x804833c.
Questo ci dà informazioni su due cose:

i.  il main non va sicuramente oltre l'indirizzo 0x804833c, altrimenti
per raggiungerlo verrebbe usato un jump.
ii. all'indirizzo 0x804833c c'è una funzione (simbolo)

Spiego questi due punti.

i. In questo caso il main è molto piccolo, e posso anche vedere subito
dove finisce, ma quando il main o altre funzioni diventano grosse, ed è
molto facile che avvenga, è utile usare le call all'interno di queste
funzioni per avere un'idea del limite superiore delle sue dimensioni.
Le call infatti puntano sicuramente a codice che non fa parte della
funzione corrente. Non è detto che ci sia una call ad una funzione
contingua a quella presa in esame, ma serve sicuramente per darci
un'idea di massima di quanto possa esser grande.

ii. Non solo le call non puntano a codice interno alla funzione
chiamante, ma non puntano nemmeno a codice interno alla funzione
chiamata. Quindi l'operando di una call ci indica esattamente dove
inizia un'altra funzione. Questo è ovviamente molto utile per poter
ricostruire l'insieme dei simboli.

Se ora infatti disassembliamo l'indirizzo target della call:

Dump of assembler code from 0x804833c to 0x80483ff:
0x804833c : push   %ebp
0x804833d : mov    %esp,%ebp
0x804833f : sub    $0x8,%esp
0x8048342 : sub    $0xc,%esp
0x8048345 : push   $0x8048434
0x804834a : call   0x8048268 
0x804834f : add    $0x10,%esp
0x8048352 : leave
0x8048353 : ret
...
End of assembler dump.

Anche in questo caso ho sovrastimato le dimensioni della funzione
chiamata.

Vediamo dal listato che l'indirizzo chiamato dalla call nel main
contiene subito un preludio:

0x804833c : push   %ebp
0x804833d : mov    %esp,%ebp

e successivamente una chiamata alla printf (di questa possiamo fidarci
in quanto è segnata come printf senza offset: la call non cade mai
all'interno di una funzione ma sempre all'inizio).

Abbiamo così ricostruito che nel programma, il main richiama un'altra
funzione, A, che a sua volta chiama una printf e stampa qualcosa a
video.

7. Argomenti di funzioni e valori di ritorno

Ora che sappiamo cosa viene chiamato da chi, sarebbe interessante nonchè
utile capire qual è il prototipo della funzione chiamata, giusto per
vedere come si interfaccia con il codice chiamante e cosa costui si
aspetta come valore di ritorno.
Anche questo passaggio è relativamente semplice, i parametri alle
funzioni vengono infatti passati attraverso delle push (parliamo sempre
di asm per x86 su linux) e il valore di ritorno alla fine della funzione
è sempre contenuto nel registro eax.
Vediamo quindi di ampliare la funzione dell'esempio precedente mettendo
una condizione, un valore di ritorno e un parametro. Questo sarà il
nuovo codice:

int main(int ac, char **av) {
        int a = foo(1);
        if (!a) printf("ok 2n");
        else printf("ko 2n");
}

int foo(int a) {
        if (a) {
                printf("okn");
                return 0;
        }
        else {
                printf("kon");
                return 1;
        }
}

Il nuovo indirizzo del main, preso nel solito modo, è: 0x804833c
e quindi il suo listato asm:

Dump of assembler code from 0x804833c to 0x80483ff:
0x804833c : push   %ebp
0x804833d : mov    %esp,%ebp
0x804833f : sub    $0x8,%esp
0x8048342 : and    $0xfffffff0,%esp
0x8048345 : mov    $0x0,%eax
0x804834a : sub    %eax,%esp
0x804834c : sub    $0xc,%esp
0x804834f : push   $0x1
0x8048351 : call   0x8048386 
0x8048356 : add    $0x10,%esp
0x8048359 : mov    %eax,0xfffffffc(%ebp)
0x804835c : cmpl   $0x0,0xfffffffc(%ebp)
0x8048360 : jne    0x8048374 
0x8048362 : sub    $0xc,%esp
0x8048365 : push   $0x8048494
0x804836a : call   0x8048268 
0x804836f : add    $0x10,%esp
0x8048372 : jmp    0x8048384 
0x8048374 : sub    $0xc,%esp
0x8048377 : push   $0x804849a
0x804837c : call   0x8048268 
0x8048381 : add    $0x10,%esp
0x8048384 : leave
0x8048385 : ret
...
End of assembler dump.

Vediamo quindi la call in 0x8048351 che richiama ovviamente la funzione
foo, e vediamo anche la push nella riga prima:

0x804834f : push   $0x1

dove si vede che c'è un passaggio di parametri per valore (by val).
Ricapitolando quindi, sappiamo che la funzione chiamata riceve come
parametro un intero passato per valore.

Il valore di ritorno invece lo si può vedere usando un berakpoint
all'indirizzo 0x8048356:

(gdb) b *0x8048356
Breakpoint 1 at 0x8048356
(gdb) r
Starting program: /root/paper/test
ok
(no debugging symbols found)...
Breakpoint 1, 0x08048356 in printf ()
(gdb) p $eax
$1 = 0
(gdb)

Come vediamo il valore ritornato dalla funzione foo è 0, coerente con
l'analisi del sorgente.
Un ipotetico prototipo potrebbe quindi essere

int A(int);


8. Un esempio semplice

Considerando l'ultimo esempio, dove, se il programma venisse eseguito,
otterremmo:

root@shantek:~/paper# ./test
ok
ok 2
root@shantek:~/paper#

Proviamo a capire come fargli scrivere ko e ko2, invece di quello che
scrive ora.
Vediamo quindi com'è fatta la funzione foo:

Dump of assembler code from 0x8048386 to 0x80483ff:
0x8048386 : push   %ebp
0x8048387 : mov    %esp,%ebp
0x8048389 : sub    $0x8,%esp
0x804838c : cmpl   $0x0,0x8(%ebp)
0x8048390 : je     0x80483ab 
0x8048392 : sub    $0xc,%esp
0x8048395 : push   $0x80484a0
0x804839a : call   0x8048268 
0x804839f : add    $0x10,%esp
0x80483a2 : movl   $0x0,0xfffffffc(%ebp)
0x80483a9 : jmp    0x80483c2 
0x80483ab : sub    $0xc,%esp
0x80483ae : push   $0x80484a4
0x80483b3 : call   0x8048268 
0x80483b8 : add    $0x10,%esp
0x80483bb : movl   $0x1,0xfffffffc(%ebp)
0x80483c2 : mov    0xfffffffc(%ebp),%eax
0x80483c5 : leave
0x80483c6 : ret
...
End of assembler dump.

Vediamo che ci sono due chiamate a printf. 
Molto probabilmente il parametro passato ad ognuna delle due sarà una
stringa. Per vedere che stringa, possiamo fare:

(gdb) x/s 0x80484a0
0x80484a0 <_IO_stdin_used+16>:   "okn"
(gdb) x/s 0x80484a4
0x80484a4 <_IO_stdin_used+20>:   "kon"
(gdb)

Quindi vediamo che una stampa quello che abbiamo visto, l'altra invece
quello che vogliamo fargli stampare. Ovviamente evitiamo di mettere
breakpoint e cambiare l'eip, non verrebbe risolto il problema.
Da notare invece che le due printf vengono raggiunte a seconda di un
salto condizionato in 

0x8048390 : je     0x80483ab 

dove viene ripresa la:

0x804838c : cmpl   $0x0,0x8(%ebp)

dove si compara lo 0x0 con il valore contenuto all'indirizzo 0x8(%ebp), 
che altro non è che la variabile passata dal main.
Quindi se il valore passato è 0x0, la funzione salta a 0x80483ab,
altrimenti continua l'esecuzione normale.
Saltando a 0x80483ab arriva alla printf che stampa ko, ovvero quello che
noi vogliamo fare.
C'è ancora un problema: il valore di ritorno. Partendo dal fondo vediamo
che l'ultima assegnazione al registro eax viene fatta in:

0x80483c2 : mov    0xfffffffc(%ebp),%eax

dove 0xfffffffc(%ebp) viene modificato in:

0x80483a2 : movl   $0x0,0xfffffffc(%ebp)

e

0x80483bb : movl   $0x1,0xfffffffc(%ebp)

Vediamo quindi che quel jump determina anche il valore di ritorno della
funzione.
Modificandolo quindi, andando a toccare la sezione .text corrispondente,
con un jne, avremo ottenuto quello che volevamo.


©  GnomixLand
http://www.gnomixland.com/