21. Codice vs dati: un altro tentativo

Ricorda che separando il codice dai dati raggiungiamo i seguenti obiettivi:

  • evitiamo di disassemblare le aree dati, che confonderebbero il rilevamento del loop e gli algoritmi di durata variabile. Il disassemblatore non riesce a capire in modo autonomo la differenza tra le diverse informazioni, restituendo la prima istruzione valida che trova all’interno della sequenza di byte grezza.

  • rileviamo la destinazione delle diramazioni e le chiamate di funzione. Conoscendo la relazione tra i rami, possiamo calcolare cicli che ci consentono di calcolare la durata delle variabili. Conoscere la destinazione delle chiamate di funzione ci consente di calcolare il numero di parametri che ogni funzione riceve e il tipo di valore restituito da una funzione, e ci consente anche di registrare quali registri vengono salvati durante la chiamata e quali registri vengono eliminati.

  • possiamo classificare le funzioni in base alla loro distanza dal punto di ingresso e in base alla loro complessità. Analizzare prima le funzioni più semplici ci consente di eseguire un’analisi più accurata delle funzioni più complesse. Le funzioni più semplici corrispondono al nodo foglia.

Come abbiamo visto nella pagina precedente, le istruzioni switch pongono un serio problema al compito di separare le regioni di codice dalle regioni di dati.

Il problema dello switch è un caso particolare del problema più generico di come gestire il codice a cui si fa riferimento tramite i puntatori. Mentre nel caso switch il puntatore veniva generato automaticamente dal compilatore e aveva un ambito abbastanza limitato (quello del blocco base dell’istruzione switch e possibilmente quello dei predecessori del blocco), ci sono altri casi che coinvolgono puntatori al codice che hanno un ambito più ampio .

In questa pagina mostriamo alcuni di questi casi.

Puntatori a funzioni esplicite

Linguaggi come C e C++ consentono l’uso di un puntatore a una funzione. Per esempio:

C code                   Asm code
---------------          --------------------
int (*ptr)();

ptr = addr1;             L1:   R1 = addr1
                               [ptr] = R1
...                            ...
(*ptr)();                L2:   R2 = [ptr]
                               CALL [R2]

Come fa il decompilatore a sapere quale funzione viene chiamata in L2? Inoltre, come sappiamo che ptr è un puntatore al codice? Dalle istruzioni in L1, non vi è alcuna indicazione che addr1 sia una funzione. Certo, potremmo presumere che se addr1 è nella sezione di testo è il punto di ingresso di una funzione, ma non c’è alcuna garanzia di ciò. Solo se in qualche altra parte del codice c’è una chiamata esplicita a addr1 allora siamo certi che si tratta di una funzione. Nel caso in cui sia L1 che L2 siano nella stessa funzione, potremmo applicare l’analisi del tipo a ptr e scoprire che è usato come puntatore a funzione dall’istruzione CALL, e quindi dedurre che addr1 è una funzione. Tuttavia, se L1 e L2 si trovano in funzioni diverse, diventa molto difficile sapere che a ptr è stato assegnato un indirizzo e quindi è stato utilizzato in un’istruzione CALL. Inoltre, ricordiamo che stiamo facendo il code walking durante le prime fasi di decompilazione, e in questa fase non abbiamo fatto alcuna analisi del flusso di dati, o ancor meno, alcuna analisi di tipo.

Potremmo applicare alcune euristiche a addr1 per vedere se inizia con la tipica sequenza di istruzioni che salva il frame pointer e alloca lo stack locale. Tuttavia, se l’euristica fallisce, non saremo in grado di identificare addr1 come funzione, il che significa che il suo codice, insieme al codice di qualsiasi altra funzione che chiama, rimarrà irraggiungibile per il decompilatore.

Un caso speciale di quanto sopra è una forma di piegatura costante applicata alle istruzioni CALL, tipicamente eseguita dal compilatore Microsoft e dai compilatori Green Hills. Quando viene eseguita questa ottimizzazione, ad esempio per ridurre la dimensione del codice generato, viene assegnato un registro all’indirizzo della funzione, e successivamente utilizzato nell’istruzione CALL senza salvare il registro in una locazione di memoria. Questo caso è un po’ più semplice da gestire, poiché un semplice algoritmo di propagazione costante dovrebbe essere in grado di ricollegare l’istruzione CALL con il suo indirizzo di destinazione. Per esempio:

C code               Asm code                Decompiled code
---------------      -----------------       ----------------

func1();             L1:   R1 = addr1
                           CALL [R1]         addr1();
...                        ...
func1();                   CALL [R1]         addr1();
...                        ...
func2();                   CALL func2        func2();
...                        ...
func1();                   CALL [R1]         addr1(); // ?
------------------------------------------------------------

SymbolTable.defineFunction(addr1)

In questo caso possiamo vedere che non ci sono altri assegnamenti a R1, e che tutte le istruzioni sono raggiungibili dalla stessa funzione, quindi possiamo convertire le istruzioni CALL [R1] in CALL addr1 (sempre che sappiamo che func2 non cambia il valore di R1!).

Nel caso in cui ci siano più valori possibili per la destinazione, possiamo ancora rilevare che ogni valore è un indirizzo di funzione, anche se non saremo sempre in grado di sostituire le istruzioni CALL. Nell’esempio seguente var è una variabile locale e tutte le istruzioni si trovano nella stessa funzione:

C code               Asm code                Decompiled code
---------------      -----------------       ----------------

void *(var)();

var = addr1;         L1:   R1 = addr1        var = addr1;
(*var)();                  CALL [R1]         addr1();
...                        ...
(*var)();                  CALL [R1]         addr1();
...                        ...
if (expr)                                    if (expr)
   var = addr2;            R1 = addr2          var = addr2;
...                        ...
(*var)();            L2:   CALL [R1]         (*var)();
------------------------------------------------------------

SymbolTable.defineFunction(addr1)
SymbolTable.defineFunction(addr2)

In questo caso, possiamo utilizzare le catene use-def per identificare tutti i possibili valori di R1 in L2 e definire una funzione per ciascuno degli indirizzi (in questo caso addr1 e addr2 ). Tuttavia, non potremo convertire l’istruzione CALL [R1] in una chiamata diretta, e non potremo eliminare la variabile locale var , poiché il suo valore non è costante in L2 (dipende se l’if () l’affermazione era vera).

Puntatori di funzione impliciti

Linguaggi come C++ e Java hanno il concetto di metodo “virtuale”. I metodi virtuali non sono altro che puntatori a funzione associati alla classe o allo struct che li definisce. Quando un metodo è contrassegnato come “virtuale”, il compilatore utilizzerà sempre una chiamata indiretta per accedere al metodo, prendendo l’indirizzo da una tabella speciale (la “tabella virtuale”) associata all’istanza di quella classe (il puntatore this).

Compilatori diversi usano metodi diversi per recuperare il puntatore al metodo dalla tabella virtuale per ogni classe. Questi metodi non sono ufficialmente documentati, poiché non fanno parte dell’interfaccia binaria dell’applicazione di un’architettura, il che implica che non è generalmente possibile combinare librerie compilate con un compilatore C++ con codice compilato con un altro compilatore C++.

Questo è un grosso problema per un decompilatore, poiché non solo il decompilatore deve dedurre che il codice proveniva originariamente da una sorgente C++ anziché da una sorgente C, ma deve anche dedurre quale compilatore è stato utilizzato e utilizzare diversi algoritmi di accesso per identificare dove si trova la tabella virtuale per ogni classe e dove sono i puntatori al metodo virtuale.

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