19. Recuperare i tipi

Finora abbiamo recuperato funzioni, istruzioni e variabili. La parte mancante per recuperare un programma di alto livello sono i tipi di variabili e funzioni.

Normalmente pensiamo a tipi associati a variabili; tuttavia anche le funzioni hanno i loro tipi, cioè la loro dichiarazione. In realtà, però, la dichiarazione di una funzione è costituita dall’elenco dei parametri della funzione, che sono variabili, e dal tipo restituito, che possiamo considerare una variabile speciale implicitamente dichiarata dal compilatore e utilizzata come argomento dell’istruzione return.

Quindi, per tutti gli scopi pratici, possiamo considerare tipi associati solo a variabili.

Ma come facciamo a sapere di che tipo è una variabile? Questo è un problema molto difficile da risolvere, e in realtà non c’è molta speranza di recuperare il tipo originale di ogni variabile. Tuttavia, fintanto che preserviamo il comportamento del programma, possiamo semplicemente assegnare a ciascuna variabile un tipo compatibile con le operazioni eseguite su quella variabile.

Infatti, determinate operazioni possono essere eseguite solo su determinati tipi di variabili. Tuttavia, altre operazioni possono essere eseguite su molti tipi di variabili. Vediamo alcuni esempi per rendere più chiaro questo punto.

Fonti di informazioni sul tipo

Alcune operazioni sono garantite per essere eseguite solo su determinati tipi. Cominciamo con l’operazione più precisa:

CMP.B  [var1], #123
JNC    Label   #           if (var1 >=unsigned 123) goto Label;

In questo esempio, abbiamo una posizione di memoria, var1. Il tipo di quella singola posizione di memoria può essere determinato dalla sequenza CMP+JNC come " unsigned char “, poiché l’ operazione JNC verifica il risultato di un confronto senza segno. Se la condizione del ramo fosse JLT , allora avremmo potuto determinare che la locazione di memoria era un " char firmato “.

Sfortunatamente, il numero di operazioni che forniscono informazioni così precise sui tipi dei suoi operandi è molto limitato. Se la condizione fosse JEQ o JNE , non saremmo stati in grado di determinare la segnatura della posizione di memoria. Saremmo solo in grado di sapere che la variabile è larga 8 bit. In altre parole:

JNC, JC     =>   unsigned                >=unsigned  <unsigned
JGE, JLT    =>   signed                  >=signed    <signed
JEQ, JNE    =>   signed or unsigned      ==          !=

Altre operazioni hanno la stessa caratteristica. Le operazioni AND , OR e XOR possono essere eseguite solo su tipi integrali e restituiscono sempre un risultato intero, ma non sappiamo se gli operandi sono con o senza segno:

MOV.B  R1, [var1]
AND.B  R1, [var2]    // same for OR.B, XOR.B
MOV.B  [var3], R1

In questo caso, sappiamo che le tre variabili sono larghe 8 bit, ma non sappiamo se sono con o senza segno. Tuttavia, se in seguito si determina che una delle variabili è senza segno (ad esempio perché è utilizzata come operando di un CMP+JNC ), allora possiamo presumere che tutte e 3 le variabili siano senza segno.

Determinare i tipi di variabile è quindi un esercizio di propagazione dei dati dalla fonte delle informazioni (l’ espressione CMP+JNC ) a tutti gli altri usi della stessa variabile, e da quelle variabili a tutti gli altri usi di quelle variabili e così via.

Si noti che funziona ugualmente bene se l’istruzione var3 = var1 & var2 è prima dell’istruzione >=unsigned, poiché i tipi non sono influenzati dal flusso di esecuzione.

Si potrebbe presumere che le operazioni aritmetiche verrebbero eseguite anche su tipi integrali. Purtroppo questo non è corretto, perché anche diverse operazioni aritmetiche possono operare sui puntatori! Considera quanto segue:

 ADD  [var1], #4    // sapendo che ADD è l'inversa della SUB, si applica anche alle istruzioni SUB  
 int  var1;       var1 += 4;
 int  *var1;      var1 = &var1[1];
 short *var1;     var1 = &var1[2];
 char *var1;      var1 = &var1[4];

Quindi, solo osservando l’ istruzione ADD non possiamo decidere quale dei 4 tipi utilizzare per " var1 “.

Le istruzioni di moltiplicazione sono più precise ma il loro risultato non può essere propagato troppo lontano. Considera quanto segue:

R1 = [var1]
R2 = R1 * 4
[var3] = [var2] + R2

int var1;    // questa dichiarazione è certa
int var2, var3;      var3 = var2 + var1 * 4;
int *var2, *var3;    var3 = &var2[var1];

In questo esempio, sappiamo che var1 è un numero intero, ma non possiamo decidere se anche var2 e var3 sono numeri interi o se sono puntatori a oggetti di dimensione 4.

La divisione ha lo stesso problema:

R1 = [var1] - [var2]
R2 = R1 / 4
[var3] = R2

int var1, var2;      var3 = (var1 - var2) / 4;
int *var1, *var2;    var3 = (var1 - var2);
int var3;   // questa dichiarazione è certa

Di nuovo, qui tutto ciò che possiamo determinare è che var3 è un numero intero, ma non possiamo sapere se anche var1 e var2 sono numeri interi o se sono puntatori a oggetti di dimensione 4.

Infine, la maggior parte degli accessi alla memoria ci consente di determinare che una variabile è un puntatore, ma non il tipo di oggetto a cui si accede tramite il puntatore:

R1 = var2[var3]
var1 = R1

? var1;
? *var2;  int var3;      var1 = var2[var3];
int var2; ? *var3;       var1 = var3[var2];

Anche se questo può sembrare strano, la verità è che " var2[var3] " è in realtà " t = var2 + var3; t[0] “, quindi senza una moltiplicazione di var2 o var3 , non possiamo decidere quale sia il puntatore e quale è l’indice.

Altre fonti di informazioni

Data la descrizione sopra delle fonti di informazioni sui tipi, sembra che l’assegnazione di tipi alle variabili sia una battaglia persa. In effetti, non c’è molto che un decompilatore possa fare oltre ad applicare le regole descritte nel capitolo precedente.

Se il programma in fase di decompilazione utilizza una libreria di runtime C o una funzione del sistema operativo, è possibile utilizzare un database di dichiarazioni di funzione per identificare i parametri passati alla funzione e il tipo del tipo restituito. Considera quanto segue:

PUSH  var1                             ===>    char *var1;
CALL  strlen     var2 = strlen(var1)
SP += 4
var2 = R0                              ===>    size_t var2;

Utilizzando il database dei prototipi di funzione, possiamo determinare che var1 è un char * e che var2 è un int (in realtà un size_t , che di solito è mappato su int ). Possiamo quindi propagare i tipi che abbiamo scoperto dalla chiamata di funzione agli altri usi di var1 e var2 .

Questo approccio è ancora più potente quando la funzione riceve una struttura come argomento. Per esempio:

R1 = &var2                           ===>  struct stat var2;
PUSH R1
PUSH var1                            ===>  int var1; // a filedesc.
CALL stat       stat(var1, &var2)
var3 = R1[4]    var3 = var2.st_ino;  ===>  ino_t var3;

Il rilevamento delle chiamate alle funzioni di sistema è quindi molto importante non solo per sapere quanti parametri vengono passati a ciascuna funzione, ma anche per determinarne i tipi di funzioni già conosciute.

Biografia

Sono uno specialista in materia di sicurezza informatica, scrittore, contributore per il progetto Monero, una criptovaluta che è focalizzata nel proteggere le informazioni sulle transazioni. Il libro che ho pubblicato Mastering Monero è diventato una delle migliori risorse per padroneggiare Monero. Più informazioni su di me

Seguimi su Twitter o scrivimi una e-mail. Le donazioni sono molto apprezzate, mi permettono di continuare a lavorare e a scrivere.

Mastering Monero Book