Speicherbereiche

Damit ein Programm ausgeführt werden kann, muss es in den Arbeitsspeicher eines Computers geladen werden. In einem Linux-basierten System belegt es dabei unterschiedliche Bereiche:

Memory layout of a program
Speicherbereiche eines Programms in Ausführung

Jeder dieser Bereiche erfüllt eine spezifische Funktion im Speicher. Dies sind:

  • Der Stack speichert lokale Variablen und Funktionsaufrufe, also Daten, die nur temporär während der Ausführung einer Funktion benötigt werden. Hier werden auch die Rücksprungadressen für Funktionsaufrufe gespeichert. Der Stack wächst nach unten zu niedrigeren Speicheradressen. Der Speicher auf dem Stack wird automatisch freigegeben, sobald eine Funktion beendet ist.
  • Die Memory Mapping Area enthält in der Regel dynamisch geladene Bibliotheken wie die C-Standardbibliothek libc.
  • Der Heap ist der Speicherbereich, in dem dynamische Speicherzuweisungen stattfinden. Das bedeutet, dass der Speicher zur Laufzeit durch Funktionen wie malloc angefordert und später manuell freigegeben wird. Der Heap wächst dabei nach oben zu höherer Speicheradressen.
  • Der Datenbereich besteht aus zwei Teilen:
    • Die .data-Sektion speichert globale und statische Variablen, die im Programm explizit mit einem festen Wert initialisiert wurden.
    • Die .bss-Sektion speichert globale und statische Variablen, die nicht explizit initialisiert wurden. Sie werden daher automatisch auf Null (0) oder einen anderen Standardwert gesetzt.
  • Der Code-Bereich enthält den ausführbaren Maschinencode des Programms. Dieser Bereich ist in der Regel schreibgeschützt, da der Programmcode nicht verändert werden sollte, während er ausgeführt wird.

Unterschiedliche Prozessorarchitekturen (wie ARM, x86, MIPS, etc.) haben ihre eigenen Konventionen und Mechanismen für die Organisation und Verwaltung des Speichers. Aus bisherigen Untersuchungen weißt du, dass der LUPFER auf einem ARM-Prozessor mit 64-Bit-Architektur läuft. Daran orientieren sich die weiteren Ausführungen.

Der Stack

Der Stack ist interessant, weil er anfällig für das Sicherheitsproblem Stack Buffer Overflow ist. Was genau ein Stack Buffer Overflow ist, erfährst du später.

Beim Stack handelt es sich um eine dynamische Datenstruktur. Das bedeutet, dass sich seine Größe zur Laufzeit ändern kann. Jedes Mal, wenn eine Funktion oder Methode aufgerufen wird, wird ein neuer Stack Frame auf den Stack gelegt. Es enthält alle Informationen, die zur Ausführung der Funktion erforderlich sind. Die wichtigsten Bestandteile eines Stack Frames sind:

  • Rücksprungadresse: Dies ist die Adresse im Programmcode, zu der das Programm nach dem Abschluss der Funktion zurückkehren soll. Diese Adresse wird beim Funktionsaufruf auf den Stack gelegt.
  • Lokale Variablen: Alle Variablen, die innerhalb der Funktion deklariert werden, werden im Stack Frame gespeichert. Sie existieren nur während des Lebenszyklus der Funktion.

Wenn eine Funktion beendet ist, wird der Stack Frame freigegeben, und die Ausführung kehrt zur aufrufenden Funktion zurück.

Stack frames
Hinzufügen von Stack Frames beim Funktionsaufruf und Freigeben von Stack Frames beim Verlassen der Funktion

Der Stack ist also nach dem LIFO-Prinzip organisiert (Last-In-First-Out). Dafür werden folgende Operationen benötigt:

  • push: Mit diesem Befehl wird ein Objekt auf den Stack abgelegt.
  • pop: Mit diesem Befehl wird das oberste Objekt vom Stack entnommen. Dabei ist zu beachten, dass der Stack von höheren zu niedrigeren Adressen wächst. Das oberste Objekt befindet sich daher an der niedrigsten Adresse des aktuellen Stack Frames.

CPU-Registersatz

In der ARM64-Architektur, auch als AArch64 bekannt, gibt es eine Reihe von Registern und spezifischen Konventionen, die für die Verwaltung des Stacks und der Funktionsaufrufe verantwortlich sind.

Register sind kleine, sehr schnelle Speicherbereiche innerhalb der CPU, um Daten und Adressen temporär zu speichern, die während der Programmausführung verarbeitet werden. Die CPU (Central Processing Unit) ist das zentrale Rechenwerk eines Computers, das sämtliche Berechnungen und Steuerbefehle für die Ausführung von Programmen verarbeitet.

Die wichtigsten Register im Zusammenhang mit dem Stack sind:

Register Übliche Verwendung
lr (Link Register) Wird dem Register x30 zugeordnet und speichert die Rücksprungadresse für Funktionsaufrufe
sp (Stack Pointer) Enthält die Adresse des obersten Elements im Stack Frame
fp (Frame Pointer) Wird dem Register x29 zugeordnet und zeigt auf den Beginn des aktuellen Stack Frames
pc (Program Pointer) Enthält die Adresse des nächsten auszuführenden Befehls

Die Register lr und fp sind Teil der 31 allgemeinen Register x0 bis x30. Alle diese Register sind 64 Bit groß. Werden nur 32-Bit-Werte benötigt, können die Register w0 bis w30 verwendet werden, die 32-Bit-Versionen der Register x0 bis x30 sind.

Der Stack Frame

Beispielprogramm

Hier ist Beispiel, um zu verstehen, was genau bei einem Funktionsaufruf in einem Stack Frame gespeichert wird. Das ist einfacher C-Code, der aus den drei Funktionen main, print_result und my_add besteht:

#include <stdio.h>

int my_add(int x, int y) {
  return x + y;
}

void print_result(int x, int y) {
  int result = my_add(x,y);
  printf("Result is %d\n", result);
}        

int main() {
  print_result(5,8);
  return 0;
}

Die main-Funktion ist der Einstiegspunkt unseres Programms. In dieser Funktion wird print_result aufgerufen, wobei zwei Argumente übergeben werden, die Integer 5 und 8. Die Funktion print_result nimmt beide Argumente entgegen und ruft die Funktion my_add auf, die die Summe der beiden Argumente zurückgibt. Anschließend gibt die Funktion printf den Rückgabewert auf der Konsole aus.

Was passiert nun auf einem Computer, wenn das Programm geladen wird? Das zeigt der Assembly-Code für das C-Programm.

Die Funktion main

Zunächst wird die main-Funktion ausgeführt, deren Assembly-Code wie folgt aussieht:

stp x29, x30, [sp, #-16]!
mov x29, sp
mov w1, #0x8
mov w0, #0x5
bl print_result
mov w0, #0x0
ldp x29, x30, [sp], #16
ret

Wir gehen die einzelnen Instruktionen nun Schritt für Schritt durch. Zunächst wird im sogenannten Prolog der Stack Frame eingerichtet:

stp x29, x30, [sp, #-16]!

Dabei werden der Frame Pointer x29 und das Link Register x30 als Paar in einen zusammenhängenden Speicherbereich auf dem Stack gespeichert, wobei die Speicheradresse 16 Byte unterhalb des aktuellen Stack Pointers sp liegt. Es werden also 16 Byte zusätzlicher Speicherplatz für den Stack reserviert. Das ! bedeutet, dass der Stack Pointer sp direkt nach dem Speichern aktualisiert und um den Wert 16 reduziert wird.

Anschließend wird der Wert des Frame Pointers x29 auf den Wert des Stack Pointers sp gesetzt:

mov x29, sp

Nach dem Prolog werden die lokalen Variablen initialisiert:

mov w1, #0x8
mov w0, #0x5

Dem Register w1 wird also der Wert 8 und dem Register w0 der Wert 5 zugewiesen. Das Präfix 0x deutet darauf hin, dass die Werte im Hexadezimalsystem dargestellt sind.

Danach sehen der Stack und ausgewählte CPU-Register wie folgt aus (Der Stack Pointer sp wird relativ zur niedrigsten Adresse des Stack Frames der Funktion main angegeben):

Stack frame and cpu registers after calling the function main
Stack Frame und ausgewählter CPU-Register nach Aufruf der Funktion 'main'

Im nächsten Schritt wird die Funktion print_result aufgerufen:

bl print_result

Dies geschieht mit dem Befehl bl, was für "Branch with Link" steht und ein spezieller Sprungbefehl ist, der nicht nur einen Sprung zur Zieladresse macht, sondern auch die Rücksprungadresse in das Link Register x30 speichert.

Die Rücksprungadresse ist die Adresse der Instruktion mov w0, #0x0, die in der main-Funktion auf die bl print_result-Instruktion folgt. Damit haben ausgewählte CPU-Register folgende Werte:

Cpu registers when calling the function print_result
Ausgewählte CPU-Register nach Aufruf der Funktion 'print_result'

Die Funktion print_result

Anschließend wird die Funktion print_result ausgeführt. Ihr Assembly-Code lautet:

stp x29, x30, [sp, #-48]!
mov x29, sp
str w0, [sp, #28]
str w1, [sp, #24]
ldr w1, [sp, #24]
ldr w0, [sp, #28]
bl my_add
str w0, [sp, #44]
ldr w1, [sp, #44]
adrp	x0, 0x746e5e050000
add	x0, x0, #0x7f0
bl	printf@plt
nop
ldp	x29, x30, [sp], #48
ret

Es folgt wieder eine Erklärung der einzelnen Instruktionen. Im Prolog wird ein neuer Stack Frame eingerichtet:

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

Mit dem ersten Befehl werden der Frame Pointer x29 und das Link Register x30 als Paar 48 Byte unterhalb des aktuellen Stack Pointers sp gespeichert. Außerdem wird der Stack Pointer sp aktualisiert und um den Wert 48 reduziert. Mit der nächsten Instruktion wird der Frame Pointer x29 auf den Stack Pointer sp gesetzt.

Mit den nächsten Instruktionen werden die Werte der Register w0 und w1 und damit die übergebenen Argumente auf dem Stack an der Adresse sp + 28 beziehungsweise sp + 24 gespeichert:

str w0, [sp, #28]
str w1, [sp, #24]

Anschließend werden die Werte, die auf dem Stack an den Adressen sp + 24 und sp + 28 gespeichert wurden, also der Wert der Register w1 und w0 wieder in die Register w1 und w0 geladen:

ldr w1, [sp, #24]
ldr w0, [sp, #28]

Diese Operationen verändern den Stack und ausgewählte CPU-Register wie folgt:

Stack frame and cpu registers after initializing local variables of function print_result
Stack Frame und ausgewählte CPU-Register nach Initialisierung der lokalen Variablen der Funktion 'print_result'

Mit der nächsten Instruktion wird die Funktion my_add aufgerufen und die Rücksprungadresse im Link Register x30 aktualisiert:

bl my_add

Das Link Register x30 verweist damit auf die Adresse der Instruktion str w0, [sp, #44], die in der print_result-Funktion auf die bl my_add-Instruktion folgt:

Cpu registers when calling the function my_add
Ausgewählte CPU-Register nach Aufruf der Funktion 'my_add'

Die Funktion my_add

Anschließend wird die Funktion my_add ausgeführt, deren Assembly-Code wie folgt aussieht:

sub sp, sp, #0x10
str w0, [sp, #12]
str w1, [sp, #8]
ldr w1, [sp, #12]
ldr w0, [sp, #8]
add w0, w1, w0
add sp, sp, #0x10
ret

Hier ist wieder eine schrittweise Erklärung des Assembly-Codes. Zunächst wird der Prolog ausgeführt:

sub sp, sp, #0x10

Der Stack Pointer sp wird also um den Wert 16 (0x10 im Hexadezimalsystem) reduziert. Damit hat der Stack Frame der Funktion my_add eine Größe von 16 Byte. Wie du siehst, werden hier der Frame Pointer x29 und das Link Register x30 nicht auf dem Stack gesichert. Das ist nicht notwendig, weil die Funktion my_add keine weiteren Funktionen aufruft.

Mit den nächsten beiden Instruktionen werden die übergebenen Argumente, die in den Registern w0 und w1 gespeichert sind, auf dem Stack gesichert:

str w0, [sp, #12]
str w1, [sp, #8]

Die Adresse sp + 12 enthält damit den Wert 5 des Registers w0 und die Adresse sp + 8 den Wert 8 des Registers w1.

Anschließend werden die auf dem Stack gespeicherten Argumente wieder in die Register w1 und w0 geladen:

ldr w1, [sp, #12]
ldr w0, [sp, #8]

Dabei wird dem Register w1 der Wert 5 von der Adresse sp + 12 und dem Register w0 der Wert 8 von der Adresse sp + 8 zugewiesen.

Der Stack und ausgewählte CPU-Register sehen nach all diesen Operationen wie folgt aus:

Stack frame and cpu registers after calling function my_add
Stack Frame und ausgewählte CPU-Register nach Aufruf der Funktion 'my_add'

Mit der nächsten Instruktion werden die Werte der Register w1 und w0 addiert und das Ergebnis in w0 gespeichert:

add w0, w1, w0

Mit den nächsten Instruktionen wird die Funktion my_add im sogenannten Epilog beendet:

add sp, sp, #0x10
ret

Dafür wird zunächst der Stack Pointer sp um 16 erhöht und so der Stack Frame wieder freigegeben. Mit dem ret-Befehl wird die Kontrolle an die aufrufende Funktion print_result zurückgegeben.

Rückkehr in aufrufende Funktion

Nach Rückkehr zur Funktion print_result sichert der nächste Befehl das im Register w0 gespeicherte Ergebnis der Funktion my_add an der Adresse sp + 44 auf dem Stack:

str w0, [sp, #44]

Anschließend wird das Ergbnis in das Register w1 geladen:

ldr w1, [sp, #44]

Der Stack und ausgewählte CPU-Register sind anschließend wie folgt angeordnet:

Stack frame and cpu registers after returning to function print_result
Stack Frame und ausgewählte CPU-Register nach Rückkehr zur Funktion 'print_result'

Mit den nächsten Instruktionen wird die Funktion printf aus der Standardbibliothek stdio.h aufgerufen und ausgeführt. Da es hier um die Rückkehr in eine aufrufende Funktion geht, wird dieser Funktionsaufruf nicht weiter betrachtet.

Nachdem die Funktion print_result die Kontrolle zurück erhält, folgt der Epilog:

ldp x29, x30, [sp], #48
ret

Dabei werden die Werte, die sich an der Speicheradresse sp befinden, in die Register x29 und x30 geladen. Außerdem wird der Wert des Stack Pointers sp um den Wert 48 erhöht, wodurch der aktuelle Stack Frame freigegeben und der Stack aufgeräumt wird. Mit der Instruktion ret wird die Kontrolle an die Funktion main zurückgegeben.

In der main-Funktion wird anschließend der Wert 0 (0x0 im Hexadezimalsystem) in das Register w0 geladen:

mov w0, #0x0

Dies ist der Rückgabewert an die aufrufende Funktion.

Anschließend wird der Epilog ausgeführt:

ldp x29, x30, [sp], #16
ret

Der Frame Pointer x29 und das Link Register x30 werden aktualisiert. Gleichzwitig wird der Stack Pointer sp um den Wert 16 erhöht und so der Speicher wieder freigegeben. Mit der ret-Instruktion wird die Kontrolle an die aufrufende Funktion zurückgegeben.