Warum kann nativer Maschinencode nicht einfach dekompiliert werden?


16

Bei bytecode-basierten virtuellen Maschinensprachen wie Java, VB.NET, C #, ActionScript 3.0 usw. hört man manchmal, wie einfach es ist, einen Decompiler aus dem Internet herunterzuladen, den Bytecode einmal durchzuarbeiten und oftmals lassen Sie sich in Sekundenschnelle etwas einfallen, das nicht allzu weit vom ursprünglichen Quellcode entfernt ist. Angeblich ist diese Art von Sprache dafür besonders anfällig.

Ich habe mich kürzlich gefragt, warum Sie nicht mehr über nativen Binärcode erfahren, wenn Sie zumindest wissen, in welcher Sprache er ursprünglich geschrieben wurde (und somit in welche Sprache Sie zu dekompilieren versuchen). Lange Zeit dachte ich, es sei nur so, weil die Maschinensprache so viel verrückter und komplexer ist als der typische Bytecode.

Aber wie sieht Bytecode aus? Es sieht aus wie das:

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

Und wie sieht nativer Maschinencode aus (in hex)? Das sieht natürlich so aus:

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

Und die Anweisungen kommen aus einer ähnlichen Denkweise:

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

Angesichts der Sprache, in der versucht werden soll, native Binärdateien in C ++ zu dekompilieren, was ist daran so schwer? Die einzigen beiden Ideen, die sofort in den Sinn kommen, sind: 1) Es ist wirklich viel komplizierter als Bytecode, oder 2) Etwas an der Tatsache, dass Betriebssysteme dazu neigen, Programme zu paginieren und ihre Teile zu zerstreuen, verursacht zu viele Probleme. Wenn eine dieser Möglichkeiten richtig ist, erklären Sie bitte. Aber warum hörst du eigentlich nie davon?

HINWEIS

Ich bin dabei, eine der Antworten zu akzeptieren, aber ich möchte zuerst etwas erwähnen. Fast jeder bezieht sich auf die Tatsache, dass verschiedene Teile des ursprünglichen Quellcodes demselben Maschinencode zugeordnet werden könnten. lokale Variablennamen gehen verloren, Sie wissen nicht, welche Art von Schleife ursprünglich verwendet wurde usw.

Beispiele wie die beiden, die gerade erwähnt wurden, sind jedoch in meinen Augen trivial. Einige der Antworten geben jedoch an, dass der Unterschied zwischen Maschinencode und der ursprünglichen Quelle drastisch größer ist als etwas, das so trivial ist.

Wenn es zum Beispiel um lokale Variablennamen und Schleifentypen geht, verliert Bytecode diese Informationen ebenfalls (zumindest für ActionScript 3.0). Ich habe das Zeug vorher durch einen Decompiler zurückgespielt und es war mir egal, ob eine Variable aufgerufen wurde strMyLocalString:Stringoder nicht loc1. Ich konnte immer noch in diesem kleinen Bereich vor Ort nachsehen, wie es verwendet wird, ohne große Probleme. Und eine forSchleife ist so ziemlich das Gleiche wie einewhileSchleife, wenn Sie darüber nachdenken. Auch wenn ich den Quellcode mit irrFuscator ausführen würde (der im Gegensatz zu secureSWF nicht viel mehr als nur die Namen von Member-Variablen und -Funktionen zufällig auswählt), sah es so aus, als könnten Sie einfach damit beginnen, bestimmte Variablen und Funktionen in kleineren Klassen zu isolieren, Abbildung Finden Sie heraus, wie sie verwendet werden, weisen Sie ihnen Ihre eigenen Namen zu und arbeiten Sie von dort aus.

Damit dies eine große Sache wird, müsste der Maschinencode viel mehr Informationen verlieren, und einige der Antworten gehen darauf ein.


35
Es ist schwierig, aus Hamburgern eine Kuh zu machen.
Kaz Dragon

4
Das Hauptproblem ist, dass eine native Binärdatei nur sehr wenige Metadaten über das Programm enthält. Es enthält keine Informationen zu Klassen (was das Dekompilieren von C ++ besonders schwierig macht) und nicht immer auch nur Informationen zu Funktionen - dies ist nicht erforderlich, da eine CPU den Code inhärent linear und anweisungsweise ausführt. Außerdem ist es unmöglich, zwischen Code und Daten ( Link ) zu unterscheiden. Weitere Informationen erhalten Sie, wenn Sie überlegen, ob Sie in RE.SE suchen oder erneut nachfragen möchten .
ntoskrnl

Antworten:


39

Bei jedem Schritt der Kompilierung gehen Informationen verloren, die nicht wiederhergestellt werden können. Je mehr Informationen Sie aus der Originalquelle verlieren, desto schwieriger ist die Dekompilierung.

Sie können einen nützlichen Dekompiler für Bytecode erstellen, da von der ursprünglichen Quelle viel mehr Informationen erhalten bleiben, als bei der Erstellung des endgültigen Zielcomputercodes.

Der erste Schritt eines Compilers besteht darin, die Quelle in eine Zwischenrepräsentation umzuwandeln, die häufig als Baum dargestellt wird. Traditionell enthält dieser Baum keine nicht-semantischen Informationen wie Kommentare, Leerzeichen usw. Sobald diese weggeworfen werden, können Sie die ursprüngliche Quelle von diesem Baum nicht mehr wiederherstellen.

Der nächste Schritt besteht darin, den Baum in eine Form von Zwischensprache zu rendern, die die Optimierung erleichtert. Hier gibt es eine ganze Reihe von Möglichkeiten, und jede Compiler-Infrastruktur verfügt über eine eigene. In der Regel gehen jedoch Informationen wie lokale Variablennamen und große Kontrollflussstrukturen (z. B. ob Sie eine for- oder while-Schleife verwendet haben) verloren. Typischerweise finden hier einige wichtige Optimierungen statt, z. B. konstante Ausbreitung, invariante Codebewegung, Inlining von Funktionen usw. Jede davon transformiert die Darstellung in eine Darstellung mit äquivalenter Funktionalität, die jedoch erheblich anders aussieht.

Ein Schritt danach besteht darin, die tatsächlichen Maschinenbefehle zu generieren, die eine sogenannte "Guckloch" -Optimierung beinhalten können, die eine optimierte Version der allgemeinen Befehlsmuster erzeugt.

Mit jedem Schritt verlieren Sie mehr und mehr Informationen, bis Sie am Ende so viel verlieren, dass es unmöglich wird, etwas wiederherzustellen, das dem ursprünglichen Code ähnelt.

Byte-Code hingegen speichert normalerweise die interessanten und transformativen Optimierungen bis zur JIT-Phase (dem Just-in-Time-Compiler), in der der Zielmaschinencode erstellt wird. Byte-Code enthält viele Metadaten wie lokale Variablentypen und Klassenstrukturen, damit derselbe Byte-Code zu mehreren Zielcomputern kompiliert werden kann. All diese Informationen sind in einem C ++ - Programm nicht erforderlich und werden beim Kompilieren verworfen.

Es gibt Dekompilierer für verschiedene Zielcomputercodes, die jedoch häufig keine nützlichen Ergebnisse liefern (etwas, das Sie ändern und anschließend neu kompilieren können), da zu viel der ursprünglichen Quelle verloren geht. Wenn Sie Debug-Informationen für die ausführbare Datei haben, können Sie einen noch besseren Job machen. Wenn Sie jedoch Debug-Informationen haben, haben Sie wahrscheinlich auch die ursprüngliche Quelle.


5
Die Tatsache, dass Informationen aufbewahrt werden, damit JIT besser funktionieren kann, ist der Schlüssel.
btilly

Sind C ++ - DLLs dann leicht dekompilierbar?
Panzercrisis

1
Nichts, was ich für nützlich halten würde.
Chuckj

1
Metadaten sollen nicht "das Kompilieren desselben Bytecodes zu mehreren Zielen ermöglichen", sondern dienen der Reflektion. Retargetable Intermediate Representation muss keine dieser Metadaten enthalten.
SK-logic

2
Das ist nicht wahr. Ein Großteil der Daten dient der Reflexion, aber Reflexion ist nicht die einzige Verwendung. Zum Beispiel werden die Schnittstellen- und Klassendefinitionen verwendet, um Feldversatz zu definieren, virtuelle Tabellen usw. auf dem Zielcomputer zu erstellen, damit diese auf die effizienteste Weise für den Zielcomputer erstellt werden können. Diese Tabellen werden vom Compiler und / oder Linker bei der Erstellung von nativem Code erstellt. Sobald dies erledigt ist, werden die zu ihrer Erstellung verwendeten Daten verworfen.
Chuckj

11

Der Informationsverlust, auf den in den anderen Antworten hingewiesen wird, ist ein Punkt, aber nicht der Dealbreaker. Schließlich erwarten Sie das ursprüngliche Programm nicht zurück, sondern möchten lediglich eine Darstellung in einer höheren Sprache. Wenn Code inline ist, können Sie ihn einfach zulassen oder gängige Berechnungen automatisch ausschließen. Sie können im Prinzip viele Optimierungen rückgängig machen. Es gibt jedoch einige Operationen, die im Prinzip irreversibel sind (zumindest ohne unendlich viel Rechenaufwand).

Beispielsweise können Zweige zu berechneten Sprüngen werden. Code wie folgt:

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

könnte kompiliert werden (sorry, dass dies kein echter Assembler ist):

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

Wenn Sie nun wissen, dass x 1 oder 2 sein kann, können Sie sich die Sprünge ansehen und diese leicht umkehren. Aber wie steht es mit der Adresse 0x1012? Solltest du auch einen case 3dafür erstellen ? Sie müssten im schlimmsten Fall das gesamte Programm verfolgen, um herauszufinden, welche Werte zulässig sind. Schlimmer noch, Sie müssen möglicherweise alle möglichen Benutzereingaben berücksichtigen! Der Kern des Problems besteht darin, dass Sie Daten und Anweisungen nicht auseinanderhalten können.

Davon abgesehen wäre ich nicht ganz pessimistisch. Wie Sie im obigen 'Assembler' vielleicht bemerkt haben, haben Sie, wenn x von außen kommt und nicht garantiert 1 oder 2 ist, im Wesentlichen einen schlechten Fehler, der es Ihnen ermöglicht, zu irgendwo hin zu springen. Aber wenn Ihr Programm frei von solchen Fehlern ist, ist es viel einfacher, darüber nachzudenken. (Es ist kein Zufall, dass "sichere" Zwischensprachen wie CLR IL oder Java-Bytecode viel einfacher zu dekompilieren sind, selbst wenn Metadaten beiseite gelegt werden.) In der Praxis sollte es also möglich sein, bestimmte, gut erzogene Sprachen zu dekompilierenProgramme. Ich denke an individuelle, funktionale Stilroutinen, die keine Nebenwirkungen haben und klar definierte Eingaben. Ich denke, es gibt ein paar Dekompilierer, die Pseudocode für einfache Funktionen liefern können, aber ich habe nicht viel Erfahrung mit solchen Werkzeugen.


9

Der Grund, warum der Maschinencode nicht einfach in den ursprünglichen Quellcode zurückkonvertiert werden kann, ist, dass beim Kompilieren viele Informationen verloren gehen. Methoden und nicht exportierte Klassen können eingebunden werden, lokale Variablennamen gehen verloren, Dateinamen und Strukturen gehen vollständig verloren, Compiler können nicht offensichtliche Optimierungen vornehmen. Ein weiterer Grund ist, dass mehrere unterschiedliche Quelldateien genau dieselbe Assembly erzeugen können.

Beispielsweise:

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

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

int main()
{
    return DoSomething();
}

Kann zusammengestellt werden zu:

main:
mov eax, 7;
ret;

Meine Assembly ist ziemlich rostig, aber wenn der Compiler überprüfen kann, ob eine Optimierung korrekt durchgeführt werden kann, wird dies auch der Fall sein. Dies liegt daran, dass die kompilierte Binärdatei die Namen nicht kennen muss DoSomethingund Adddass der AddCompiler neben der Tatsache, dass die Methode zwei benannte Parameter aufweist, auch weiß, dass die DoSomethingMethode im Wesentlichen eine Konstante zurückgibt und sowohl den Methodenaufruf als auch den inline-Aufruf ausführen kann Methode selbst.

Der Compiler hat den Zweck, eine Assembly zu erstellen, nicht die Möglichkeit, Quelldateien zu bündeln.


Überlegen Sie, ob Sie den letzten Befehl in "nur" ändern möchten retund sagen, dass Sie die C-Aufrufkonvention angenommen haben.
Chuckj

3

Die allgemeinen Prinzipien hier sind viele-zu-eins-Abbildungen und der Mangel an kanonischen Vertretern.

Als einfaches Beispiel für das Phänomen von vielen zu einem Zeitpunkt können Sie darüber nachdenken, was passiert, wenn Sie eine Funktion mit einigen lokalen Variablen übernehmen und zu Maschinencode kompilieren. Alle Informationen zu den Variablen gehen verloren, da sie nur zu Speicheradressen werden. Ähnliches gilt für Schleifen. Sie können eine foroder whileSchleife nehmen und wenn sie genau richtig aufgebaut sind, erhalten Sie möglicherweise identischen Maschinencode mit jumpAnweisungen.

Dies führt auch zu einem Mangel an kanonischen Vertretern des ursprünglichen Quellcodes für die Maschinencodeanweisungen. Wenn Sie versuchen, Schleifen zu dekompilieren, wie ordnen Sie die jumpAnweisungen Schleifen-Konstrukten zu? Machst du sie forSchleifen oder whileSchleifen.

Das Problem wird durch die Tatsache weiter verschärft, dass moderne Compiler verschiedene Formen des Falzens und Inlinens ausführen. Wenn Sie also zum Maschinencode gelangen, ist es so gut wie unmöglich zu sagen, aus welchen High-Level-Konstrukten der Low-Level-Maschinencode stammt.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.