Umleiten des Kontrollflusses

Im vorherigen Abschnitt wurde ein Stack Buffer Overflow ausgenutzt, um die Rücksprungadresse zu manipulieren und einen Fehler auszulösen. Es ist aber auch möglich, die Rücksprungadresse so zu manipulieren, ohne dass das Programm abstürzt. Das soll das Programm redirect_flow zeigen. Hier ist der C-Quellcode:

#include <string.h>
#include <stdlib.h>

void call_me() {
  printf("Oops, you redirected the control flow successfully.\n");
  exit(0);
}

void copy_string(char *src) {
  char buffer[8];
  strcpy(buffer, src);
  printf("Received %s\n", buffer);
}

int main(int argc, char *argv[]) {
  copy_string(argv[1]);
  printf("Program ends as expected.\n");
  return 0;
}

Im Grunde handelt es sich um das gleiche Programm wie im Abschnitt Stack Buffer Overflow 1. Der einzige Unterschied ist, dass es zusätzlich die Funktion call_me gibt, die aber nicht aufgerufen wird.

In diesem Abschnitt wirst du erfahren, wie du über einen Stack Buffer Overflow den Kontrollfluss des Programms beeinflussen und so call_me aufrufen kannst. Die Idee ist ganz einfach: Du überschreibst die Rücksprungadresse der Funktion main mit der Speicheradresse von call_me. Aber wie kannst du diese Speicheradresse herausfinden? Genau da kommt ein Debugger wie der GNU Debugger (GDB) ins Spiel.

Ein Debugger ist ein Werkzeug, das es ermöglicht, Programme während der Ausführung zu überwachen, den Code schrittweise zu analysieren und Fehler zu diagnostizieren.

Debugging

Debugger starten

Um Cross-Debugging einer ARM64-Binary auf einem x86_64-System zu ermöglichen, verwenden wir die Version gdb-multiarch des GNU Debuggers (GDB). Du benötigst zwei Terminals. Im ersten Terminal startest du das Programm, dass du ausführen möchtest:

qemu-aarch64 -L /usr/aarch64-linux-gnu/ -g <port> ./<program> <argument>

Hier ist eine Erklärung der einzelnen Bestandteile:

  • qemu-aarch64 startet das Programm QEMU zum Emulieren von ARM64-Architekturen (aarch64).
  • -L /usr/aarch64-linux-gnu/ gibt den Pfad zu einer Root-Dateistruktur an, die von QEMU verwendet wird und die für die Emulation benötigten Bibliotheken und Systemdateien enthält.
  • -g <port> startet einen GDB-Server und setzt den Debugger-Port auf <port> (Wähle eine Portnummer > 1.023).
  • ./<program> <argument> startet das emulierte Programm mit einem Argument.

Mit der Tastenkombination Strg+C kannst du gdb abbrechen.

Im zweiten Terminal muss nun der Debugger gestartet werden:

gdb-multiarch -q -ex 'set architecture aarch64' -ex 'file <program>' -ex 'target remote localhost:<port>'

Hier ist eine Erklärung der einzelnen Bestandteile:

  • gdb-multiarch startet den GDB-Debugger, der für Cross-Debugging verwendet wird und mehrere Architekturen unterstützt.
  • -q startet GDB im stillen Modus (quiet mode), sodass keine zusätzlichen Begrüßungsnachrichten oder Banner angezeigt werden.
  • -ex 'set architecture aarch64' konzipiert GDB direkt beim Start für ARM-64-Programme (aarch64).
  • -ex 'file <program>' lädt die Datei <program> als zu debuggendes Programm.
  • -ex 'target remote localhost:<port>' verbindet den Debugger über den GDB-Server, der auf dem Port <port> läuft, mit dem Zielprogramm, das auf localhost läuft.

Wichtige Befehle in GDB sind:

GDB-Befehl Erklärung
disass <fct> Gibt den Assemblercode der Funktion <fct> aus
break *<addr> Setzt einen Haltepunkt an der Adresse <addr>
info functions Führt alle Funktionen auf, die im Quellcode des Programms vorhanden sind
info frame Zeigt Informationen über den aktuellen Stack Frame
info reg <reg> Zeigt den Inhalt des Registers <reg> an
info address <fct> Zeigt die Startadresse der <fct> an
x/<number>xg <addr> Zeigt die ersten <num> Speicherinhalte in hexadezimaler Form als 64-Bit-Wert ab der Adresse <addr>
c Setzt die Ausführung des Programms fort, bis der nächste Haltepunkt erreicht wird oder das Programm endet
n Führt den nächsten Quellcodebefehl aus (überspringt Funktionsaufrufe)
s Führt den nächsten Quellcodebefehl aus, wobei auch in Funktionsaufrufe gesprungen wird
ni Führt den nächsten Maschinenbefehl aus (überspringt Funktionsaufrufe)
si Führt den nächsten Maschinenbefehl aus, wobei auch in Funktionsaufrufe gesprungen wird
quit Beendet die GDB-Debugging-Sitzung

Das Programm redirect_flow wurde so kompiliert, dass es Debugging-Symbole enthält. Diese erleichtern das Debugging, da sie Informationen über den Quellcode wie zum Beispiel Funktionsnamen, Dateinamen und Zeilennummern bereitstellen. Normalerweise werden Programme jedoch ohne Debugging-Symbole kompiliert. Ohne diese Symbole ist das Debugging schwieriger, da nur die Maschinencodes und Adressen angezeigt werden.

Adressen einer Funktion ermitteln

Wie oben erwähnt, ist der erste Schritt die Startadresse der Funktion call_me zu ermitteln, weil diese die Rücksprungadresse in der main-Funktion überschreiben soll.

Nun kennst du die Startadresse der Funktion call_me. Es ist wichtig zu wissen, dass diese Startadresse systemabhängig ist und sich ändern kann. Das passiert, wenn das System zum Beispiel durch ein Update neu konfiguriert wird. Außerdem sorgt der Schutzmechanismus ASLR dafür, dass sich die Speicheradressen von Programmen bei jedem Systemstart und jedem Programmaufruf ändert.

Für die weitere Betrachtung gehen wir davon aus, dass die Adresse 0x00007ffff7c1c858 lautet. Diese Zahl hat führende Nullen, die keine Bedeutung für den Wert der Zahl haben. Sie dienen lediglich dazu, die Zahl in einer bestimmten Breite (in diesem Fall 64 Bit) darzustellen. Wir können die Adresse also auch als 0x7ffff7c1c858 darstellen.

Stack Frame untersuchen

Im zweiten Schritt geht es darum, den Payload zusammenzustellen. Die Frage ist dabei, wie viele Zeichen übergeben werden müssen, bis die Rücksprungadresse im Stack Frame der Funktion main überschrieben wird. Dafür wirst du das Speicherlayout in GDB untersuchen.

Führe folgende Schritte durch:

  1. Setze einen Haltepunkt bei der Adresse der Funktion main:

    break *main

    Damit hält das Programm an, wenn es die Funktion main erreicht.

  2. Führe redirect_flow bis zum Haltepunkt aus:

    c
  3. Am Haltepunkt ist der Stack Frame noch nicht eingerichtet und damit die Rücksprungadresse der main-Funktion noch nicht abgelegt. Dies geschieht erst mit dem 1. Maschinenbefehl, den du mit folgendem Befehl ausführen lässt:

    ni
  4. Nun lässt sich ermitteln, an welcher Speicheradresse die Rücksprungadresse gespeichert ist und welchen Wert sie enthält. Führe dafür folgenden Befehl aus:

    info frame

    Die Speicheradresse der Rücksprungadresse ist angegeben mit x30 at <Speicheradresse> und kann beispielsweise 0x4000007ff548 lauten. Die Rücksprungadresse selbst in hexadezimaler und dezimaler Form wird mit folgendem Befehl ausgegeben:

    info reg $x30

    Die Rücksprungadresse verweist also zum Beispiel auf die Adresse 0x4000008884c4.

  5. Als Nächstes wird die Startadresse des Puffers ermittelt, in dem das Argument in der Funktion copy_string gespeichert wird. Dafür setzt du einen weiteren Haltepunkt:

    break *copy_string
  6. Führe anschließend das Programm redirect_flow bis zu diesem Haltepunkt aus:

    c
  7. Dann muss der Stack Frame von copy_string eingerichtet werden und das Argument in den Puffer gespeichert werden. Es muss also die Funktion strcpy ausgeführt werden. Dies erreichst du, in dem du folgende Befehle ausführt:

    s
    n

    Mit s wird in die Funktion copy_string eingetreten und mit n der nächste Quellcodebefehl, also strcpy(buffer, src) ausgeführt.

  8. Nun kannst du den Stack Frame von copy_string untersuchen und ermitteln, ab welcher Speicheradresse das Argument abgelegt ist. Mit folgendem Befehl wird der Speicherinhalt von 10 Elementen, jeweils 8 Bytes groß, in hexadezimaler Form angezeigt, beginnend bei der Adresse, auf die der aktuelle Stack Pointer sp verweist:

    x/10xg $sp

    Ein A entspricht in hexadezimaler Form 41. Damit kannst du nun feststellen, an welcher Stelle das erste A abgelegt ist. Dies ist beispielsweise die Adresse 0x4000007ff538. Wenn du dir die Ausgabe genau ansiehst, fällt dir auf, dass auch der Stack Frame der Funktion main und damit die Speicheradresse ihrer Rücksprungadresse angezeigt werden.

  9. Zuletzt berechnest du den Abstand zwischen der Startadresse des Puffers und der Speicheradresse der Rücksprungadresse der Funktion main:

    p (<Startadresse Puffer> - <Speicheradresse Rücksprungadresse von main>)

    Damit ist klar, dass 16 Zeichen übergeben werden müssen, bevor die Rücksprungadresse überschrieben wird.

Payload erstellen und ausführen

Bisher hast du Zeichen als Argumente in der UTF-8-Zeichenkodierung übergeben. Von der Rücksprungadresse kennst du jedoch den hexadezimalen Wert der Zeichen. Wie kannst du daher Hex-Bytes als Argument an redirect_flow übergeben? Dies lässt sich mit der sogenannten ANSI-C Quoting-Syntax realisieren:

$'\x41\x42\n\x43\x44'

Dies ist eine spezielle Syntax, die es ermöglicht, Escape-Sequenzen innerhalb einer Zeichenkette zu verwenden. Mit dieser Syntax kannst du zum Beispiel Zeichen wie \x41 in hexadezimaler Darstellung (A) oder Steuerzeichen wie \n für einen Zeilenumbruch verwenden.

Das Programm redirect_flow wird mit folgendem Befehl ausgeführt:

qemu-aarch64 -L /usr/aarch64-linux-gnu/ ./<programm> $'<payload>'

Ohne die Option -g <Port> wird das Debugging deaktiviert.

Nachdem du anhand dieses Beispiels verstanden hast, wie ein einfacher Stack Buffer Overflow funktioniert, werden wir uns nun das Programm lupfer_heartbeat_server anschauen.