 |
Menu principale |
 |
 |
Cartoline virtuali |
 |
Cartolina n° 605

Sono presenti 1307 cartoline virtuali. Entra ora
 |
Giochi online |
 |
 |
News Reader |
 |
|
Torino, 16/11/2005 - ver. 0.1
Sommario:
1. Abstract
2. Premessa
3. Nozioni sul formato ELF
4. Modificare direttamente il .text?
5. Utilizzo dell'heap
6. Senza rilocamento
7. Rilocare i riferimenti: tecnica stupida
8. Rilocare i riferimenti: tecnica meno stupida
9. Criptare il binario
10. Mettere tutto insieme
11. Vulnerabilità
12. Ringraziamenti
1. Abstract:
Questo documento parlerà di una tecnica a cui ho pensato, e alla quale
sicuramente anche altri avranno già pensato, per evitare che un
programma protetto da ipotetica licenza, venga debuggato, utilizzando in
prima approssimazione, la licenza come chiave di criptatura/decriptatura
del binario.
A differenza di altri documenti e tecniche, la presente non userà
syscall come la ptrace per ricostruire il flusso di esecuzione non
criptato del programma.
A causa di questa scelta si evidenzieranno quindi problemi di
rilocazione nelle chiamate a funzioni, sia locali che di libreria.
2. Premessa
Una premessa, per fare il punto, è d'obbligo. Partendo innanzitutto dal
presupposto che dal 1975/1980 non è stato inventato più nulla in
informatica, si può dire che questo doc sia inutile.
Allora perchè lo scrivo? Perchè magari qualcuno non ha voglia di andarsi
a leggere i documenti originali dell'IBM su 360/370, virtual machine,
DAT, memoria virtuale, e cose del genere e quindi fa comodo avere una
descrizione, in italiano e aggiornata ai nostri tempi, di qualcosa che
quasi sicuramente era all'ordine del giorno quando ancora il C non esisteva.
Altrettanto sicuramente altra gente avrà scritto o usato tali tecniche,
posso comunque assicurare che tutto quello che scriverò è farina del mio
sacco. Per quanto riguarda i riferimenti bibliografici, li trovate al
fondo.
3. Nozioni sul formato ELF
Non spiegherò in questo capitolo cos'è il formato ELF, a cosa serva etc
etc, si presuppone che qualcosa sugli ELF si sappia già.
Mi soffermo invece sulla sezione .text dei binari ELF.
In questa sezione è contenuto il codice macchina dei diversi simboli-
funzioni che lavorano all'interno del programma.
Il codice macchina è scritto sequenzialmente, non vi sono separazioni
tra il codice di un simbolo e quello del successivo; il flusso di
esecuzione del programma infatti non tiene conto dei nomi dei simboli,
l'unica cosa che conta sono le call e i jump.
Nonostante tutto le informazioni sui simboli sono utili al debug e sono
mantenute all'interno della sezione .symtab (la sto facendo molto
semplice, diciamo che in realtà si dovrebbe considerare a quale sezione
il simbolo è linkato). Tra le varie informazioni sui simboli, è
contenuto anche nell'elemento st_value della struttura Elf32_Sym,
l'indirizzo in memoria virtuale da cui quel simbolo partirà
all'esecuzione del programma.
Attraverso questo indirizzo e l'entrypoint dell'elf, contenuto
nell'header elf nell'elemento e_entry della struttura Elf32_Ehdr, con
una sottrazione si può ricavare l'offset all'interno della sezione .text
del file in cui inizia il codice macchina di uno specifico simbolo.
Considerando quindi di avere nella struttura Elf32_Sym sym l'header di
un generico simbolo di tipo funzione e nella struttura Elf32_Ehdr eh
l'header del file ELF, e in char *text tutta la sezione .text, l'inizio
del codice macchina del simbolo sym inizierà a:
unsigned char *sym_start = text + (sym.st_value - eh.e_entry);
Per quanto riguarda le dimensioni del simbolo invece si deve usare un
altro elemento della struttura Elf32_Sym, st_size.
Quindi volendo scorrere tutto il codice di un singolo simbolo di tipo
funzione, si potrà iterare sulla porzione di memoria che parte da quanto
visto sopra, leggendo un numero di byte pari a sym.st_size.
Ora che abbiamo visto come agire sulla sezione .text di un particolare
simbolo, vediamo come è possibile andare a recuperare le informazioni
del simbolo all'interno della tabella dei simboli.
Ovviamente all'interno di tale tabella non sono presenti i nomi in ascii
dei simboli, risulta così leggermente più laboriosi ricavarli e
indirizzare un simbolo attraverso il suo nome, usato all'interno del
sorgente.
Prima di tutto bisogna vedere a quale sezione di stringhe è linkata la
tabella dei simboli, la .symtab, per poter ricavare per ogni header di
ogni simbolo il suo nome. Per fare questo all'interno della struttura
Elf32_Shdr, che definisce l'header delle sezioni, è presente l'elemento
sh_link, che assume valori diversi a seconda della sezione a cui è
attribuito. Nel caso della sezione .symtab indica la string table
associata per ricavare i nomi dei simboli.
Anche la string table, al pari della .text contiene una sequenza di
stringhe terminate dal null byte. Per ricavare quindi il nome di un
simbolo dobbiamo utilizzare un elemento della struttura Elf32_Sym,
st_name, che rappresenta l'offset in byte dall'inizio della string
table associata attraverso il numero contenuto in sh_link, della
struttura Elf32_Shdr. Quindi mettendo di avere la string table in char
*strtab, l'header del simbolo in Elf32_Sym sym, il nome del simbolo
sarà:
unsigned char *sym_name = strtab + sym.st_name;
Evito di parlare di come ricavare l'indice di una sezione dato il nome,
visto che il procedimento è simile, risalendo di un livello di
indirizzamento. E' comunque ben visibile nella mia libreria di esempio
per la lettura degli ELF.
Ancora una cosa che non riguarda propriamente il formato ELF ma che sarà
utile quando parleremo di rilocazione dei riferimenti: jump e call
vengono rappresentate in modo diverso ma le differenze tra le due
istruzioni non sono così abissali. Un jump sposta, in maniera a volte
condizionata a diverse flag, il flusso di esecuzione da un certo
indirizzo ad un altro, andando a modificare il registro EIP. Anche la
call sposta il flusso di esecuzione ad un'altra locazione di memoria, ma
nel frattempo salva anche l'indirizzo di ritorno (per quanto riguarda i
processori x86, sullo stack) e il codice target deve avere alcuni
requisiti, come ad esempio il cosidetto preambolo, per avere a che fare
con uno stack frame diverso, cosa che con il jump non accade. Inoltre
con una call è possibile passare al codice chiamato, dei parametri
facendone push sullo stack. Ricordo inoltre che dopo una call, bisogna
sempre ripulire lo stack, tenendo conto dell'allineamento usato durante
la compilazione.
Ancora una cosa: il compilatore genera call relative, non si avrà quindi
nei 4 byte dedicati all'operando della call l'indirizzo esatto della
funzione chiamata, bensì un offset relativo all'indirizzo di memoria in
cui l'istruzione di call termina.
Mettendo una call all'indirizzo 0x3 che deve chiamare una funzione
all'indirizzo 0xa0, e mettendo che la call sia composta da opcode
(1byte) + operando (4byte), l'offset sarà di 0xa - 0x8 = 0x2 e quindi
facendo un dump e tenendo conto del little endian avremo:
0xe8 0x02 0x00 0x00 0x00
Fine introduzione agli ELF, andiamo avanti con il paper.
4. Modificare direttamente il .text?
L'idea alla base di questa tecnica era in prima battuta di criptare
la sezione .text di determinate funzioni di un certo programma
attraverso un programma esterno, lasciando in chiaro il motore di
decriptatura, il quale avrebbe dovuto decriptare direttamente le zone di
memoria a partire dagli indirizzi delle funzioni criptate.
Esempio:
int foo() { /* funzione il cui .text verrà criptato */
printf("yeahn");
return 0;
}
int main(int ac, char **av) {
unsigned char *foo_text = (unsigned char *)foo;
decrypt_text(foo_text); /* l'implementazione non interessa per ora */
foo();
return 0;
}
Bene, questo approccio non funziona. Segmentation Fault è tutto quello
che si ottiene quando si prova a modificare al volo direttamente la
sezione .text di una funzione interna al programma. La sezione .text è
infatti solitamente read-only se non si passa al linker del gcc
l'opzione -N. Ma non rendiamoci le cose troppo semplici e decidiamo di
avere un programma la cui sezione .text sia ro, complicando
ulteriormente eventuali manipolazioni del codice una volta caricata in
memoria da un debugger.
5. Utilizzo dell'heap
Per ovviare al fatto che abbiamo deciso di avere la sezione .text macata
come read-only, dobbiamo appoggiarci a zone di memoria modificabili ed
eseguibili, come ad esempio l'heap o lo stack.
Ovviamente, nel caso in cui la sezione .text di un simbolo sia molto
grossa, è d'obbligo usare l'heap. L'idea è quindi quella di copiare li
codice macchina criptato del simbolo da decriptare, in una zona di
memoria allocata con malloc, decriptarla, e spostare il flusso di
esecuzione del programma all'indirizzo della zona di memoria appena
allocata.
A questo punto nascono diverse problematiche.
Come detto nel paragrafo 3 riguardo alle call e ai loro operandi
relativi, dobbiamo considerare come il linker abbia già risolto tutte le
rilocazioni necessarie, facendo riferimento all'elemento st_value dei
diversi simboli. Copiando però il .text di un simbolo in un'altra zona
di memoria e spostando il flusso di esecuzione su quest'ultima, tutti i
riferimenti relattivi calcolati dal linker vanno a farsi benedire.
Infatti ritornando all'esempio della call di cui sopra, dove in
condizioni normali avremo:
0x03: 0xe8 0x02 0x00 0x00 0x00
...
0xa0:
dove 0x03 è l'indirizzo dell'istruzione call, abbiamo che l'indirizzo
della funzione è calcolato correttamente in quanto la call è
effettivamente all'indirizzo 0x03 e termina all'indirizzo 0x08. Se però
noi questa call la copiamo all'indirizzo 0x4f, allora l'offset calcolato
dal linker per passare alla funzione in 0xa0, non funziona più.
Vediamo comunque ora come copiare la funzione nell'heap, decriptarla ed
eseguirla normalmente (aggiungiamo la possibilità di passare parametri
alla funzione, in questo caso un int by val):
int foo(int a) {
...
}
...
unsigned char *heap_foo;
...
heap_foo = (unsigned char *)malloc(sizeof(char) * func_size);
/* si, faccio finta di conoscere la dimensione della funzione
* ricavabile comunque esternamente dal st_size del simbolo */
memcpy(heap_foo,(unsigned char *)foo,func_size);
/* copio il .text di foo nell'heap puntato da heap_foo */
__asm__("pushl $0x02n" /* passo il parametro alla funzione */
"call *%%eaxn" /* chiamo la funzione */
"addl $0x04,%%espn" :: "a" (heap_foo)); /* pulisco lo stack */
6. Senza rilocamento
Ovviamente si può anche decidere di non utilizzare funzioni esterne a
quella da decriptare; i jump infatti, non modificando il .text e
le sue dimensioni, non danno problemi.
Per evitare comunque il rilocamento degli offset, e utilizzare funzioni
esterne, l'unica soluzione, subito abbandonata, che mi è venuta in mente,
è stata quella di riscrivere in asm la funzione utilizzando direttamente
il suo indirizzo di memoria e fare una call assoluta.
Esempio, con una macro:
#define strout(str) {
__asm__("movl $0x804828c,%%eaxn;"
"pushl %0n;"
"call *%%eaxn;"
"add $0x4,%%espn;"
::"d" (str));
}
In questo caso viene ricreata una semplicissima printf che stampa a
video una stringa. Il valore messo in EAX nella prima riga di codice è
infatti l'indirizzo della printf sul mio sistema, alla quale passo
l'indirizzo della stringa pushandola sullo stack.
Successivamente chiamo in modo assoluto la printf e dopo la chiamata
ripulisco lo stack aggiungendo 0x4 (dimensione del puntatore pushato
prima della call - lo stack creshe verso valori di memoria inferiori).
La pulizia dello stack, come detto prima, dipende dall'allineamento
scelto in fase di compilazione: qua ho compilato il programma con
-mpreferred_stack_boundary=2 .
La funzione da criptare diventa quindi:
int foo() {
strout("yeahn");
}
e può essere eseguita anche dopo che il .text della funzione stessa è
stato copiato nell'heap.
Questa soluzione ovviamente non ha molta utilità pratica, se dobbiamo
criptare grosse funzioni che girino su sistemi diversi.
Ne ho parlato giusto per completezza.
7. Rilocare i riferimenti: tecnica stupida
Questa tecnica è quella che alla fine ho utilizzato per il programma di
prova. Consiste nell'andare a ricercare, all'interno della sezione .text
in chiaro della funzione da rilocare, tutti quegli offset, che combinati
con l'eip che si avrebbe se fosse eseguita la funzione criptata, che
porterebbero alla funzione chiamata, come ad esempio una printf.
In pratica, algoritmicamente si parte dall'indirizzo della sezione .text
copiata nell'heap e si avanza byte per byte, salvando di volta in volta
in un intero, un possibile offset di 32 bit.
Una volta ottenuto questo intero, si calcola il valore a cui
un'ipotetica call, usando questo offset e in quella posizione rispetto
all'indirizzo originario della funzione criptata, avrebbe puntato.
Forse è meglio vedere direttamente il codice che implementa tutto ciò:
char *heap_func;
int foo(int a) {
printf("yeahn");
}
int modify_offset(char *func, char *original_func, char *text, int
func_size, char *func_name) {
int i;
char *prt = original_func;
int addr, new_off, old_off;
for (i=0;i: push %ebx
0x80483b2 : pop %ebx
0x80483b4 : test %ebp,%edx
0x80483b6 : push %cs
0x80483b7 : test %edi,0xe(%ebx)
0x80483ba : pop %es
0x80483bb : jae 0x80483d1
0x80483bd : test %ebp,%edx
0x80483bf : or 0x74(%esi),%ch
0x80483c2 : addb $0xe,(%edx)
0x80483c5 : out %al,(%dx)
0x80483c6 : lock clc
0x80483c8 : stc
0x80483c9 : stc
0x80483ca : test %eax,%edx
0x80483cc : push %ss
0x80483cd : in (%dx),%eax
0x80483ce : push %ss
0x80483cf : test %ebp,%edx
0x80483d1 : or 0xffffff8f(%esi),%ch
0x80483d4 : addb $0xe,(%edx)
0x80483d7 : out %al,(%dx)
0x80483d8 : loop 0x80483d2
0x80483da : stc
0x80483db : stc
0x80483dc : test %eax,%edx
0x80483de : push %ss
0x80483df : iret
0x80483e0 : lds 0xffffff89(%ebp),%edx
End of assembler dump.
(gdb)
mentre la versione in chiaro:
(gdb) disass abs_foo3
Dump of assembler code for function abs_foo3:
0x80483b1 : push %ebp
0x80483b2 : mov %esp,%ebp
0x80483b4 : sub $0x8,%esp
0x80483b7 : cmpl $0x1,0x8(%ebp)
0x80483bb : jne 0x80483cf
0x80483bd : sub $0xc,%esp
0x80483c0 : push $0x8048672
0x80483c5 : call 0x80482c0
0x80483ca : add $0x10,%esp
0x80483cd : jmp 0x80483df
0x80483cf : sub $0xc,%esp
0x80483d2 : push $0x8048689
0x80483d7 : call 0x80482c0
0x80483dc : add $0x10,%esp
0x80483df : leave
0x80483e0 : ret
End of assembler dump.
(gdb)
Stessa cosa ovviamente con objdump.
Non dimentichiamo inoltre che abbiamo mantenuto la symbols table,
facilitando comunque il raggiungere la funzione, seppur criptata.
Togliendo i simboli si dovrebbe ricostruire il flusso d'esecuzione a
aprtire dall'entry point in e_entry e ricavare l'indirizzo del main, il
quale viene passato come argomento (push) alla funzione
__libc_start_main, ma questa è un'altra storia.
11. Vulnerabilità
Non ho provato questa vulnerabilità, ma obiettivamente dovrebbe
funzionare e permettere di avere un binario non criptato.
Ovvero: al momento dell'esecuzione, quando il motore decripta la
funzione per eseguirla, una volta decriptata si può fare un dump
dell'area di memoria che contiene il .text in chiaro. Una volta ottenuto
è quindi possibile salvarlo e poi sovrascrivere all'interno dell'ELF
quello della funzione criptata e noppare (inserire nop 0x90) il pezzo di
codice (in chiaro ovviamente) che dovrebbe decriptare la porzione di
codice che ora sarebbe in chiaro.
12. Ringraziamenti
Sembra stupido, ma voglio ringraziare la mia ragazza, Alessandra, che mi
ha dato una mano a tirar fuori la parte della rilocazione, e brnocrist
che mi ha fatto da mentore per lo studio degli ELF.
|
|
|
|