Stackmanipulation

Dieses Kapitel soll die Manipulation des Stacks in Windows- bzw. Linux-Systemen beschreiben. Nun ist dies allerdings ein schwerwiegender Eingriff in ein laufendes Programm, welcher auch die Funktionalität stark beeinträchtigt bzw. verändert.
Somit dürfte auch klar sein, dass diese Thematik besonders interessant für sogenannte "Hacker" ist. Nun soll dies keine Anleitung zum hacken sein, sondern eher die Effekte einer versehentlichen oder sogar gewollten Manipulation des Stack erläutern. Besonders deshalb, da nach diesem schon seit langem bekannten Problem immer noch viele Angriffe auf Programme erfolgreich durchgeführt werden.

Speichersegmentierung

Soll auf einer x86-CPU ein Programm oder Prozess gestartet werden, müssen vor der eigentlichen Ausführung erst ein paar Vorbereitungen getroffen werden. In enger Zusammenarbeit mit dem Betriebssystem werden für den auszuführenden Prozess drei Bereiche im Hauptspeicher gesucht. Dabei spielt die Startadresse eines Segmentes keine Rolle und kann bzw. wird bei einem erneuten Programmstart auch variieren. Entscheidend ist hierbei nur, dass jedes Segment für sich einen zusammenhängenden Adressbereich fixer Größe abdeckt.
Nun stellt sich aber die Frage, welchen Sinn die Unterteilung des Speichers macht, bzw. welche Funktion jedes einzelne Segment hat. Wird ein fertig erstelltes Programm, vorzugsweise eine .exe, gestartet und im ersten Schritt vom Betriebssystem (OS) von einem Datenträger gelesen, besteht es primär nur aus ausführbarem Code. Diese CPU-Anweisungen werden in einem eigens dafür vorgesehenen Bereich, dem Codesegment (CS), abgelegt. Da ein Programm nicht anderes als ein vom OS aufgerufenes Unterprogramm ist, werden häufig auch Übergabeparameter an das Programm übergeben. Genauso wie es in der Regel auch einen Rückgabewert an das OS liefert. Diese für den Prozess statischen Parameter, ebenfalls die Strings, die in der Konsole mittels printf ausgegeben werden, werden im sogenannten Datensegment (DS) gehalten. Das letzte Segment, das Stacksegment (SS), das in unserem Falle näher betrachtet werden muss, beinhaltet, wie sein Name schon sagt, den Stack. Nicht nur die Rücksprungadressen, sondern auch lokale Variablen werden hier abgelegt.
Am Rande sei noch erwähnt, dass drei CPU-Register, die Segmentregister CS, DS und SS, auf die Anfangsadressen der drei Segmente zeigen. So kann eine dynamische Anordnung der Bereiche bei der Programmausführung realisiert werden.
Die unten stehende Abbildung gibt eine denkbare Anordnung mit den Adressen der drei Segmente im Hauptspeicher wieder. Nicht ganz korrekt ist die Darstellung, dass die Segmentregister CS, DS und SS die Adressen direkt halten. Sie verweisen lediglich auf einen Speicherbereich, in dem die Startadressen zu finden sind. Gut zu erkennen ist jedoch die "räumliche" Trennung der einzelnen Segmente im Speicher.

Code- und Datensegment

Da sie eine weniger entscheidende Rolle beim Bufferoverflow spielen, sollen nur noch ein paar ergänzende Informationen zu den Segmenten Code und Daten gegeben werden.
Wie schon erwähnt, stehen im Codesegment die Befehle des eigentlichen Programms. Da sich die Firma Intel aus nicht nachvollziehbaren Gründen nicht auf eine einheitliche Länge der Befehle einlassen wollte, gibt der aktuelle Befehl auch seine eigentliche Länge wieder. Somit lassen sich die Positionen der nächsten Befehle nicht im Voraus bestimmen. Bei Auszügen der Codesegmente ist hierauf zu achten, sonst könnte es zur Verwirrung kommen.
Daten, die generell für den gesamten Ablauf dem gesamten Prozess zur Verfügung stehen sollen, werden im Datensegment gespeichert. Hierzu zählen die schon erwähnten Textstrings oder Kommandozeilenparameter, aber auch globale Variablen, die vor dem eigentlichen Hauptprogramm deklariert und initialisiert werden.
Um ein Überschreiben der Codedaten zu verhindern, wacht das Betriebssystem über das Codesegment. Aus diesem Bereich kann der Anwender bzw. der Prozess die Daten nur lesen, ein Schreiben ist nicht möglich. Man sagt auch, der Bereich ist read-only. Nur das OS hat das Recht, den Programmcode abzulegen, also zu schreiben.
Anders sieht es im Datensegment aus. Es dürfte leicht nachvollziehbar sein, dass eine Beschränkung auf "nur Lesen" nicht viel Sinn machen würde, wenn hier die Variablen abgelegt werden sollen. Somit gilt in diesem Segment auch für den Prozess read + write. Daten können so zur Laufzeit geändert werden.
Für beide Segmente gilt, dass sie von den niedrigen zu den hohen Adressen hin wachsen. D.h. der nächste Befehl oder die nächste Variable liegt an der bzw. den nächst höheren Adressen.

Stacksegment

Ohne dieses Segment wäre die Ausführung von Unterprogrammen und die Realisierung von globalen Variablen wohl um einiges schwieriger. Sobald ein Prozess eine Funktion oder Prozedur aufruft, verzweigt der Programmablauf an eine andere Stelle im Hauptspeicher. Damit der Befehl nach dem Aufruf auch wieder gefunden werden kann, wird die Adresse nach dem aktuellen Instruction Pointer (EIP) auf dem Stack abgelegt. Man bezeichnet diesen Wert als Rücksprungadresse. Da sehr viele Berechnungen und Operationen in den CPU-Registern durchgeführt werden und diese auch nur begrenzt zur Verfügung stehen, ist es notwendig, diese gelegentlich freizugeben. Bei einem Sprung in eine Routine dürfen die Registerinhalte allerdings nicht verloren gehen. Deshalb werden sie ebenfalls auf dem Stack zwischengespeichert und nach dem Rücksprung in die Register zurück geschrieben. So wird jedem Unterprogramm der Einsatz aller Register ermöglicht, ohne dass Daten verloren gehen können.
Lokale Variablen lassen sich auch sehr gut auf dem Stack anlegen. Sie existieren für eine Routine nur solange, bis ein Rücksprung erfolgt, was auch dem Konzept der lokal angelegten Variablen entspricht.
Der Stack verhält sich sehr dynamisch. Er wächst innerhalb seines Segmentes, je stärker die Ausführung von Routinen geschachtelt ist. Gewöhnungsbedürftig ist, dass dieses Segment von den hohen zu den niedrigen Adressen hin orientiert ist. Mit anderen Worten, er wächst zu den niedrigen Adressen hin.
Die weiter oben erwähnte Eigenschaften der Speicherung von lokalen Variablen hat zur Folge, dass ein Programm read + write Rechte auf dem Stack besitzt. Was als solches schon ein gewisses Risiko birgt, da hier zwei Kategorien von Daten abgelegt werden. Daten in den Variablen, die "nur" Ergebnisse des Programms beeinflussen und die Daten, die den Programmfluss wie eben die Rücksprungadressen steuern.
Noch viel gefährlicher ist die Tatsache, dass Daten auf dem Stack als Programmcode interpretiert und ausgeführt werden kann.

Stackregister

Um den Stack richtig verwalten zu können, werden noch ein paar weitere Register benötigt. Zum einen der eigentliche Stackpointer (ESP), der auf das zuletzt gezeigte Element auf dem Stapel zeigt. Mit seiner Hilfe ist es möglich, die Werte von z.b. Registern in umgekehrter Reihenfolge wieder vom Stack zu nehmen. Außerdem zeigt es der CPU, wo der nächste zu sichernde Wert abgelegt werden kann.
Nun ergeben sich aber ein paar kleine Schwierigkeiten beim Aufruf eines Unterprogramms. Dieses speichert auch wieder eine Rücksprungadresse, sichert einige Register und legt vielleicht auch einige lokale Variablen an. Da sämtliche zu sichernde Daten einfach immer auf den Stack geschoben werden und bei geschachteltem Aufruf von Routinen nicht mehr erkennbar ist, wo die letzte Rücksprungadresse liegt, gibt es den sogenannten Basepointer (EBP).
Dass dieser auch notwendig bei dem Anlegen von lokalen Variablen ist, soll nun geklärt werden. Der Basepointer als CPU-Register zeigt auf den Anfang des Stacks. So lassen sich auch die lokalen Variablen, die sich im Adressbereich darunter befinden, mit einem Offset plus dem Basepointer lokalisieren. Durch diese relative Adressierung wird im Programmcode nur mit dem festen Offset gerechnet.
Will man jetzt die Rücksprungadresse ausmachen, kann man natürlich darauf vertrauen, alle Daten wieder vom Stack zu nehmen bis der Stackpointer auf die entsprechende Adresse zeigt. Oder man befragt den Basepointer, da dieser immer auf die Adresse über der Rücksprungadresse zeigt.
In der unteren Abbildung zeigt der Basepointer auf eine Speicherzelle, die einen alten EBP hält. Dies ist eine äußerst wichtige Sache, dass der vorherige Basepointer beim Sprung in eine Routine gesichert wird.

Der Abbildung nach befindet sich der Prozess gerade in einem Unterprogramm und hat dabei lokale Variablen (Daten) deklariert und Register gesichert. Angenommen der Rücksprung soll erfolgen, dann werden die Daten in die Register zurückgeschrieben. Dabei rutscht der ESP nach unten. Als nächstes erreicht es das Feld alter EBP. Dieses Datum wird in das Register EBP geschrieben. Nun haben wir die Situation wie in der zweiten Abbildung. Nach dem ausgeführten Sprung in die aufrufende Prozedur, kann wieder mit ESP und EBP auf die lokalen Variablen dieser Routine zugegriffen werden oder bei einem erneuten Aufruf die Register gesichert werden.

Stackoperationen

Prinzipiell lässt sich jede Operation, die auf das Datensegment anwendbar ist, auch auf dem Stack ausführen, schließlich besitzt ein Prozess für beide Segmente read + write Zugriffsrechte. Üblich ist hier die Verwendung des Befehles mov, mit dem Daten von einer Speicherzelle oder einem Register zum anderen kopiert wird.
Da diese Form des Transfers nicht dem eigentlichen Konzept des Stacks entspricht, existieren noch die zwei wesentlichen Befehle push und pop. Mit ihnen werden die Register der CPU auf dem Stack gesichert und bei Bedarf wieder zurückgeschrieben. Dabei wird die Position, an der das Datum des Registers geschrieben wird nur durch den Stackpointer (ESP) bestimmt. Eine explizite Adressangabe ist mit push und pop nicht möglich. Wird ein push ausgeführt, d.h. ein Register auf dem Stack gerettet, wird der Stackpointer um eine Adresse verringert. Bzw. um vier Adressen, da die Register nun mal 32 Bit breit sind. Das Verringern lässt sich damit erklären, dass der Stack wie schon erwähnt zu den kleinen Adressen hin wächst. Umgekehrt verhält es sich, wenn ein Datum in ein Register zurückgeschrieben werden soll, dann wird die Adresse im ESP um vier erhöht und der Stack wird damit vom Dateninhalt her kleiner.
Die Abbildungen unten sollen den Vorgang des Rettens von Registern auf dem Stack noch ein wenig verdeutlichen. Das jeweils obere „Kästchen“ soll ein Auszug aus dem Stacksegment entsprechen. Mit Hilfe der beiden Pointer ESP und EBP ist der aktuelle Rahmen, in dem der Stack sich zur Zeit bewegt, eindeutig festgelegt. Auf der linken Seite sind zwei Standardregister EAX und EBX sowie ESP und EBP angezeigt. Im rechten Kasten ist andeutungsweise die gerade auszuführende Operation zu sehen.
Die Register EAX (0x001A) und EBX (0x007F) halten Werte (oberste Abbildung) und der Stack ist gerade angelegt worden bzw. es ist gerade gesprungen worden, da der Basepointer vom vorherigen Stackbeginn (0x1210) schon gesichert ist. In der folgenden Abbildung (mittlere Abbildung) wird hintereinander zwei push-Anweisungen ausgeführt. Als erstes wird der Wert von EAX, dann der von EBX auf den Stack geschoben. Es darf nicht vergessen werden, dass die Daten wie auf einem Stapel, daher ja auch der Name Stack, abgelegt werden. Was zu letzt geschrieben wurde, wird als erstes wieder gelesen. Man nennt dies auch LIFO, Last In First Out.

1.

2.

3.

Dass die Register EAX und EBX nun leer (0x0000) sind, ist natürlich nicht ganz richtig, es soll nur symbolisiert werden, dass sie jetzt für andere Operationen zur Verfügung stehen. Wie zu erwarten, blieb der Basepointer (EBP) unverändert, es wurde ja auch nicht neu gesprungen. Dafür hat sich jetzt der Stackpointer (ESP) gleich um acht Adressen nach unten bewegt. Es sei hier angenommen, dass es sich wie auf den heutigen Intel-CPU’s üblich, um 32 Bit (4 Byte) Worte handelt.
Nun sollte das Unterprogramm irgendwelche Operationen durchführen und den Sprung in die aufrufende Routine wagen. Doch zuerst werden die Register in den Ursprungszustand zurückversetzt (untere Abbildung). Allerdings wird hier zum besseren Verständnis nur das Register EBX mit dem Befehl pop vom Stack geholt. Entsprechend des einen pop’s wird die Adresse im ESP nur um vier Stellen nach oben gezählt. Nun zeigt der Pointer auf den Wert, der in das Register EAX gehört. Eine weitere pop-Operation würde auch diesen Wert in dem im Befehl angegebenen Register ablegen.

Überschreiben der Rücksprungadresse

Bis jetzt ging es in den vorstehenden Kapiteln nur um den prinzipiellen Aufbau und das Verhalten des Stacks. Deshalb soll nun auf die eigentliche Gefahr, die von dem Konzept des Stacks ausgeht, eingegangen werden.
Vorab soll beispielhaft auf dem Stack ein lokales Array a mit der Länge fünf angelegt werden. Der Datentyp entspricht einem DWORD mit 32 Bit Länge.

Wie rechts zu sehen, wird das lokale Array in einem Unterprogramm auf dem Stack angelegt, da die Rücksprungadresse des aufrufenden Prozesses unter dem Basepointer (EBP) liegt. Direkt darüber bzw. darunter, von den Adressen her betrachtet, liegt die Adresse, an der der vorherige Stack beginnt. Schon als nächstes kommt das Array.
Es ist hier zu beachten, dass trotz der Orientierung des Stacks zu den niedrigen Adressen hin, das Array in umgekehrter Reihenfolge angelegt und vom Programm beschrieben wird. Somit liegt das erste Element a[0] auf einer kleineren Adresse als das letzte a[4].
Dieses widersprüchliche Verhalten lässt sich wohl damit erklären, das lokale Variablen auf dem Stack genauso wie Daten im Datensegment geschrieben und gelesen werden. Natürlich von den kleinen zu den großen Adresse. Zugegriffen wird nicht mit push und pop, was ja auch gar nicht möglich wäre, da man mit ihnen nur abhängig vom ESP an die Daten gelangen kann, sondern mit dem Befehl mov. Als der elementarste Datentransferbefehl verbietet er uns natürlich nicht, auf dem Stack, unabhängig vom Stackpointer und seiner Position, Daten zu manipulieren.
Ein Array ist im wesentlichen nichts anderes als ein Pointer auf einen Datenbereich, der mindestens soviel freien Speicher wie im Array deklariert zur Verfügung stellt. Wird dieses Array nun befüllt, kann der Befehl mov nicht wissen, wie groß das Array ist, also wo die Grenzen liegen. Es liegt einzig und allein in der Verantwortung des Programmierers bzw. des Compilers, dass diese Grenzen eingehalten werden. Unter diesem Aspekt stellt es auch kein großes Problem dar, die Rücksprungadresse auf dem Stack derartig zu manipulieren, dass der Rücksprung an eine neue Adresse erfolgt.
Wird nun mit einem strcpy, memcpy oder auch durch explizite Zuweisung das Array a mit Daten gefüllt, gibt es keine Kontrolle über den tatsächlich zur Verfügung stehenden Platz im Array. Sollten die zu kopierenden Daten größer als das Array a sein, wird über das Ende hinaus geschrieben. In der Regel ist jeder Programmierer versucht, dieses zu vermeiden. Doch Bufferflowangriffe existieren aufgrund dieser Eigenschaften.
Angenommen, es sei bekannt, an welcher Stelle ein lokales Array auf dem Stack liegt. Ermittelt man dann die Distanz zu dem Basepointer und damit auch zu der davor stehenden Rücksprungadresse (RSP), ist es ein Leichtes, die Adresse mit einer neuen zu überschreiben.
Mit den folgenden Abbildungen sollte dieser Vorgang gut nachvollziehbar sein.

1.

2.

3.

In der ersten Darstellung ist der ursprüngliche Zustand zu sehen. Der Platz für das Array mit fünf Elementen ist reserviert und noch unbelegt, darunter befinden sich der alte EBP und die Rücksprungadresse, für die wir uns interessieren. Angenommen, das Programm macht eine Zuordnung folgender Art: a[0]=1; a[1]=2; a[2]=3; a[3]=4; a[4]=5;. Dann haben wir den Zustand wie in der folgenden Abbildung. Nun ist das Array komplett gefüllt und der Feldindex darf nicht weiter erhöht werden, ansonsten bewegt man sich außerhalb der Arraygrenzen.
Da dies aber genau das Ziel eines Bufferoverflows ist, werden einfach noch ein paar weitere Daten geschrieben: a[5]=6; a[6]=0x0012FF08; (Abb. 12). Schade ist nur, dass hierbei der alte EBP zerstört wird. Viel schlimmer ist allerdings, dass nun auch die Rücksprungadresse einen völlig anderen Wert darstellt. Bei näherer Betrachtung zeigt sie sogar auf das erste Element von a[0] im Stacksegment.
Von dieser bewussten Manipulation hat das Unterprogramm natürlich nichts mitbekommen und sucht den EBP, um damit auch die darunter liegende Rücksprungadresse zu ermitteln. Über die Qualität einer solchen Adresse kann der Prozess keine Aussage treffen und springt wie in diesem Fall auch an eine Stelle, die auf dem Stack liegt. Es wird dort das Datum gelesen und einfach als Befehl interpretiert und ausgeführt. Schließlich darf Code im Stacksegment ausgeführt werden.
In diesem Beispiel machen die Werte 1 bis 5 wohl nicht viel Sinn. Aber stattdessen kann man das Array ebenso gut mit Befehlen füllen, die dann entsprechend dem Gedanken des Programmierers solcher Overflows bestimmte Anweisungen ausführen.
Aus dem Beispiel in den Abbildungen wurde angenommen, dass das Array über eine direkte Zuweisung der einzelnen Felder erfolgt. Dies wird in der Praxis wohl kaum der Fall sein. Den gleichen Effekt erzielt man bei Einsatz der Stringkopierfunktionen strcpy, memcpy, ... Man hofft dann darauf, dass der zu übergebende String so groß ist, dass er einerseits das Zielarray überschreitet und dann auch noch die Speicherzelle mit der Rücksprungadresse erreicht. Möglichst auch noch mit der vorher berechneten Adresse.
Der Angreifer sucht sich nun ein Programm, welches einen String empfängt, z.B. einen Kommandozeilenparameter und formuliert in diesem String den auszuführenden Code und die neue Rücksprungadresse an den Beginn seiner Befehle.


Download

Die hier aufgeführten Programme dienen lediglich dem Verständnis des Aufbaus des Stacks. Wer sich intensiver mit dieser Problematik beschäftigen möchte, sollte unbedingt einen Debugger einsetzen und die Programme dort mal unter die Lupe nehmen.

Beispielprogramm "Ret"

Beispielprogramm "Exploit"