Let's crack a binary!
Um zu zeigen wie einfach Programme geknackt werden können, wird im Folgenden ein konkretes Programm auf mehreren Wegen geknackt. Zum Ausprobieren laden Sie das Programm auf den eigenen Rechner herunter.
Untersuchung
Als ersten Schritt muss man bei unbekannten Programme herausfinden, in welchem Format diese geschrieben wurden. Dabei kann das Linux-Kommando „file” helfen. File liest die ersten Bytes von einer bestimmten Datei ein, und anhand dessen bestimmt er den Typ der Datei. Diese Bytes nennt man auch "Magic Number".
Auf dem vorherigen Bild sieht man die Ausgabe von „file”. Was kann man davon ablesen?
- „ELF 64-bit LSB pie executable” bedeutet, dass die Datei den Typ „Executable and Linking Format” hat. Dieser Typ ist das Standard Format für Linux-Programme und in diesem Fall handelt es sich um ein 64-bit Programm.
„PIE” steht für "Position independent executable" und bezeichnet Maschinencode, der ausgeführt werden kann, unabhängig davon, an welcher Adresse er sich im Hauptspeicher befindet. Dh. es werden keine absolute Adressen referenziert. - „dynamically linked”: Die verwendete Bibliotheken wurden nicht in dem Programm selbst eingepackt, sondern werden vom System – bevor das Programm ausgeführt wird – in den Arbeitsspeicher geladen.
- „interpreter /lib/ld-musl-x86_64.so.1” gibt an, welche Standard C-Bibliothek verwendet wird. In diesem Fall wird statt „GCC” – eine sehr verbreitete C-Bibliothek – „musl” benutzt
- „stripped”: alle Metadaten wurden entfernt.
Nach dieser ersten Analyse sollte man versuchen das Programm zu starten:
Das Programm wartet auf eine Texteingabe, die im Anschluss vom Programm geprüft wird. Wenn man zum Beispiel „test” eingibt, terminiert das Programm mit dem Statuscode „1”.
Es ist eine gängige Praxis, dass Programme ohne Fehler „0” als Statuscode zurückgegeben. Programme, die mit einem Fehler beenden eine positive Zahl.
Mit dem Linux-Kommando „echo $?” kann der Statuscode des gerade eben ausgeführten Kommandos ausgegeben werden..
Statische Analyse - disassembly
Um das Programm zu zerlegen, kann das Linux-Kommando „objdump” mit der Option „-d” verwendet werden. (Eine manuell annotierte Ausgabe von objdump ist auf Github zu finden.)
Als erste Aufgabe bei der statischen Analyse gilt es die Main-Methode zu finden. Diese Methode ist die erste Funktion, die aufgerufen wird.
Dadurch dass im Programm alle Metadaten entfernt wurden („stripped”), kann nicht einfach über Volltextsuche nach „main” gesucht werden. Wie findet man in so einem Fall diese wichtige Methode?
In den vorherigen Absatz wurde erwähnt, dass die Main-Methode als erstes nach dem Programmstart aufgerufen wird. Dies ist nur teilweise richtig. Die Main-Methode ist die erste Funktion, , die vom jeweiligen Entwickler kontrolliert wird. Aber davor werden noch einige andere Methoden vor der Main-Methode aufgerufen. Folgende Grafik zeigt die Methodennamen, die während der Laufzeit eines Programms aufgerufen werden.
In diesem Bild sieht man, dass die Main-Methode von der „__libc_start_main”-Methode aufgerufen wird. Die „__libc_start_main”-Methode ist so wichtig, dass deren Name nicht einmal in „stripped” Programme entfernt werden kann.
Wenn man in dem Dump nach „__libc_start_main” sucht, findet man drei Stellen, an denen dieser Text vorkommt. Die ersten beiden Stellen sind auf folgendem Screenshot sichtbar:
Dieser Eintrag befindet sich im Global Offset Table (GOT). Diese Tabelle wird verwendet um externe Methoden und deren Adresse zu managen. Die dritte Stelle ist wesentlich interessanter:
Auf der linken Seite sieht man die Adresse der Instruktionen in hexadezimalem Format. An der Adresse „805” wird die Instruktion „callq 760 <__libc_start_main@plt>” aufgerufen. Die Information in den spitzen Klammer wird von „objdump” eingefügt.
Die eigentliche Instruktion lautet „callq 760”. In Assembly bedeutet diese Instruktion, dass die Methode an der Adresse „760” aufgerufen werden soll – in dem Fall „__libc_start_main”.
Davor werden noch Daten in unterschiedliche Register („lea”-Instruktion) geladen bzw. verschoben („mov”-Instruktion). Laut den Calling Conventions für x64 werden die Parameter von Methoden in unterschiedliche Registern geschrieben. In unserem Fall ist die wichtigste Information, dass die Adresse der eigentlichen Main-Methode im RDI-Register abgelegt wird. An der Adresse „7fe” sieht man, dass etwas seltsames in den RDI-Register geladen wird.
Die Instruktion „lea” bedeutet „Load Effective Address”. In unserem Fall führt die CPU „RIP - 0x95” aus und schiebt das Ergebnis ins RDI-Register. RIP ist ein spezielles Register, in dem die Adresse der als nächstes auszuführenden Instruktion gespeichert wird.
Als die Instruktion an der Adresse „lea -0x95(%rip),%rdi” ausgeführt wird, wurde von der CPU das Register RIP schon auf die Adresse „0x805” erhöht. (An der Adresse „0x805” ist die nächste auszuführende Instruktion zu finden und damit „callq 760”.)
Das heisst: als „lea -0x95(%rip),%rdi” ausgeführt wird, steht im Register RIP „805”. Die Rechenoperation „0x805 - 0x95” ergibt „0x770” (hexademizale Schreibweise). Denselben Wert kann man in Zeile „0x7fe” hinter „#” finden, da „objdump” dieselbe Berechnung durchgeführt hat.
Zusammengefasst ist nun klar, dass die Adresse der Main-Methode im RDI Register gespeichert wurde. Wir haben ausgerechnet, dass im RDI Register „0x770” steht, bevor „__libc_start_main” gerufen wird. Damit können wir die Main-Methode an Adresse „0x770” in Assembly finden:
Als erstes betrachten wir nun die „callq”-Instruktionen in der Main-Methode. Diese Instruktionen rufen andere Funktionen auf.
Die erste „callq”-Instruktion ruft die calloc-Methode auf (Adresse „0x77b”), eine Funktion der C Standardbibliothek. Diese Methode reserviert einen bestimmten Bereich im Hauptspeicher.
Dann ruft „callq” eine Funktion namens „puts” auf (Adresse „0x78a”). „Puts” ist ebenfalls eine Funktion der C Standardbibliothek und schreibt eine Zeichenkette auf die Konsole.
Wenn man die Adresse „0xec9” ausgeben würde, würde man "enter code:" finden können. Die Adresse „0xec9” wird nämlich „puts” als Parameter übergeben (siehe Instruktion „0x780”).
Als nächstes wird fflush aufgerufen (Adresse „0x796”). Diese Methode leert in diesem Fall den Eingabestream (stdin bzw. StandardIn).
Kurz danach wird fgets aufgerufen (Adresse „0x7aa”). „Fgets” liest Tastatureingaben ein und ist wie alle bisher besprochenen Funktionen eine Funktion der C Standardbibliothek. Damit wird an Adresse „0x7aa” das Passwort eingelesen.
Im nächsten „callq” Aufruf (Instruktion „0x7b2”) wird eine unbenannte Methode an der Adresse „0xc6c” aufgerufen. Diese Methode ist die erste Funktion, die keinen Namen hat. Damit stammt diese Methode nicht aus einer Bibliothek weswegen deren Namen entfernt wurde („stripped”).
Zum Schluss wird printf aufgerufen (Adresse „0x7c3”) und erneut etwas auf die Konsole geschrieben.
Als nächsten Schritt analysieren wir den Anfang der Methode an der Adresse „0xc6c” – die unbenannte Methode.
Die ersten zwei Zeilen initialisieren den Stack.
Der Stack ist ein Bereich im Arbeitsspeicher in dem eine Funktion temporär Daten speichert. Mit einer „push”-Instruktion werden Daten abgelegt. Mit einer „pop”-Instruktion werden Daten wieder geholt. Wenn „pop” aufgerufen wird, werden die Daten in ein Register der CPU geschrieben und die geholten Daten werden aus dem Stack entfernt.
Die „mov”-Instruktion (Adresse „0xc6e”) schreibt den ersten Parameter der aufgerufenen Methode auf Stack. (Genauer: es wird ein Zeiger zu der Adresse auf den Stack geschrieben, an der der tatsächliche Wert des Parameters zu finden ist.)
In der bereits analysierten Main-Methode war der erste Parameter für die unbenanntete Methode (Adresse „0x7b2”) ein Zeiger zur Zeichenkette, die mit „fgets” eingelesen wurde.
Die Instruktion „movsbq (%rdi),%rdi” (Adresse „0xc75”) schreibt den Wert, der hinter dem Zeiger im RDI Register zu finden ist, direkt ins RDI.
Angenommen es wurde mit „fgets” die Zeichenkette „test” gelesen. Diese Zeichenkette wird von fgets an der fiktive Adresse „0xFFFF0” gespeichert. „Fgets” gibt den Wert „0xFFFF0” zurück. Beim Wert „0xFFFF0” handelt es sich jedoch nicht um den vom Benutzer eingegebene Zeichenkette. Es handelt sich lediglich um die Adresse an dem diese Zeichenkette zu finden ist, die der Benutzer tatsächlich eingegeben hat.
Allerdings wird an dieser Adresse nicht die ganze Zeichenkette gespeichert – sondern nur das erste Zeichen „t”. Die restlichen Zeichen werden in direkt darauffolgenden Adressen gespeichert: „e” ist an der Adresse „0xFFFF1”, „s” an „0xFFFF2”, usw.
Der Zeiger „0xFFFF0” wird nun an die Methode übergeben und ins Register RDI geschrieben (Instruktion „0xc6e”). Sobald die Instruktion „movsbq (%rdi), %rdi” ausgeführt wird, wird der eigentliche Wert der an „0xFFFF0” zu finden ist – also „t” – ins Register RDI kopiert.
Innerhalb der unbenannten Methode wird eine weitere unbekannte Methode mit „t” als Parameter aufgerufen (Instruktion „0xc79”):
In der ersten Zeile wird der Inhalt vom Register RDI mit einem fixen Wert „0x79” verglichen.
„0x79” entspricht das ASCII-Zeichen „y”.
Die nächste Instruktion ist ein „je 94f”. „je” steht für „jump if equal”.
Steht im Register RDI das Zeichen „y”, so springt die CPU zu Adresse „0x94f” und die Ausführung wird dort fortgesetzt. Die CPU springt in die vorherige Methode zurück.
Sollte in RDI kein „y” stehen, wird die Funktion „exit” aus der C-Standardbibliothek aufgerufen (Adresse „0x94a”: „callq 758”). Als Parameter wird „exit” der „1” übergeben (Adresse „0x945”: „mox $0x1,%edi”). Nachdem „exit” aufgerufen wurde, wird die Programmausführung abgebrochen und der Status des Programms auf den Parameter von „exit” gesetzt.
An der Adresse „0xc7e” wird das zweite Zeichen der Benutzereingabe ins RDI kopiert. Es wird eine weitere Methode aufgerufen (Adresse „0xc8a”: „callq 955”). Die aufgerufene Methode befindet sich damit an Adresse „0x955”.
In der Methode (Adresse „0x955”) wird das Register RDI diesmal mit dem Wert „0x65” verglichen: Der Wert „0x65” entspricht in der ASCII Kodierung dem Kleinbuchstaben „e”. Falls etwas anderes als „e” im RDI steht, wird „exit” mit „2” als Parameter aufgerufen.
Wenn man die weiteren Methoden, die in der Funktion an Adresse „0xc6c” aufgerufen werden, anschauen würde, würde man weitere Aufrufe finden, die das RDI Register mit unterschiedlichen Werten vergleichen.
Somit kann man vermuten, dass die Benutzereingabe Zeichen für Zeichen mit fixen Werten verglichen wird. Mit einem Bash Befehl kann man diese fixe Werte aus dem Assembly-Code auslesen und ausgeben:
objdump 4a2181aaf70b04ec984c233fbe50a1fe600f90062a58d6b69ea15b85531b9652 -d -M intel| grep 'cmp *rdi' | python3 -c 'while 1: print(chr(int(input()[-2:],16)),end="")' 2>/dev/null
Die Ausgabe des Bash Befehls lautet „yes and his hands shook with ex”.
Wenn man das Programm erneut startet und statt „test” die Zeichenfolge „yes and his hands shook with ex” eingibt, wird das Programm mit dem Status „0” beendet. Zusätzlich wird noch „sum is 19” auf die Konsole.
Statische Analyse - decompilation
Um das Beispielprogramm zu dekompilieren wird „Ghidra” eingesetzt. Ghidra ist ein „Binary Analysis Toolkit” – entwickelt von der NSA (dem US-amerikanischen Auslandsgeheimdienst) und vor einigen Jahren auf Github veröffentlicht.
Nachdem das Programm von Ghidra geladen wurde, werden automatische Analyseschritte durchgeführt:
- Funktionen identifizieren
- Variablentypen identifizieren (in Assembly werden Variablentypen nicht unterschieden – alles wird als Zahl behandelt)
- Datentypen identifizieren
- Kontrollflussgraph erstellen
- und vieles mehr
Sobald diese Schritte abgeschlossen sind, wirde eine Benutzeroberfläche, die einer Entwicklungsumgebung ähnelt:
In der Mitte sieht man ein Disassembly-Fenster.
Auf der rechten Seite die aktuell ausgewählte Funktion in dekompiliertem Format. (Wenn die Metadaten aus dem Programm entfernt wurden, werden die Funktionen von Ghidra nach ihren Adressen benannt. Allerdings kann man sie manuell umbenennen. Wird eine Methode vom Cracker identifiziert und umbenannt, wird an allen Stellen, wo diese Methode aufgerufen wird, dieser neue Name angezeigt.)
Auf dem folgenden Bild sieht man die Funktion der Adresse „0xc6c” in dekompilierter Form:
Am Bild ist der Funktionsaufruf mit den Werten aus der Benutzereingabe zu finden: „*param_1” entspricht dem ersten Zeichen, „param_1[1]” dem zweiten Buchstaben, usw.
Weiter unten werden die Rückgabewerte von den einzelnen Funktionen erst mit 3 nach rechts verschoben und die Summe mit einem fixen Wert verglichen.
Bei der vorherigen Analyse wurde dieser Vergleich nicht beachtet. Es wird damit sichergestellt, dass tatsächlich alle Funktionen besucht wurden.
Im dekompilierten Code der ersten aufgerufenen Funktion lässt sich der Vergleich zwischen eingehendem Parameter („param_1”) und dem statischen Wert „y” sehen. Dies entspricht der Hypothese der vorherigen Analyse mit Disassembly:
Somit wurde das Programm mit zwei unterschiedlichen Werkzeuge – und ohne dynamische Analyse – geknackt.
To be continued...
Im nächsten Teil der Serie wird das Programm dynamisch analysiert und mithilfe von zwei „side-channel”-Angriffen geknackt.