5. Problemi dell'esempio precedente
Consideriamo i problemi che vengono introdotti dal semplice decompilatore descritto prima, e proviamo a trovare una possibile soluzione. Iniziamo con l’istruzione iniziale che abbiamo decompilato:
0x004011E5: or byte [ebp-0x3], 0x8 -> L4011E5: ebp[-0x3] |= 0x8;
- EBP è un registro del processore, quindi non è noto a un compilatore generico, ma nell’architettura x86 si riferisce al puntatore alla base dello stack fornito al nostro programma; quindi, per compilare il nostro programma, dobbiamo dichiarare EBP. In questo caso, possiamo dichiarare che EBP è un puntatore ad uno spazio di memoria, cioè:
unsigned char *ebp;
ebp[-0x3] |= 0x8;
- EBP viene utilizzato principalmente per puntare al frame della procedura locale (vedremo in seguito come questo può esserci utile). L’istruzione precedente sta probabilmente tentando di accedere a una variabile locale che è stata dichiarata di dimensioni in byte. Tuttavia, potrebbero esserci altre variabili locali di dimensioni diverse e saranno tutte accessibili tramite EBP (a diversi offset da EBP), quindi dichiarare EBP come puntatore a
unsigned char
non funzionerà per quelle altre variabili. Possiamo risolvere questo problema forzando la dimensione di ogni accesso attraverso il registro, in base alla dimensione dell’istruzione originale:
char *bp;
*(char *)(ebp-0x3) |= 0x8;
L’accesso a una variabile diversa utilizzerà un cast diverso:
*(short *)(ebp-0x8) += 10;
Anche se di solito EBP contiene un indirizzo, ci sono casi in cui il compilatore potrebbe usarlo per mantenere una costante numerica (nelle funzioni senza frame EBP è usato proprio come qualsiasi altro registro). Se EBP viene utilizzato in un’operazione aritmetica diversa da + o -, il compilatore si lamenterà se lo dichiariamo
char *
. Quindi dobbiamo dichiararlo comeint
. Questo non è un problema quando si utilizza l’approccio cast sopra, perché l’aggiunta e la sottrazione da un numero intero è essenzialmente la stessa dell’aggiunta e della sottrazione da unchar *
.Un tipo di istruzione che non è facilmente tradotto utilizzando il metodo sopra è per quelle istruzioni che impostano un registro con un indirizzo per un uso successivo. Queste istruzioni possono essere indistinguibili dalle istruzioni che caricano una costante numerica per qualche calcolo successivo:
8D 3C 85 DC 68 40 00 lea edi, [eax*4+0x4068DC] // A
8D 3C 85 04 00 00 00 lea edi, [eax*4+4] // B
La traduzione di queste due istruzioni è piuttosto diversa:
edi = &var_4068DC[eax*4]; // A
edi = 4 + eax * 4; // B
Perché c’è questa differenza? Poiché in un caso si accederà a un’area di memoria, quindi dobbiamo prendere l’indirizzo di quell’area di memoria e indicizzarla (in effetti, dovremmo probabilmente rimuovere anche la moltiplicazione, poiché è così che si accede tipicamente all’array di numeri interi, quindi il l’istruzione dovrebbe diventare: edi = &var_4068DC[eax];
). Nel secondo caso, viene eseguito un calcolo numerico e il compilatore ha scelto di utilizzare un’istruzione lea
(Load Effective Address) invece della sequenza più lunga mul e add . Risolvere questo problema non è facile.
Questi sono solo alcuni esempi. Come hai visto, le istruzioni del flusso di controllo (salti, chiamate) non si traducono molto bene e l’utilizzo del metodo di traduzione meccanica descritto sopra crea rapidamente codice C generato illeggibile che può gonfiarsi se ricompilato in codice macchina dopo la traduzione.
Abbiamo chiaramente bisogno di una soluzione migliore. Le euristiche fin’ora viste possono aiutare l’analista a rappresentare il programma ad alto livello, tuttavia per evitare di incorrere in alcuni problemi è necessario procedere per modularità. Nei prossimi capitoli vedremo come affrontare in maniera più rigorosa la traduzione inversa e cercheremo di implementare una prima versione solida del compilatore.