Perché il codice macchina nativo non può essere decompilato facilmente?

16

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

Recentemente ho iniziato a chiedermi perché non si ascolti di più riguardo al codice binario nativo, quando almeno conosci la lingua in cui è stato scritto in origine (e quindi quale lingua cercare di decompilare). Per molto tempo ho pensato che fosse solo perché il linguaggio macchina nativo è molto più folle e complesso del tipico codice byte.

Ma che aspetto ha il bytecode? Assomiglia a questo:

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 hex)? 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 in qualche modo 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 in, ad esempio in C ++, cosa c'è di così difficile? Le uniche due idee che vengono subito in mente sono 1) è davvero molto più intricata di 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, per favore spiega. Ma in entrambi i casi, perché non ne hai mai sentito parlare sostanzialmente?

Nota

Sto per accettare una delle risposte, ma prima voglio menzionare qualcosa. Quasi tutti si riferiscono al fatto che pezzi diversi del codice sorgente originale potrebbero essere associati allo stesso codice macchina; i nomi delle variabili locali sono persi, non si sa quale tipo di loop è stato originariamente utilizzato, ecc.

Tuttavia esempi come i due che sono stati appena menzionati sono una specie di banale nei miei occhi. Alcune delle risposte tendono a dire che la differenza tra il codice macchina e la fonte originale è drasticamente molto più di qualcosa di così banale.

Però, per esempio, quando si tratta di cose come nomi di variabili locali e tipi di loop, bytecode perde anche queste informazioni (almeno per ActionScript 3.0). Ho rimosso quella roba da un decompilatore in precedenza, e non mi importava davvero se una variabile fosse chiamata strMyLocalString:String o loc1 . Potrei ancora guardare in quel piccolo ambito locale e vedere come viene usato senza troppi problemi. E un ciclo for è praticamente la stessa cosa di un ciclo while , se ci pensi. Inoltre, anche quando eseguivo la sorgente tramite irrFuscator (che, a differenza di secureSWF, non fa molto più che randomizzare nomi di variabili e funzioni membro), sembrava ancora che si potesse iniziare ad isolare determinate variabili e funzioni in classi più piccole, come vengono utilizzati, assegna loro i tuoi nomi e lavora da lì.

Affinché questo sia un grosso problema, il codice della macchina dovrebbe perdere molte più informazioni di quello, e alcune delle risposte sono utili.

    
posta Panzercrisis 21.02.2014 - 00:39
fonte

4 risposte

39

In ogni fase della compilazione perdi informazioni che sono irrecuperabili. Più informazioni perdi dalla fonte originale, più difficile sarà decompilare.

È possibile creare un utile de-compiler per il codice byte poiché molte più informazioni vengono preservate dall'origine originale di quante ne siano preservate quando si produce il codice macchina finale di destinazione.

Il primo passo di un compilatore è trasformare la fonte in una porzione di 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 buttato via non puoi recuperare la fonte originale da quell'albero.

Il prossimo passo è rendere l'albero in una qualche forma di linguaggio intermedio che renda le ottimizzazioni più facili. Ci sono alcune scelte qui e ogni infrastruttura del compilatore ce l'ha proprio. In genere, tuttavia, informazioni come nomi di variabili locali, strutture di flusso di controllo di grandi dimensioni (ad esempio se si è utilizzato un ciclo for o while) vengono perse. Alcune importanti ottimizzazioni tipicamente accadono qui, propagazione costante, movimento di codice invariante, inlining di funzioni, ecc. Ognuno di questi trasforma la rappresentazione in una rappresentazione che ha funzionalità equivalenti ma sembra sostanzialmente diversa.

Un passo dopo è quello di generare le istruzioni macchina reali che potrebbero coinvolgere quella che viene definita 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 di classi, per consentire la compilazione dello stesso codice byte su più codici macchina di destinazione. Tutte queste informazioni non sono necessarie in un programma C ++ e vengono scartate 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é viene persa troppa parte della fonte originale. Se disponi di informazioni di debug per l'eseguibile, puoi fare un lavoro ancora migliore; ma, se disponi di informazioni di debug, probabilmente hai anche la fonte originale.

    
risposta data 21.02.2014 - 01:20
fonte
11

La perdita di informazioni, come sottolineato dalle altre risposte, è un punto, ma non è il dealbreaker. Dopo tutto, non ti aspetti il programma originale, vuoi solo la qualsiasi rappresentazione in un linguaggio di alto livello. Se il codice è in linea, puoi semplicemente lasciarlo 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 (senza una quantità infinita di computazione almeno).

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

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

potrebbe essere compilato (scusa se questo non è 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 invertire facilmente. Ma per quanto riguarda l'indirizzo 0x1012? Dovresti creare anche case 3 per questo? Dovresti rintracciare l'intero programma nel peggiore dei casi per capire quali valori sono consentiti. Ancora peggio, potrebbe essere necessario considerare tutti i possibili input dell'utente! Al centro del problema è che non è possibile distinguere dati e istruzioni.

Detto questo, non sarei del tutto pessimista. Come avrai notato nel precedente "assemblatore", se x viene da fuori ed è non garantito come 1 o 2, in pratica hai un bug errato che ti permette 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 Java bytecode siano molto più facili da decompilare, anche mettendo da parte i metadati.) Quindi, in pratica, dovrebbe essere possibile decompilare determinati, ben educati programmi. Sto pensando a routine di stile individuali e funzionali, che non hanno effetti collaterali e input ben definiti. Penso che ci siano un paio di decompilatori che possono dare pseudocodice per funzioni semplici, ma non ho molta esperienza con questi strumenti.

    
risposta data 21.02.2014 - 12:10
fonte
9

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

Ad esempio:

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

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

int main()
{
    return DoSomething();
}

Può essere compilato in:

main:
mov eax, 7;
ret;

Il mio assembly è piuttosto arrugginito, ma se il compilatore può verificare che l'ottimizzazione possa essere eseguita in modo accurato, lo farà. Ciò è dovuto al fatto che il binario compilato non ha bisogno di conoscere i nomi DoSomething e Add , così come il fatto che il metodo Add ha due parametri con nome, il compilatore sa anche che il metodo DoSomething restituisce essenzialmente un costante, e potrebbe integrare sia la chiamata al metodo che il metodo stesso.

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

    
risposta data 21.02.2014 - 01:21
fonte
3

I principi generali qui sono mappature molti-a-uno e mancanza di rappresentanti canonici.

Per un semplice esempio di fenomeno molti-a-uno, puoi pensare a cosa succede quando prendi una funzione con alcune variabili locali e la compili al 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 for o while e se sono strutturati correttamente potresti ottenere codice macchina identico con istruzioni jump .

Questo fa emergere anche la mancanza di rappresentanti canonici dal codice sorgente originale per le istruzioni del codice macchina. Quando provi a decompilare i loop, come si mappano le istruzioni jump ai costrutti ciclici? Li fai for loop o while loops.

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 stabilire da quale livello alto viene generato il codice macchina di basso livello.

    
risposta data 21.02.2014 - 06:51
fonte

Leggi altre domande sui tag