Perché non è possibile decompilare facilmente il codice macchina nativo?


16

Con linguaggi di macchine virtuali basati su bytecode come Java, VB.NET, C #, ActionScript 3.0, ecc., A volte senti parlare di quanto sia facile scaricare un decompilatore da Internet, eseguire il bytecode attraverso di esso una buona volta e spesso, viene fuori qualcosa di non troppo lontano dal codice sorgente originale in pochi secondi. Presumibilmente questo tipo di linguaggio è particolarmente vulnerabile a questo.

Di recente ho iniziato a chiedermi perché non senti di più su questo riguardo al codice binario nativo, quando almeno sai in quale lingua è stato scritto in origine (e quindi in quale lingua provare a decompilare). Per molto tempo, ho pensato che fosse solo perché il linguaggio macchina nativo è molto più folle e più complesso del tipico bytecode.

Ma che aspetto ha il bytecode? Sembra così:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

E come appare il codice macchina nativo (in esadecimale)? Ovviamente, assomiglia a questo:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

E le istruzioni provengono da uno stato d'animo un po 'simile:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

Quindi, dato il linguaggio per provare a decompilare un binario nativo, diciamo C ++, cosa c'è di così difficile? Le uniche due idee che mi vengono subito in mente sono 1) è davvero molto più intricato del bytecode, o 2) qualcosa sul fatto che i sistemi operativi tendono a impaginare i programmi e spargere i loro pezzi causa troppi problemi. Se una di queste possibilità è corretta, spiega. Ma in entrambi i casi, perché non ne hai mai sentito parlare?

NOTA

Sto per accettare una delle risposte, ma prima voglio menzionare qualcosa. Quasi tutti fanno riferimento al fatto che diversi pezzi di codice sorgente originale potrebbero essere associati allo stesso codice macchina; i nomi delle variabili locali vengono persi, non si conosce il tipo di loop originariamente utilizzato, ecc.

Tuttavia esempi come i due che sono stati appena citati sono in qualche modo banali ai miei occhi. Alcune delle risposte tendono tuttavia a affermare che la differenza tra il codice macchina e l'origine originale è drasticamente molto più di qualcosa di così banale.

Ma per esempio, quando si tratta di cose come nomi di variabili locali e tipi di loop, anche il bytecode perde queste informazioni (almeno per ActionScript 3.0). Ho già recuperato quella roba attraverso un decompilatore prima e non mi importava davvero se una variabile fosse chiamata strMyLocalString:Stringo loc1. Potrei ancora guardare in quel piccolo ambito locale e vedere come viene utilizzato senza troppi problemi. E un forloop è praticamente la stessa cosa esatta di awhileloop, se ci pensate. Inoltre, anche quando eseguivo il sorgente tramite irrFuscator (che, a differenza di secureSWF, non fa molto di più che randomizzare i nomi di variabili e funzioni dei membri), sembra comunque che potresti iniziare a isolare determinate variabili e funzioni in classi più piccole, figura scoprire come vengono utilizzati, assegnare loro i tuoi nomi e lavorare da lì.

Affinché questo sia un grosso problema, il codice macchina dovrebbe perdere molte più informazioni di così, e alcune delle risposte vanno in questo.


35
È difficile ricavare una mucca dagli hamburger.
Kaz Dragon,

4
Il problema principale è che un binario nativo conserva pochissimi metadati sul programma. Non conserva informazioni sulle classi (rendendo il C ++ particolarmente difficile da decompilare) e non sempre nemmeno nulla sulle funzioni - non è necessario poiché una CPU esegue intrinsecamente il codice in modo abbastanza lineare, un'istruzione alla volta. Inoltre, è impossibile distinguere tra codice e dati ( link ). Per ulteriori informazioni, si può prendere in considerazione la ricerca o ri-chiedere a RE.SE .
ntoskrnl,

Risposte:


39

Ad ogni fase della compilazione si perdono informazioni irrecuperabili. Più informazioni perdi dalla fonte originale, più è difficile decompilare.

È possibile creare un utile de-compilatore per il codice byte perché molte più informazioni vengono conservate dalla fonte originale di quelle conservate durante la produzione del codice macchina di destinazione finale.

Il primo passo di un compilatore è trasformare la fonte in una rappresentazione intermedia spesso rappresentata come un albero. Tradizionalmente questo albero non contiene informazioni non semantiche come commenti, spazi bianchi, ecc. Una volta che questo viene gettato via non è possibile recuperare la fonte originale da quell'albero.

Il prossimo passo è rendere l'albero in una forma di linguaggio intermedio che semplifichi le ottimizzazioni. Ci sono alcune scelte qui e ogni infrastruttura del compilatore ha la propria. In genere, tuttavia, informazioni come nomi di variabili locali, strutture di flusso di controllo di grandi dimensioni (ad esempio se è stato utilizzato un ciclo for o while) vengono perse. Alcune importanti ottimizzazioni in genere si verificano qui, propagazione costante, movimento di codice invariante, allineamento di funzioni, ecc. Ognuna delle quali trasforma la rappresentazione in una rappresentazione che ha funzionalità equivalenti ma sembra sostanzialmente diversa.

Un passo dopo è generare le istruzioni effettive della macchina che potrebbero comportare quella che viene chiamata ottimizzazione "peep-hole" che produce una versione ottimizzata di schemi di istruzioni comuni.

Ad ogni passo perdi sempre più informazioni fino a quando, alla fine, perdi così tanto che diventa impossibile recuperare qualcosa che assomigli al codice originale.

Il codice byte, d'altra parte, salva in genere le ottimizzazioni interessanti e trasformative fino alla fase JIT (il compilatore just-in-time) quando viene prodotto il codice macchina di destinazione. Il codice byte contiene molti metadati come tipi di variabili locali, struttura della classe, per consentire la compilazione dello stesso codice byte in più codici macchina di destinazione. Tutte queste informazioni non sono necessarie in un programma C ++ e vengono eliminate nel processo di compilazione.

Esistono decompilatori per vari codici macchina di destinazione, ma spesso non producono risultati utili (qualcosa che è possibile modificare e quindi ricompilare) poiché si perde troppa fonte originale. Se disponi di informazioni di debug per l'eseguibile, puoi fare un lavoro ancora migliore; ma, se hai informazioni di debug, probabilmente hai anche la fonte originale.


5
Il fatto che le informazioni siano conservate in modo che JIT possa funzionare meglio è la chiave.
btilly

Le DLL C ++ sono quindi facilmente decompilabili?
Panzercrisis,

1
Non in nulla riterrei utile.
Chuckj,

1
I metadati non "consentono di compilare lo stesso codice byte su più target", è lì per la riflessione. La rappresentazione intermedia retargetable non deve disporre di tali metadati.
SK-logic,

2
Quello non è vero. Gran parte dei dati sono lì per la riflessione, ma la riflessione non è l'unico uso. Ad esempio, le definizioni dell'interfaccia e della classe vengono utilizzate per creare l'offset di campo definito, costruire tabelle virtuali, ecc. Sulla macchina target, consentendo loro di essere costruite nel modo più efficiente per la macchina target. Queste tabelle sono costruite dal compilatore e / o dal linker durante la produzione di codice nativo. Fatto ciò, i dati utilizzati per costruirli vengono scartati.
Chuckj,

11

La perdita di informazioni, come sottolineato dalle altre risposte, è un punto, ma non è il rompicapo. Dopotutto, non ti aspetti che il programma originale torni indietro, vuoi solo qualsiasi rappresentazione in un linguaggio di alto livello. Se il codice è inline, puoi semplicemente lasciarlo essere, o calcolare automaticamente i calcoli comuni. In linea di principio è possibile annullare molte ottimizzazioni. Ma ci sono alcune operazioni che sono in linea di principio irreversibili (almeno senza una quantità infinita di elaborazione).

Ad esempio, i rami potrebbero diventare salti calcolati. Codice come questo:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

potrebbe essere compilato in (mi dispiace che questo non sia un vero assemblatore):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

Ora, se sai che x può essere 1 o 2, puoi guardare i salti e invertirlo facilmente. Ma che dire dell'indirizzo 0x1012? Dovresti crearne uno anche case 3tu? Dovresti tracciare l'intero programma nel peggiore dei casi per capire quali valori sono ammessi. Ancora peggio, potresti dover considerare tutti i possibili input dell'utente! Il nocciolo del problema è che non è possibile distinguere dati e istruzioni.

Detto questo, non sarei del tutto pessimista. Come avrai notato nell'assemblatore di cui sopra, se x proviene dall'esterno e non è garantito che sia 1 o 2, in pratica hai un brutto bug che ti consente di saltare ovunque. Ma se il tuo programma è libero da questo tipo di bug, è molto più facile ragionare. (Non è un caso che linguaggi intermedi "sicuri" come CLR IL o bytecode Java siano molto più facili da decompilare, anche mettendo da parte i metadati.) Quindi, in pratica, dovrebbe essere possibile decompilare determinati, ben educatiprogrammi. Sto pensando a routine individuali, funzionali, che non hanno effetti collaterali e input ben definiti. Penso che ci siano un paio di decompilatori in giro che possono fornire uno pseudocodice per funzioni semplici, ma non ho molta esperienza con tali strumenti.


9

Il motivo per cui il codice macchina non può essere facilmente convertibile in codice sorgente originale è che molte informazioni vengono perse durante la compilazione. Metodi e classi non esportate possono essere incorporati, i nomi delle variabili locali vengono persi, i nomi dei file e le strutture vengono persi completamente, i compilatori possono effettuare ottimizzazioni non ovvie. Un altro motivo è che più file sorgente diversi potrebbero produrre lo stesso assembly esatto.

Per esempio:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Può essere compilato per:

main:
mov eax, 7;
ret;

Il mio assemblaggio è piuttosto arrugginito, ma se il compilatore può verificare che un'ottimizzazione possa essere eseguita con precisione, lo farà. Ciò è dovuto al fatto che il binario compilato non ha bisogno di conoscere i nomi DoSomethinge Add, oltre al fatto che il Addmetodo ha due parametri nominati, il compilatore sa anche che il DoSomethingmetodo essenzialmente restituisce una costante e potrebbe incorporare sia la chiamata del metodo che il metodo metodo stesso.

Lo scopo del compilatore è quello di creare un assembly, non un modo per raggruppare i file di origine.


Considera di cambiare l'ultima istruzione in giusto rete solo dire che stavi assumendo la convenzione di chiamata C.
Chuckj,

3

I principi generali qui sono mappature molte-a-una e mancanza di rappresentanti canonici.

Per un semplice esempio di fenomeno molti-a-uno puoi pensare a cosa succede quando si prende una funzione con alcune variabili locali e la si compila in codice macchina. Tutte le informazioni sulle variabili vengono perse perché diventano solo indirizzi di memoria. Qualcosa di simile accade per i loop. Puoi prendere un ciclo foro whilee se sono strutturati nel modo giusto, potresti ottenere un codice macchina identico con le jumpistruzioni.

Ciò porta anche alla mancanza di rappresentanti canonici dal codice sorgente originale per le istruzioni del codice macchina. Quando si tenta di decompilare i loop come si mappano le jumpistruzioni ai costrutti loop? Li fai forloop o whileloop.

Il problema è ulteriormente esasperato dal fatto che i compilatori moderni eseguono varie forme di piegatura e allineamento. Quindi, quando arrivi al codice macchina è praticamente impossibile dire da quali costrutti di alto livello provenga il codice macchina di basso livello.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.