Ich glaube nicht, dass es dafür eine zuverlässige Methode gibt. Maschinencodeformate sind sehr kompliziert, komplizierter als Assemblerdateien. Es ist nicht wirklich möglich, eine kompilierte Binärdatei (z. B. im ELF-Format) zu nehmen und ein Quell-Assembler-Programm zu erstellen, das zu derselben (oder einer ausreichend ähnlichen) Binärdatei kompiliert wird. Um die Unterschiede besser zu verstehen, vergleichen Sie die Ausgabe von GCC beim direkten Kompilieren mit Assembler (gcc -S
) gegenüber der Ausgabe von objdump auf der ausführbaren Datei (objdump -D
).
Es gibt zwei Hauptkomplikationen, die ich mir vorstellen kann. Erstens ist der Maschinencode selbst keine 1-zu-1-Entsprechung zum Assemblercode, wegen Dingen wie Zeiger-Offsets.
Betrachten Sie beispielsweise den C-Code für Hello world:
int main()
{
printf("Hello, world!\n");
return 0;
}
Dies wird zum x86-Assemblercode kompiliert:
.LC0:
.string "hello"
.text
<snip>
movl $.LC0, %eax
movl %eax, (%esp)
call printf
Dabei ist .LCO eine benannte Konstante und printf ein Symbol in einer gemeinsam genutzten Bibliothekssymboltabelle. Vergleichen Sie mit der Ausgabe von objdump:
80483cd: b8 b0 84 04 08 mov $0x80484b0,%eax
80483d2: 89 04 24 mov %eax,(%esp)
80483d5: e8 1a ff ff ff call 80482f4 <[email protected]>
Erstens ist die Konstante .LC0 jetzt nur ein zufälliger Offset irgendwo im Speicher - es wäre schwierig, eine Assembler-Quelldatei zu erstellen, die diese Konstante an der richtigen Stelle enthält, da der Assembler und der Linker die Speicherorte für diese Konstanten frei wählen können.
Zweitens bin ich mir darüber nicht ganz sicher (und es hängt von Dingen wie positionsunabhängigem Code ab), aber ich glaube, dass der Verweis auf printf dort überhaupt nicht an der Zeigeradresse in diesem Code codiert ist, aber die ELF-Header enthalten a Nachschlagetabelle, die ihre Adresse zur Laufzeit dynamisch ersetzt. Daher entspricht der disassemblierte Code nicht ganz dem Assembler-Quellcode.
Zusammenfassend hat die Quellassembly Symbole während kompilierter Maschinencode Adressen hat die schwer rückgängig zu machen sind.
Die zweite große Komplikation besteht darin, dass eine Assembly-Quelldatei nicht alle Informationen enthalten kann, die in den ursprünglichen ELF-Dateiheadern vorhanden waren, z. B. welche Bibliotheken dynamisch verknüpft werden sollen, und andere Metadaten, die vom ursprünglichen Compiler dort platziert wurden. Es wäre schwierig, dies zu rekonstruieren.
Wie gesagt, es ist möglich, dass ein spezielles Tool all diese Informationen manipulieren kann, aber es ist unwahrscheinlich, dass man einfach Assemblercode erzeugen kann, der wieder in die ausführbare Datei zurückgebaut werden kann.
Wenn Sie daran interessiert sind, nur einen kleinen Teil der ausführbaren Datei zu ändern, empfehle ich einen viel subtileren Ansatz als die Neukompilierung der gesamten Anwendung. Verwenden Sie objdump, um den Assemblercode für die Funktion(en) abzurufen, an der/denen Sie interessiert sind. Konvertieren Sie ihn von Hand in „Quell-Assembly-Syntax“ (und hier wünschte ich, es gäbe ein Tool, das tatsächlich eine Disassemblierung in derselben Syntax wie die Eingabe erzeugt). , und ändern Sie es nach Ihren Wünschen. Wenn Sie fertig sind, kompilieren Sie nur diese Funktion(en) neu und verwenden Sie objdump, um den Maschinencode für Ihr modifiziertes Programm herauszufinden. Verwenden Sie dann einen Hex-Editor, um den neuen Maschinencode manuell über den entsprechenden Teil des ursprünglichen Programms einzufügen, und achten Sie darauf, dass Ihr neuer Code genau die gleiche Anzahl von Bytes hat wie der alte Code (sonst wären alle Offsets falsch). ). Wenn der neue Code kürzer ist, können Sie ihn mit NOP-Anweisungen auffüllen. Wenn es länger dauert, könnten Sie in Schwierigkeiten geraten und müssen möglicherweise neue Funktionen erstellen und sie stattdessen aufrufen.
Ich mache das mit hexdump
und einen Texteditor. Du musst wirklich sein vertraut mit dem Maschinencode und dem Dateiformat, in dem er gespeichert ist, und flexibel mit dem, was als „zerlegen, modifizieren und dann wieder zusammensetzen“ zählt.
Wenn Sie nur "punktuelle Änderungen" vornehmen können (Bytes umschreiben, aber keine Bytes hinzufügen oder entfernen), wird es (relativ gesehen) einfach sein.
Sie wirklich Ich möchte keine vorhandenen Anweisungen verdrängen, da Sie dann jeden bewirkten relativen Offset innerhalb des Maschinencodes manuell anpassen müssten, für Sprünge/Verzweigungen/Laden/Speichern relativ zum Programmzähler, beides in fest codiertem immediate Werte und diejenigen, die durch Register berechnet werden .
Sie sollten immer in der Lage sein, Bytes nicht zu entfernen. Das Hinzufügen von Bytes kann für komplexere Änderungen erforderlich sein und wird viel schwieriger.
Schritt 0 (Vorbereitung)
Nachdem Sie eigentlich die Datei mit objdump -D
ordnungsgemäß disassembliert oder was auch immer Sie normalerweise zuerst verwenden, um es tatsächlich zu verstehen und die Stellen zu finden, die Sie ändern müssen, müssen Sie die folgenden Dinge beachten, um Ihnen zu helfen, die richtigen zu ändernden Bytes zu finden:
- Die "Adresse" (Offset vom Anfang der Datei) der Bytes, die Sie ändern müssen.
- Der Rohwert dieser Bytes, wie sie derzeit sind (die
--show-raw-insn
Option zuobjdump
ist hier wirklich hilfreich).
Sie müssen auch prüfen, ob hexdump -R
funktioniert auf Ihrem System. Wenn nicht, verwenden Sie für den Rest dieser Schritte den xxd
Befehl oder ähnliches anstelle von hexdump
in allen folgenden Schritten (lesen Sie die Dokumentation für das Tool, das Sie verwenden, ich erkläre nur hexdump
vorerst in dieser Antwort, denn das ist die, mit der ich vertraut bin).
Schritt 1
Sichern Sie die rohe hexadezimale Darstellung der Binärdatei mit hexdump -Cv
.
Schritt 2
Öffnen Sie die hexdump
ed-Datei und suchen Sie die Bytes an der Adresse, die Sie ändern möchten.
Schneller Crashkurs in hexdump -Cv
Ausgabe:
- Die Spalte ganz links sind die Adressen der Bytes (relativ zum Beginn der Binärdatei selbst, genau wie
objdump
bietet). - Die Spalte ganz rechts (umgeben von
|
Zeichen) ist nur eine "vom Menschen lesbare" Darstellung der Bytes - das ASCII-Zeichen, das zu jedem Byte passt, wird dort mit einem.
geschrieben stellvertretend für alle Bytes, die keinem druckbaren ASCII-Zeichen entsprechen. - Das Wichtige liegt dazwischen - jedes Byte als zwei durch Leerzeichen getrennte Hexadezimalziffern, 16 Bytes pro Zeile.
Achtung:Im Gegensatz zu objdump -D
, das Ihnen die Adresse jeder Anweisung gibt und das rohe Hexadezimalformat der Anweisung anzeigt, basierend darauf, wie sie als codiert dokumentiert ist, hexdump -Cv
gibt jedes Byte genau in der Reihenfolge aus, in der es in der Datei erscheint. Dies kann zunächst auf Maschinen etwas verwirrend sein, auf denen die Befehlsbytes aufgrund von Endianness-Unterschieden in entgegengesetzter Reihenfolge sind, was auch verwirrend sein kann, wenn Sie ein bestimmtes Byte als eine bestimmte Adresse erwarten.
Schritt 3
Ändern Sie die Bytes, die geändert werden müssen - Sie müssen offensichtlich die rohe Maschinenbefehlscodierung (nicht die Assembler-Mnemonik) herausfinden und die richtigen Bytes manuell einschreiben.
Hinweis:Du nicht müssen die menschenlesbare Darstellung in der Spalte ganz rechts ändern. hexdump
wird es ignorieren, wenn Sie es "undumpen".
Schritt 4
Entpacken Sie die modifizierte Hexdump-Datei mit hexdump -R
.
Schritt 5 (Sicherheitsprüfung)
objdump
Ihr neu unhexdump
ed-Datei und vergewissern Sie sich, dass die von Ihnen geänderte Disassemblierung korrekt aussieht. diff
es gegen den objdump
des Originals.
Im Ernst, überspringen Sie diesen Schritt nicht. Ich mache meistens einen Fehler, wenn ich den Maschinencode manuell bearbeite, und so fange ich die meisten davon ab.
Beispiel
Hier ist ein Beispiel aus der Praxis, als ich kürzlich eine ARMv8-Binärdatei (Little Endian) modifiziert habe. (Ich weiß, die Frage ist x86
getaggt , aber ich habe kein x86-Beispiel zur Hand, und die Grundprinzipien sind dieselben, nur die Anweisungen sind unterschiedlich.)
In meiner Situation musste ich eine bestimmte Händchenhalten-Prüfung „Sie sollten das nicht tun“ deaktivieren:in meiner Beispielbinärdatei in objdump --show-raw-insn -d
Ausgabe der Zeile, die mir wichtig war, sah so aus (eine Anweisung davor und danach für den Kontext):
f40: aa1503e3 mov x3, x21
f44: 97fffeeb bl af0 <[email protected]>
f48: f94013f7 ldr x23, [sp, #32]
Wie Sie sehen können, wird unser Programm "hilfreich" beendet, indem es in eine error
springt Funktion (die das Programm beendet). Inakzeptabel. Also werden wir diese Anweisung in eine No-Op umwandeln. Wir suchen also nach den Bytes 0x97fffeeb
an der Adresse/Datei-Offset 0xf44
.
Hier ist der hexdump -Cv
Zeile, die diesen Offset enthält.
00000f40 e3 03 15 aa eb fe ff 97 f7 13 40 f9 e8 02 40 39 |[email protected]@9|
Beachten Sie, wie die relevanten Bytes tatsächlich umgedreht werden (Little-Endian-Codierung in der Architektur gilt für Maschinenbefehle wie für alles andere) und wie sich dies etwas unintuitiv darauf bezieht, welches Byte an welchem Byte-Offset ist:
00000f40 -- -- -- -- eb fe ff 97 -- -- -- -- -- -- -- -- |[email protected]@9|
^
This is offset f44, holding the least significant byte
So the *instruction as a whole* is at the expected offset,
just the bytes are flipped around. Of course, whether the
order matches or not will vary with the architecture.
Wie auch immer, ich weiß aus anderen Disassemblierungen, dass 0xd503201f
zerlegt zu nop
Das scheint also ein guter Kandidat für meine No-Op-Anweisung zu sein. Ich habe die Zeile im hexdump
geändert ed-Datei entsprechend:
00000f40 e3 03 15 aa 1f 20 03 d5 f7 13 40 f9 e8 02 40 39 |[email protected]@9|
Mit hexdump -R
zurück ins Binärformat konvertiert , die neue Binärdatei mit objdump --show-raw-insn -d
disassembliert und überprüft, ob die Änderung korrekt war:
f40: aa1503e3 mov x3, x21
f44: d503201f nop
f48: f94013f7 ldr x23, [sp, #32]
Dann habe ich die Binärdatei ausgeführt und das gewünschte Verhalten erhalten - die relevante Überprüfung führte nicht mehr zum Abbruch des Programms.
Maschinencode-Änderung erfolgreich.
!!! Achtung !!!
Oder war ich erfolgreich? Haben Sie bemerkt, was ich in diesem Beispiel übersehen habe?
Ich bin mir sicher, dass Sie das getan haben - da Sie fragen, wie Sie den Maschinencode eines Programms manuell ändern können, wissen Sie vermutlich, was Sie tun. Aber zum Nutzen aller Leser, die vielleicht lesen, um zu lernen, werde ich näher darauf eingehen:
Ich habe nur das letzte geändert Anweisung im Fehlerfall-Zweig! Der Sprung in die Funktion, die das Programm beendet. Aber wie Sie sehen können, registrieren Sie x3
wurde von mov
modifiziert gleich darüber! Tatsächlich sind es insgesamt vier (4) Register wurden als Teil der Präambel geändert, um error
aufzurufen , und ein Register war. Hier ist der vollständige Maschinencode für diesen Zweig, beginnend mit dem bedingten Sprung über if
Block und endet, wohin der Sprung geht, wenn die Bedingung if
wird nicht vergeben:
f2c: 350000e8 cbnz w8, f48
f30: b0000002 adrp x2, 1000
f34: 91128442 add x2, x2, #0x4a1
f38: 320003e0 orr w0, wzr, #0x1
f3c: 2a1f03e1 mov w1, wzr
f40: aa1503e3 mov x3, x21
f44: 97fffeeb bl af0 <[email protected]>
f48: f94013f7 ldr x23, [sp, #32]
Der gesamte Code nach der Verzweigung wurde vom Compiler unter der Annahme generiert, dass der Programmzustand wie vor dem bedingten Sprung war ! Aber indem Sie einfach den letzten Sprung zum error
machen Funktionscode ein No-Op, ich habe einen Codepfad erstellt, wo wir diesen Code mit inkonsistentem/falschem Programmzustand erreichen !
In meinem Fall schien es tatsächlich keine Probleme bereiten. Also hatte ich Glück. Sehr glücklich:erst nachdem ich meine modifizierte Binärdatei (die übrigens eine sicherheitskritische Binärdatei war) bereits ausgeführt hatte :es hatte die Fähigkeit zu setuid
, setgid
, und ändern Sie den SELinux-Kontext !) Ist mir aufgefallen, dass ich vergessen habe, die Codepfade zu verfolgen, ob diese Registeränderungen die späteren Codepfade beeinflusst haben!
Das hätte katastrophal sein können - jedes dieser Register könnte in späterem Code mit der Annahme verwendet worden sein, dass es einen vorherigen Wert enthielt, der jetzt überschrieben wurde! Und ich bin die Art von Person, die die Leute für sorgfältiges Nachdenken über Code und als Pedanten und Verfechter dafür kennen, dass sie sich der Computersicherheit immer bewusst sind.
Was wäre, wenn ich eine Funktion aufrufe, bei der die Argumente aus den Registern auf den Stapel übertragen werden (wie es beispielsweise bei x86 sehr häufig vorkommt)? Was wäre, wenn es tatsächlich mehrere bedingte Anweisungen im Befehlssatz gab, die dem bedingten Sprung vorausgingen (wie es beispielsweise bei älteren ARM-Versionen üblich ist)? Ich wäre in einem noch rücksichtsloseren inkonsistenten Zustand gewesen, nachdem ich diese scheinbar einfachste Änderung vorgenommen hätte!
Das ist also meine warnende Erinnerung: Manuelles Herumtüfteln mit Binärdateien ist buchstäblich das Abstreifen alles Sicherheit zwischen Ihnen und dem, was die Maschine und das Betriebssystem zulassen. Buchstäblich alle die Fortschritte, die wir in unseren Tools gemacht haben, um Fehler in unseren Programmen automatisch zu erkennen, weg .
Wie können wir das also besser beheben? Lesen Sie weiter.
Code entfernen
effektiv /logisch mehr als eine Anweisung "entfernen", können Sie die erste Anweisung, die Sie "löschen" möchten, durch einen unbedingten Sprung zur ersten Anweisung am Ende der "gelöschten" Anweisungen ersetzen. Für diese ARMv8-Binärdatei sah das so aus:
f2c: 14000007 b f48
f30: b0000002 adrp x2, 1000
f34: 91128442 add x2, x2, #0x4a1
f38: 320003e0 orr w0, wzr, #0x1
f3c: 2a1f03e1 mov w1, wzr
f40: aa1503e3 mov x3, x21
f44: 97fffeeb bl af0 <[email protected]>
f48: f94013f7 ldr x23, [sp, #32]
Grundsätzlich "töten" Sie den Code (verwandeln Sie ihn in "toten Code"). Nebenbemerkung:Sie können etwas Ähnliches mit in die Binärdatei eingebetteten Literalzeichenfolgen tun:Solange Sie sie durch eine kleinere Zeichenfolge ersetzen möchten, können Sie fast immer damit davonkommen, die Zeichenfolge zu überschreiben (einschließlich des abschließenden Nullbytes, wenn es sich um ein "C- string") und bei Bedarf die fest codierte Größe der Zeichenfolge im Maschinencode, der sie verwendet, überschreiben.
Sie können auch alle unerwünschten Anweisungen durch No-Ops ersetzen. Mit anderen Worten, wir können den unerwünschten Code in einen sogenannten „No-Op-Schlitten“ verwandeln:
f2c: d503201f nop
f30: d503201f nop
f34: d503201f nop
f38: d503201f nop
f3c: d503201f nop
f40: d503201f nop
f44: d503201f nop
f48: f94013f7 ldr x23, [sp, #32]
Ich würde erwarten, dass das nur CPU-Zyklen verschwendet, wenn man sie überspringt, aber es ist einfacher und damit sicherer vor Fehlern , da Sie nicht manuell herausfinden müssen, wie die Sprunganweisung zu codieren ist, einschließlich des darin zu verwendenden Offsets/der darin zu verwendenden Adresse - Sie müssen nicht so viel nachdenken für einen No-Op-Schlitten.
Um es klar zu sagen, Fehler sind einfach:Ich habe zwei (2) vermasselt Zeiten, wenn diese unbedingte Verzweigungsanweisung manuell codiert wird. Und es ist nicht immer unsere Schuld:Das erste Mal war, weil die Dokumentation, die ich hatte, veraltet/falsch war und besagte, dass ein Bit in der Codierung ignoriert wurde, obwohl dies eigentlich nicht der Fall war, also habe ich es bei meinem ersten Versuch auf Null gesetzt. P>
Code hinzufügen
Sie könnten Verwenden Sie theoretisch diese Technik zum Hinzufügen Maschinenanweisungen auch, aber es ist komplexer, und ich musste es nie tun, also habe ich zu diesem Zeitpunkt kein funktionierendes Beispiel.
Aus Sicht des Maschinencodes ist es ziemlich einfach:Wählen Sie eine Anweisung an der Stelle aus, an der Sie Code hinzufügen möchten, und konvertieren Sie sie in eine Sprunganweisung zu dem neuen Code, den Sie hinzufügen müssen (vergessen Sie nicht, die Anweisung(en) so hinzuzufügen durch den neuen Code ersetzt werden, es sei denn, Sie haben dies für Ihre hinzugefügte Logik nicht benötigt, und um zu der Anweisung zurückzukehren, zu der Sie am Ende der Hinzufügung zurückkehren möchten). Im Grunde "spleißen" Sie den neuen Code ein.
Aber Sie müssen einen Ort finden, an dem Sie diesen neuen Code tatsächlich einfügen können, und das ist der schwierige Teil.
Wenn Sie wirklich sind Glücklicherweise können Sie den neuen Maschinencode einfach am Ende der Datei anhängen, und es wird "einfach funktionieren":Der neue Code wird zusammen mit dem Rest in die gleichen erwarteten Maschinenanweisungen geladen, in Ihren Adressraum, der fällt in eine Speicherseite, die ordnungsgemäß als ausführbar gekennzeichnet ist.
Meiner Erfahrung nach hexdump -R
ignoriert nicht nur die Spalte ganz rechts, sondern auch die Spalte ganz links - Sie könnten also buchstäblich Nulladressen für alle manuell hinzugefügten Zeilen eingeben, und es würde funktionieren.
Wenn Sie weniger Glück haben, müssen Sie nach dem Hinzufügen des Codes tatsächlich einige Header-Werte in derselben Datei anpassen:Wenn der Loader für Ihr Betriebssystem erwartet, dass die Binärdatei Metadaten enthält, die die Größe des ausführbaren Abschnitts beschreiben (aus historischen Gründen). oft als "Text"-Abschnitt bezeichnet), müssen Sie diesen finden und anpassen. Früher waren Binärdateien nur roher Maschinencode - heutzutage ist der Maschinencode in eine Reihe von Metadaten verpackt (zum Beispiel ELF unter Linux und einige andere).
Wenn Sie immer noch ein wenig Glück haben, haben Sie vielleicht eine "tote" Stelle in der Datei, die als Teil der Binärdatei richtig geladen wird, mit den gleichen relativen Offsets wie der Rest des Codes, der sich bereits in der Datei befindet (und das Totpunkt kann in Ihren Code passen und ist richtig ausgerichtet, wenn Ihre CPU eine Wortausrichtung für CPU-Anweisungen erfordert). Dann können Sie es überschreiben.
Wenn Sie wirklich Pech haben, können Sie nicht einfach Code anhängen und es gibt keinen Totraum, den Sie mit Ihrem Maschinencode füllen können. An diesem Punkt müssen Sie im Grunde genommen mit dem ausführbaren Format vertraut sein und hoffen, dass Sie innerhalb dieser Einschränkungen etwas herausfinden können, das menschlich machbar ist, um es innerhalb einer angemessenen Zeit und mit einer angemessenen Chance, es nicht zu vermasseln, manuell durchzuziehen .
@mgiuca hat diese Antwort aus technischer Sicht richtig angesprochen. Tatsächlich ist das Disassemblieren eines ausführbaren Programms in eine einfach zu rekompilierende Assemblerquelle keine leichte Aufgabe.
Um der Diskussion etwas hinzuzufügen, gibt es ein paar Techniken/Werkzeuge, die interessant zu erforschen sein könnten, obwohl sie technisch komplex sind.
- Statische/dynamische Instrumentierung . Diese Technik umfasst das Analysieren des ausführbaren Formats, das Einfügen/Löschen/Ersetzen spezifischer Assembleranweisungen für einen bestimmten Zweck, das Korrigieren aller Verweise auf Variablen/Funktionen in der ausführbaren Datei und das Ausgeben einer neuen modifizierten ausführbaren Datei. Einige mir bekannte Tools sind:PIN, Hijacker, PEBIL, DynamoRIO. Beachten Sie, dass die Konfiguration solcher Tools für einen anderen Zweck als den, für den sie entwickelt wurden, schwierig sein kann und ein Verständnis sowohl der ausführbaren Formate als auch der Befehlssätze erfordert.
- Vollständige ausführbare Dekompilierung . Diese Technik versucht, eine vollständige Assemblyquelle aus einer ausführbaren Datei zu rekonstruieren. Vielleicht möchten Sie einen Blick auf den Online-Disassembler werfen, der versucht, die Arbeit zu erledigen. Sie verlieren sowieso Informationen über verschiedene Quellmodule und eventuell Funktionen/Variablennamen.
- Retargetable Dekompilierung . Diese Technik versucht, mehr Informationen aus der ausführbaren Datei zu extrahieren, indem sie sich Compiler-Fingerabdrücke ansieht (d. h. Codemuster, die von bekannten Compilern generiert werden) und andere deterministische Dinge. Das Hauptziel besteht darin, Quellcode auf höherer Ebene, wie C-Quellcode, aus einer ausführbaren Datei zu rekonstruieren. Dies ist manchmal in der Lage, Informationen über Funktions-/Variablennamen wiederzugewinnen. Beachten Sie, dass Quellen mit
-g
kompiliert werden bietet oft bessere Ergebnisse. Vielleicht möchten Sie den Retargetable Decompiler ausprobieren.
Das meiste davon stammt aus den Forschungsbereichen Schwachstellenanalyse und Ausführungsanalyse. Es handelt sich um komplexe Techniken, und oft können die Tools nicht sofort verwendet werden. Dennoch bieten sie eine unschätzbare Hilfe beim Versuch, Software zurückzuentwickeln.