Einleitung

Für den LUPFER steht dir das Firmware-Archiv zur Verfügung. Damit hast du Zugriff auf die Binärdatei des Dienstes, der auf dem Port 161 läuft. Sie befindet sich im Verzeichnis /lupfer/opt/heartbeat/bin/ und heißt lupfer_heartbeat_server.

Ab jetzt konzentrieren wir uns auf Schwachstellen in Linux-Binaries, insbesondere auf den Stack Buffer Overflow, der auftritt, weil der Speicher auf dem Stack unsachgemäß verwaltet wird. In diesem Abschnitt erfährst du, wie du das Programm lupfer_heartbeat_server auf einen Stack Buffer Overflow testen kannst.

Der Puffer oder Buffer

Der Stack ist ein Speicherbereich, der von Programmen genutzt wird, um lokale Variablen und Funktionsaufrufe zu speichern. In dem Beispiel-Programm, mit dem der Stack erklärt wird, sind ausschließlich Variablen vom Datentyp int enthalten, der eine feste Größe von 4 Byte auf 64-Bit-Systemen hat. Damit ist beim Kompilieren klar, wie viel Platz auf dem Stack Frame reserviert werden muss.

Was ist aber bei komplexeren Datenstrukturen wie Arrays?

Ein Array ist eine Datenstruktur, die eine Sammlung von Elementen des gleichen Typs speichert. Diese Elemente sind in einer festen Reihenfolge angeordnet und über einen Index zugänglich.

Arrays ermöglichen es, mehrere Werte effizient zu speichern und darauf zuzugreifen. Sie haben eine feste Größe, die sich aus der Anzahl der Elemente und der Speichergröße pro Element ergibt, wobei die Größe eines einzelnen Elements wiederum durch den Datentyp bestimmt wird. Arrays, die lokale Funktionsvariablen sind, werden auf dem Stack gespeichert. Hier heißen sie dann Puffer oder im Englischen Buffer.

Hier ist ein Beispielprogramm buffer_overflow, das ein Array und damit einen Puffer nutzt:

#include <stdio.h>
#include <string.h>

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]);
  return 0;
}

Was macht das Programm? Es besteht aus einer main-Funktion, die mit den Argumenten der Kommandozeile arbeitet:

  • argc (argument count) ist die Anzahl der übergebenen Kommandozeilenargumente.
  • argv (argument vector) ist ein Array von Strings, das die Argumente enthält. argv[0] ist der Name des Programms, argv[1] ist das erste benutzerdefinierte Argument.

Relevant ist das erste benutzerdefinierte Argument argv[1], das an die Funktion copy_string übergeben wird. Innerhalb der Funktion copy_string wird ein Array buffer mit einer Größe von 8 Zeichen deklariert. Die C-Standardfunktion strcpy kopiert den String argv[1] in das buffer-Array. Der Inhalt des Arrays buffer wird mit der C-Standardfunktion printf im Terminal ausgegeben.

Bevor du das Programm ausführst, lässt du dir zunächst den Dateityp und grundlegende Informationen dazu anzeigen. Nutze dafür den Befehl

file <filename>

Die Bedeutung der wichtigsten Teile der Ausgabe ist:

  • ELF: Die ausführbare Datei liegt im Executable and Linking Format (ELF) vor.
  • 64-bit: Das Programm wurde für eine 64-Bit-Architektur entwickelt.
  • LSB: LSB steht für Least Significant Bit. Das bedeutet, dass das Dateiformat eine Little-Endian-Speicheranordnung verwendet, bei der die niederwertigeren Bytes zuerst gespeichert werden.
  • pie: PIE steht für Position Independent Executable. Das bedeutet, dass das Programm so kompiliert wurde, dass es an jeder Speicheradresse ausgeführt werden kann, um Sicherheitsrisiken zu minimieren, bei denen ein Angreifer vorhandenen, legitimen Code aus einem Programm wiederverwendet.
  • ARM aarch64: Das Programm ist für die ARM-Architektur im AArch64-Modus (64-Bit) ausgelegt, der häufig in modernen ARM-basierten Prozessoren verwendet wird.

Da es sich um eine Binary der Architektur AArch64 (64-Bit-ARM) handelt, dieser Leitfaden aber auf einem x64_86-System basiert, wird qemu-aarch64 QEMU verwendet, um die AArch64-Architektur zu emulieren. Das Programm lässt sich daher mit folgendem Aufruf starten:

qemu-aarch64 -L /usr/aarch64-linux-gnu/ ./buffer_overflow ABCDEFG

Mit -L /usr/aarch64-linux-gnu/ wird dem Emulator ein Verzeichnis angegeben, das die benötigten Bibliotheken und Systemdateien für die emulierte Architektur enthält. Anschließend wird das Programm buffer_overflow gestartet, wobei der String ABCDEFG als Argument übergeben wird.

Du siehst das erwartete Verhalten. Das Programm gibt die Eingabe einfach wieder aus. Das geschieht in der Funktion copy_string, deren Assembly wir uns nun anschauen. Wir beschränken uns dabei auf die ersten Instruktionen.

stp x29, x30, [sp, #-48]!
mov x29, sp 

Der Frame Pointer x29 und das Link Register x30 werden auf dem Stack gespeichert, wobei die Speicheradresse 48 Byte unterhalb des aktuellen Stack Pointers sp liegt. Der Stack Pointer sp wird direkt nach dem Speichern aktualisiert und um den Wert 48 reduziert. Mit der Instruktion mov wird der Wert des Stack Pointers sp in das Register x29 kopiert.

str x0, [sp, #24]

Anschließend wird der Inhalt des Registers x0 auf dem Stack an der Adresse sp + 24 gespeichert. Das Register x0 enthält den Zeiger auf den mit dem Programmaufruf übergebenen String ABCDEFG. Daher ist nun die Speicheradresse zu diesem String auf dem Stack gespeichert.

add x0, sp, #0x28

Als Nächstes wird die Speicheradresse des Arrays buffer in das Register x0 gespeichert. Sie befindet sich 40 Byte oberhalb des Stack Pointers sp. Berücksichtigen musst du dabei, dass der Dezimalwert 40 als Hexadezimalwert 0x28 dargestellt ist.

ldr x1, [sp, #24]

Der Wert an der Adresse sp + 24 wird in das Register x1 geladen. Damit zeigt x1 nun auf den übergebenen String ABCDEFG.

bl strcpy@plt 

Dann wird die Funktion strcpy aufgerufen, wobei die Zieladresse mit x0 und der Quellstring der Adresse aus dem Register x1 entnommen wird. Damit wird der String ABCDEFG an der Adresse sp + 40 des Arrays buffer gespeichert.

Nach Rückkehr aus der Funktion strcpy sieht der Stack so aus:

Stack frame of the 'copy_string' function
Stack Frame der Funktion 'copy_string' nach Rückkehr aus der Funktion 'strcpy'

Für das Array buffer stehen also wie deklariert 8 Byte zur Verfügung. In der Programmiersprache C wird ein spezielles Zeichen, der sogenannte Nullterminator (\0), verwendet, um das Ende eines Strings zu markieren. Aus diesem Grund hat das Array buffer nur Platz für sieben Zeichen und den Nullterminator. Der String ABCDEFG füllt also den vorgesehenen Speicherplatz aus und grenzt unmittelbar an den Stack Frame der Funktion main.

Ausnutzen eines Stack Buffer Overflows

Nun soll es aber endlich um den Stack Buffer Overflow gehen.

Wird mit dem Programmaufruf ein String mit einer Länger von mindestens 16 Zeichen übergeben, dann gibt das Programm buffer_overflow im Terminal die Fehlermeldung Segmentation fault aus. Ein Blick auf den Stack verrät, warum dieser Fehler auftritt:

The string overwrites the frame pointer and the return address stored in the stack frame of the 'main' function.
Überschreiben des Frame Pointers und der Rücksprungadresse bei einem Stack Buffer Overflow

Der String überschreibt die Rücksprungadresse im Stack Frame der Funktion main, die ab der Adresse 8 gespeichert ist. Wird beispielsweise ein String mit einer Länge von 16 Bytes übergeben, überschreibt der Nullterminator \0 das niedrigstwertige Byte der Rücksprungadresse.

Warum wird das niedrigstwertige Byte zuerst überschrieben? Das liegt am Little-Endian-System, in dem mehrbyteige Daten wie Adressen im Speicher so gespeichert werden, dass das niedrigstwertige Byte an der niedrigeren Speicheradresse liegt und das höchstwertige Byte an der höheren Speicheradresse. Das bedeutet, dass die Bytes der Adresse in umgekehrter Reihenfolge im Speicher abgelegt werden. Die Adresse 0x1817161514131211 wird also Little-Endian folgendermaßen gespeichert:

8-byte address stored in Little Endian format
Speichern einer 8-Byte-Adresse im Little-Endian-Format

Deshalb wird also das Byte 0x11 vom Nullterminator \0 bzw. 0x00 in hexadezimaler Schreibweise überschrieben, wenn ein String mit einer Länge von 16 Bytes an das Programm buffer_overflow übergeben wird.

Wenn das Programm anschließend versucht, zu dieser manipulierten Adresse zurückzukehren, aber keinen Zugriff darauf hat, führt dies zur Speicherschutzverletzung und damit zur Fehlermeldung Segmentation fault.

Schutzmechanismen

Hier sind einige Maßnahmen, um Stack Buffer Overflows zu vermeiden:

  1. Verwendung sicherer Funktionen: In C gibt es sichere Funktionen wie strlcpy, die die Größe eines Puffers überprüfen und anstelle unsicherer Funktionen wie strcpy verwendet werden sollten.
  2. Überprüfung von Puffergrößen: Im Quellcode werden Grenzen für alle Eingabedaten definiert, um sicherzustellen, dass die Größe des Puffers eingehalten wird.
  3. Verwendung moderner Programmiersprachen: Sprachen wie Rust oder Go bieten Sicherheitsfeatures wie automatisch überprüfte Array- oder Puffergrenzen und sind von Natur aus weniger anfällig für Buffer Overflows.

Daneben gibt es auch Compiler-Schutzmechanismen, die das Ausnutzen von Stack-Buffer-Overflow-Schwachstellen erschweren. Dies sind beispielsweise:

  1. Stack Canaries: Es werden zufällige Werte (Canaries) vor die Rücksprungadresse im Stack eingefügt. Wenn ein Buffer Overflow auftritt, wird der Canary-Wert überschrieben, was beim Funktionsaufruf zu einem Fehler führt.
  2. Position Independent Executable (PIE): Wenn das Programm als PIE kompiliert wird, ist die Basisadresse des Programms bei jedem Start zufällig, was es schwieriger macht, gezielt auf den Stack zuzugreifen.

Speicher-Schutztechnologien erschweren ebenfalls das Ausnutzen von Stack-Buffer-Overflow-Schwachstellen:

  1. Non-Executable Stack (NX oder DEP): Moderne Betriebssysteme und Prozessoren unterstützen nicht-ausführbaren Speicher (NX oder DEP). Dies verhindert, dass der Stack als ausführbarer Bereich genutzt wird, selbst wenn ein Buffer Overflow stattfindet.
  2. Address Space Layout Randomization (ASLR): Dies randomisiert die Speicheradressen von Stack, Heap und Bibliotheken, was das gezielte Ausnutzen von Bufferoverflows erschwert.