28.07.2021

Reverse Engineering - Teil 2 - Assembler

Reverse Engineering - Teil 2 - Assembler Techblog

Assemblersprachen sind die erste Abstraktionsebene vom reinen Maschinencode. In diesem Artikel schauen wir uns an wie ein Codebeispiel in x86-Assembler übersetzt wird. Viele der Grundlagen, die wir hier kennenlernen, gelten so oder so ähnlich aber auch für andere Assemblersprachen, wie z.B. ARM-Assembler. Diese Kenntnisse helfen beim tieferen Verständnis des Reverse Engineerings und der späteren Artikel, in denen wir uns Reverse-Engineering-Tools im praktischen Einsatz ansehen. Der nächste Artikel kann aber auch ohne die hier vermittelten Kenntnisse verstanden werden.

Bevor wir mit dem Beispiel starten, benötigen wir noch wenige theoretische Grundlagen.

Opcodes und Mnemonics

Wie bereits in Teil 1 beschrieben, muss Programmcode in Maschinencode – also Einsen und Nullen - übersetzt werden, damit ein Prozessor diesen verarbeiten kann. Der Prozessor ist also so konzipiert, dass bei einer bestimmten Abfolge von Einsen und Nullen eine bestimmte Aktion ausgeführt wird. Ein Opcode (kurz für Operation Code) ist eine solche Folge und wird meist in hexadezimaler Form dargestellt.

Mnemonics (englisch für Gedächtnisstütze) wiederum sind textuelle Kürzel für Opcodes und damit leichter für Menschen zu verstehen. „Mov“ um Daten zu „bewegen“ (englisch: move) lässt sich leichter merken als „b0“, zumal es mehrere Opcodes geben kann, die zu einem Mnemonic passen.

Register

Register sind Speicherbereiche, auf die der Prozessor sehr schnell zugreifen kann und die bei der Ausführung von Programmen genutzt werden, um Daten zwischenzuspeichern.

In unserem 32-Bit-x86-Beispiel werden uns die folgenden Register begegnen:

  • EAX, EBX, ECX, EDX: Register für verschiedene Zwecke. Meist innerhalb von Funktionen genutzt, um Werte und Adressen zwischenzuspeichern
  • EBP, ESP: Pointer für den Stack
  • EIP: Enthält die Adresse der nächsten auszuführenden Instruktion (Instruction Pointer)

Stack

Der Stack (dt. Stapel) ist eine zusammenhängende Speicherregion in einem Prozess. In diesem Bereich werden temporäre Daten wie lokale Variablen und Funktionsparameter gespeichert.

Für jede Funktion, die aufgerufen wird, wird der Stack in kleinere Speicherbereiche - sogenannte Frames (dt. Rahmen) - aufgeteilt. In dem Stack Frame werden dann die lokalen Variablen, etc. der Funktion hinterlegt.

Beispiel

Kommen wir jetzt zu unserem Beispiel. Wir gehen den Code abschnittsweise durch. Dabei sehen wir wie Anweisungen aus dem C-Quellcode in Assembler übersetzt wurden und wie sich Register und der Stack verändern.

Die „richtige“ Ausführung eines C-Programms beginnt üblicherweise mit der Funktion main. Vorher werden Funktionen aufgerufen, die die Ausführung vor- und nachbereiten. Daher befinden sich bereits zu Beginn unserer Betrachtung Werte auf dem Stack.

Die main-Funktion beginnt mit einem Prolog. In diesem wird zunächst die untere Grenze (EBP) des alten Stack Frames gesichert, um diese später wiederherzustellen. Dazu wird EBP auf den Stack „gepusht“ (push ebp). Da der Wert auf den Stack gelegt wurde, wird automatisch die obere Grenze (ESP) angepasst. Anschließend wird der neue Stack Frame geöffnet. Dabei wird EBP auf die bisherige obere Grenze (ESP) gesetzt (mov ebp, esp) und ESP anschließend um 16 Bytes verringert (sub esp, 0x10). Es ist also ein Rahmen von 16 Bytes geöffnet, in das u.a. lokale Variablen geschrieben werden.

In unserem Fall ist der Stack so implementiert, dass er von „oben nach unten“ wächst. D.h. die „untere“ Grenze hat eine höhere Adresse als die „obere“. Das ist typisch für x86-Architekturen. Bei anderen Typen wie z.B. ARM gibt es auch Stack-Implementierungen, in denen der Stack nach oben wächst.

In der main-Funktion werden den beiden Variablen a und b Werte zugewiesen und anschließend an die Funktion add_numbers übergeben. Im Assembler-Code können wir das ebenso erkennen. Zunächst werden die Werte 0x5 und 0xa im aktuellen Stack Frame gespeichert. 0x5 wird an die Adresse ebp-4, also 0x7FE4 geschrieben. 0xa landet an der Adresse ebp-8.
Danach werden die Werte aus dem Stack Frame auf den Stack gepusht und damit an die nächste Funktion - also add_numbers -übergeben.

Es gibt verschiedene Konventionen, die Compiler nutzen, um Funktionsargumente zu übergeben. So ist es auch möglich die Argumente über die Register weiterzureichen. Die hier dargestellte Variante, in der die Werte auf den Stack gepusht werden ist die sogenannte cdecl-Aufrufkonvention.

Nun folgt die call-Instruktion, die zwei Dinge tut. Erstens wird die Speicheradresse der nächsten Instruktion auf den Stack gelegt, um zu speichern an welcher Stelle es nach dem Funktionsaufruf von add_numbers weitergeht. Zweitens wird der Instruction Pointer (EIP) auf den Beginn der Funktion add_numbers gesetzt, sodass dort die Ausführung fortgesetzt wird.

Die Funktion add_numbers startet ebenfalls mit einem Funktions-Prolog. Genau wie in der main-Funktion wird zunächst die untere Grenze des vorherigen Stack Frames gesichert und anschließend ein 16 Byte großer Frame geöffnet.

Als nächstes folgen die Vorbereitung und Durchführung der Addition. Da es sich bei der Variablen c ebenfalls um eine lokale Variable handelt, wird der Wert im Stack Frame der aktuellen Funktion gespeichert. (mov DWORD PTR [ebp-4], 0x14). Anschließend werden die beiden erhaltenen Funktionsargumente aus dem vorherigen Stack Frame genommen – Erinnerung: EBP + x ist eine Adresse im alten Stack Frame, da der Stack nach unten wächst - und in den Registern EDX und EAX gespeichert.

Die beiden Register werden addiert und das Ergebnis in EDX gespeichert, da das Register als erster Operand übergeben wurde (add edx, eax). Bis hierhin wurden also 5 und 10 mit dem Ergebnis 15 (0xf) addiert.

Dann wird der Wert, der an der Adresse ebp-4 steht, in das Register EAX geschrieben. Dabei handelt es sich um die lokale Variable c und damit um den Wert 20 (0x14). Anschließend werden erneut EAX und EDX addiert. Das Ergebnis landet diesmal in EAX, da dieses Register als erster Operand gesetzt wurde (add eax, edx).

Anschließend startet der Funktions-Epilog. Durch diesen wird der Stack Frame abgebaut und die Ausführung an der vorherigen Stelle fortgeführt. Die leave-Instruktion setzt dabei ESP auf den Wert von EBP - damit wird der Stack Frame geschlossen – und setzt EBP auf die untere Grenze des vorherigen Stack Frames zurück. Die ret-Instruktion verändert schließlich den Instruction Pointer so, dass die nächste Instruktion nach dem ursprünglichen call-Aufruf ausgeführt wird. Der dazu passende Wert ist wurde durch die call-Instruktion den Stack gelegt.

Wieder zurück in der main-Funktion werden zunächst die übergebenen Funktionsargumente vom Stack entfernt. Dazu wird ESP um 8 erhöht (add esp, 0x8). Dann wird das Ergebnis der Funktion add_numbers aus dem Register EAX in den Stack Frame an die Stelle ebp-12 geschrieben (mov DWORD PTR [ebp-12], eax). Hierbei handelt es sich um die lokale Variable c.

Die nächste Instruktion scheint unsinnig, da in EAX der Wert geschrieben wird, der noch in EAX steht. Das liegt unter anderem daran, dass wir jegliche Optimierungen durch den Compiler unterbunden haben. Weil wir den Wert der Variablen c zurückgeben wollen und der Rückgabewert typischerweise in dem Register EAX steht, wird er erneut in das Register geschrieben.

Die main-Funktion endet ebenfalls mit einem Epilog. Der Stack Frame wird abgebaut und die Ausführung geht dort weiter, wo sie vor dem Aufruf der main-Funktion aufgehört hat. Da es sich dabei wie bereits beschrieben um Funktionen handelt, die lediglich die Ausführung kontrollieren, endet hier unser Beispiel

Ausblick

Im nächsten Artikel werden wir praktischer und sehen uns mit Ghidra das Open-Source Reverse-Engineering-Tool der NSA im Einsatz an. Dabei schauen wir uns vor allem dekompilierten Code an und weniger (Dis-)Assembler. Um die Hintergründe besser nachvollziehen zu können, sind die Grundlagen aus diesem Artikel allerdings hilfreich.



Wichtige IT-Security-News per E-Mail

  • Aktuelle IT-Gefahren
  • Schutz-Tipps für Privatkunden
  • 15 % Willkommensgutschein