Basiswissen Buffer Overflow

14.05.2004 von Thomas Wölfer
Viele der aktuellen Exploits nutzen einen so genannten Buffer Overflow als Angriffsvektor. Wir beschreiben, wie ein Buffer Overflow entsteht, wie er sich auswirkt und wie Angreifer ihn ausnutzen können.

Jeder, der sich mit der Sicherheit von Computersystemen beschäftigt, stolpert über kurz oder lang über das Wort "Buffer Overflow". Der Begriff beschreibt eines der grundlegenden Probleme, die sich Dritte für einen Angriff auf ein Rechnersystem zu Nutze machen: Was aber steckt genau dahinter? In diesem Beitrag erfahren Sie alles, was man über Buffer Overflows wissen muss: wie sie entstehen, warum sie nicht verschwinden, wie Angreifer sie ausnutzen und welche Konsequenzen das hat.

Die Bezeichnung "Buffer Overflow" stammt aus der Programmierung und bezeichnet im Wesentlichen einen Programmfehler, bei dem ein Programmierer für bestimmte Daten weniger Speicher zur Verfügung stellt, als tatsächlich benötigt wird. Kommen die unerwartet vielen Daten an, so ist der zur Verfügung gestellte Puffer nicht ausreichend groß: Er läuft über.

Dieser Zustand kann für einen Einbruch ausgenutzt werden. Um zu verstehen wie, braucht man ein paar kleine Programmbeispiele. Die folgenden Beispiele sind dabei "C"-ähnlich beziehungsweise an x86-Assembler angelehnt.

Der Einstieg: Variablen

Zunächst muss man wissen, wie Daten in Variablen gespeichert werden. Bei einer Variablen handelt es sich im Wesentlichen um einen symbolischen Namen, der für einen Wert oder für eine Anzahl von Werten steht. Im Normalfall ist die kleinste beim Programmieren verwendete Einheit ein Byte - Variablen sind also symbolische Namen für ein oder mehrere Bytes.

Texte sind häufig vorkommende Daten. Text ist auch für dieses Beispiel besonders geeignet, weil Text innerhalb von Programmiersprachen oft direkt auf Bytes abgebildet wird. Ein einzelnes Zeichen nennt man dabei "Character", ein einzelner Character verbraucht im Allgemeinen ein einzelnes Byte.

Setzt sich ein Wort aus mehreren Zeichen zusammen, braucht man mindestens eine ebenso hohe Anzahl an Bytes zum Speichern des Wortes, wie das Wort Buchstaben hat. Ansammlungen aus Daten eines bestimmten Typs nennt man ein "Array". Bei einem Wort handelt es sich also um ein Array aus Bytes. Stattdessen sagt man bei Texten auch "Strings".

In der Sprache C, auf der ein Großteil des Windows- und Linux-Codes basiert, sind Strings immer mit einem besonderen Zeichen abgeschlossen: Am Ende eines Textes steht immer ein Byte, das den Wert 0 hat. Dadurch wird das Ende des Strings markiert.

Ein String wird also mit einem symbolischen Namen beschrieben und steht für eine bestimmte Anzahl an Bytes mit einer abschließenden "0". Arrays werden in C in eckigen Klammern markiert. Innerhalb der eckigen Klammer steht eine Zahl, die die Größe des Arrays angibt:

char aVariable[10];

Hier handelt es sich also um die Variable mit dem Namen aVariable. Die Variable ist ein Array der Größe 10 und hat den Typ char. Die Variable bietet also Speicherplatz für 10 Bytes.

Adressen von Variablen

Der Arbeitsspeicher des Computers wird auf unterschiedlichste Art und von verschiedenen Instanzen verwaltet. Der Einfachheit halber ist es aber ausreichend, sich den Speicher des Rechners als eine Menge von hintereinander angeordneten Zellen vorzustellen. Jede dieser Zellen hat dabei eine Hausnummer und kann jeweils ein einzelnes Byte aufnehmen. Die erste Zelle hat die Nummer 0, die zweite hat die Nummer 1 und so weiter. Die Nummer einer Zelle nennt man die "Adresse" der Zelle.

Man kann jeder Variablen auch eine Adresse zuordnen. Die Adresse einer Variablen ist dabei jeweils die Nummer der ersten Zelle, für die diese Variable zuständig ist. Jede Variable hat eine solche feste Adresse.

Angenommen die Variable aVariable hätte die Adresse 42, dann wäre das erste Zeichen eben in der 43. Speicherzelle abgelegt. Ferner würde die Variable auch für die folgenden neun Zellen zuständig sein. Die Bytes an Adresse 42 bis 51 würden also der Variablen aVariable "gehören".

Das Byte an Adresse 52 ist dann für eine andere Variable zu haben. Benötigt man für ein Programm zwei Variablen, würde man das in etwa wie folgt deklarieren:

char eineVariable[10];

char dieZweiteVariable[5];

Weiterhin davon ausgehend, dass die Variable eineVariable an der Adresse 42 beginnt, wäre die Adresse von dieZweiteVariable die 52.

Mit diesem einfachen Konzept ist es nun bereits möglich, einen einfachen Buffer Overflow zu verstehen. Zur Verdeutlichung werden nun die Variablen umbenannt.

Ein Beispielprogramm

Beim Beispielprogramm soll es sich um ein Programm zur Bearbeitung von Kontaktdaten handeln. Im Zuge des Programms werden die Namen und Vornamen von Personen gespeichert. Dazu werden zwei Variablen angelegt: "Name" und "Vorname". Namen sind meist recht kurz und daher bekommen die Variablen feste Längen. Für Namen und Vornamen stehen je 15 Bytes zur Verfügung.

char Name[ 15];

char Vorname[ 15];

Um einen Text in eine Variable zu kopieren, benutzt man den Befehl "String Copy", abgekürzt strcpy. Der Befehl erhält zwei Parameter. Der erste gibt die Quelle an, der zweite das Ziel des Kopiervorgangs. Das Ziel des Vorgangs wird als Adresse ausgedrückt - dazu kann man einfach den Namen der Variablen benutzen, da der Name für ihre Adresse steht. Um Namen und Vornamen eines Kontakts in eine Variable zu kopieren, verwendet man die folgenden Befehlszeilen:

strcpy( "Kofler", Name);

strcpy( "Heinz", Vorname);

Nun muss man sich den Inhalt des Arbeitsspeichers im Zuge der vier Programmzeilen vergegenwärtigen. Bevor die erste Programmzeile ausgeführt wird, liegt einfach nur unbelegter Speicher vor: Da noch nichts passiert ist, enthalten alle Speicherzellen mehr oder weniger zufällige Werte.

Dann folgen die beiden Programmzeilen, mit denen die Variablen festgelegt werden:

char Name[ 15];

char Vorname[ 15];

Je 15 Byte: Die maximale Größe der beiden Strings ist festgelegt.

Nun einmal angenommen, dass die beiden Variablen die ersten im Programm definierten sind. Dann hat Name die Adresse 0 und reicht bis zur Adresse 14, Vorname hat die Adresse 15 hat und belegt die folgenden 15 Bytes. Die Variablen wurden aber noch nicht mit Inhalten belegt, also enthalten die zugehörigen Speicherzellen auch nur zufällige Werte. Diese zufälligen Werte sind in der Abbildung mit einem X markiert. Die Adressen der einzelnen Zellen stehen jeweils über der Zelle.

Speicher wird beschrieben

Die nächste Programmzeile kopiert den Text "Kofler" in die Speicherzellen der Variablen Name. Da Strings in C eine abschließende 0 als Endemarkierung haben, wird also eine Zelle mehr belegt, als für den Wert "Kofler" eigentlich notwendig wäre.

strcpy( "Kofler", Name);

Als Resultat dieses Befehls sind nun also einige Speicherzellen mit "sinnvollen" Werten belegt. An Adresse 0 steht also der Buchstabe "K", an Adresse 1 steht das "o" und die folgenden Zellen enthalten den Rest des Namens, bis schließlich die Zelle an Adresse 6 die abschließende "0" enthält. Im letzten Schritt des Programms wird nun der Vorname in die zugehörige Variable kopiert.

strcpy( "Heinz", Vorname);

Alles in Ordnung: Die beiden Strings sind nicht länger als erlaubt.

Das führt also nun zu diesem Aufbau des Arbeitsspeichers:

Kurz rekapituliert: Für den Namen wurden 15 Bytes vereinbart, denn das char-Array der Variable Name wurde als ein Array mit 15 Elementen angelegt. Erst nach diesen 15 Bytes steht Platz für die nächste Variable zur Verfügung. Die Adresse von Vorname liegt also bei Byte 16 an Adresse 15 und darum wird das erste Zeichen des Vornamens auch an diese Adresse kopiert.

Verarbeitung der Variablen

Mit diesem Speicheraufbau kann das Programm nun weiterarbeiten. Angenommen, es soll nun den Namen und den Vornamen ausgeben: Dazu verwendet man die Funktion puts. Diese bekommt als Parameter die Adresse des gewünschten Strings. Die Funktion schreibt den String dann bis zu seinem Ende auf den Bildschirm. Das Ende des Strings erkennt die Funktion anhand der abschließenden Null. Um Namen und Vornamen auszugeben, würde man die folgenden beiden Zeilen verwenden:

Puts( Name);

Puts( Vorname);

Puts liest das Zeichen das an der Adresse von Name liegt und überprüft, ob es sich dabei um eine "0" handelt. Ist das nicht der Fall, so stellt sie das Zeichen am Bildschirm dar. Die Adresse von Name, also die Adresse des ersten Zeichens, ist die 0. Wichtig: Nicht den Wert, der an einer Adresse liegt, mit der Adresse verwechseln. Es hilft hier wirklich, sich die Adressen als Hausnummern vorzustellen.

Puts ermittelt nun die nächste Adresse, indem es aktuelle Adresse um 1 erhöht. Daraus resultiert die Adresse 1. Das Zeichen an dieser Adresse ist ein "o" und wird also ausgegeben. Kommt Puts dann bei Adresse 6 an, findet es eine Null und hört mit seiner Arbeit auf. Am Bildschirm steht dann:

Kofler

Der zweite Aufruf von Puts arbeitet analog und verwendet als erste Adresse die von Vorname. Am Bildschirm steht dann:

KoflerHeinz

Nachdem die von Zufallswerten belegten Zellen immer nur hinter den abschließenden Null-Werten stehen, werden diese - ebenso wie die Null-Werte selbst - nicht ausgegeben.

Das Problem: Der Puffer ist zu klein

Nun gibt es dummerweise Namen, die länger als 15 Zeichen sind. Wenn also beispielsweise der komplette zu bearbeitende Name "Heinz Kofler-Angermannsdorf" lautet, dann verändert das natürlich das Programm. Das Programm mit dem neuen Namen sieht wie folgt aus:

char Name[ 15];

char Vorname[ 15];

strcpy( Name, "Kofler-Angermannsdorf");

strcpy( Vorname, "Heinz");

Puts( Name);

Puts( Vorname);

Soweit alles OK: Zwei Variablen a 15 Byte sind deklariert.

Der interessante Teil der Veränderung befindet sich in der dritten Programmzeile. Hier wird der "neue" Nachname in die Variable kopiert. Man muss sich erneut vergegenwärtigen, wie der Speicherinhalt nach den einzelnen Zeilen aussieht. Bis zum ersten strcpy-Befehl tut sich nicht viel: Es gibt 30 Bytes Speicherplatz, die für zwei Variable zur Verfügung stehen und zufällige Werte enthalten:

Dann folgt aber die Zeile mit dem strcpy-Befehl:

strcpy( Name, "Kofler-Angermannsdorf");

Diese Zeile kopiert also nun die Zeichen des Namens in den Speicher, der ab der Adresse von Name beginnt. Danach sieht der Speicher wie folgt aus:

Achtung: Der String endet mitten in der zweiten Variable.

Der Speicher, der der Variablen Name zur Verfügung steht, ist zu klein. Das weiß der strcpy-Befehl jedoch nicht, denn der kennt ja nur die Adresse für das erste Zeichen und kopiert dann wie vom Programmierer verlangt alle anderen Zeichen des Namens in die folgenden Speicherzellen. Das Resultat ist verheerend: Die Zeichen überschreiben Speicher, der eigentlich der Variablen Vorname gehört.

Überschriebener Speicher = kaputte Daten

Genau wie es zuvor gleichgültig war, dass der Name "Kofler" kürzer als die festgelegten 15 Zeichen war, ist es an dieser Stelle gleichgültig, dass der Name länger ist: Da das Programm nur mit Adressen arbeitet, an die dann Zeichen kopiert werden, gibt es keinen Schutz vor derartigen Fehlern. Der Fehler hat aber zunächst keinerlei Auswirkungen. Würde man nun zum Beispiel den Inhalt der Variablen Name mit

Puts( Name);

ausgeben, dann würde die Puts()-Funktion ganz wie erwartet den Text

Kofler-Angermanssdorf

 

am Bildschirm anzeigen. Die Adresse von Name ist völlig in Ordnung und ab dieser Adresse beginnt auch ein String der völlig ordnungsgemäß mit einer 0 abgeschlossen wurde. Aus Sicht von Puts() ist damit alles in bester Ordnung - dass einige der Zeichen sich de facto im Speicherbereich einer anderen Variablen befinden, tut der Sache keinen Abbruch.

Das Beispielprogramm ruft nun aber nicht Puts() auf. Stattdessen führt es einen weiteren Aufruf von strcpy() aus, mit dem der Vorname kopiert wird. Vorname hat die Adresse 15 und ab dieser Adresse platziert strcpy() dann auch die Zeichen des Vornamens. Nach dem zweiten strcpy() sieht der Speicherinhalt also wie folgt aus:

Zeichensalat: Nun wird je nach ausgegebener Variable nur noch Unsinn angezeigt.

Der zweite Aufruf von strcpy() überschreibt also Teile des Namens. Auch das ist aber genau das, was im Programm angeordnet wurde, denn das sagt ganz klar aus: Schreibe die Zeichenfolge "Heinz" in den Speicher und beginne mit der Adresse 15.

Name: Nicht erkennbar

Abgesehen davon, dass Teile des Namens überschrieben wurden, hat diese Sache aber noch einen zweiten Effekt: Im Zuge des Überschreibens liegt die abschließende Null des Namens hinter der des Vornamens. Dadurch ist das tatsächliche Ende des Namens für die Funktion Puts() nicht mehr zu erkennen. Der folgende Aufruf von

Puts( Name);

hat nun also gar keine andere Wahl, als alle Zeichen bis zur Null an Adresse 20 auszugeben. Am Bildschirm erscheint also der Text:

Kofler-AngermanHeinz

Es werden also der teilweise überschriebene Name und der komplette Vorname angezeigt. Darauf folgt noch ein weiterer Aufruf von Puts(), der den Vornamen - also die Zeichen ab Adresse 15 - ausgibt. Das Wort "Heinz" wird folglich nochmals ausgegeben: Am Bildschirm steht dann schließlich:

Kofler-AngermanHeinzHeinz

Ein Buffer Overflow kann also sehr merkwürdige Ergebnisse in einem Programm auslösen.

Buffer Overflow als Angriffsvektor

Nachdem Sie nun wissen, was ein Buffer Overflow eigentlich ist, stellt sich nun die Frage: Wie kann so etwas von einem Angreifer ausgenutzt werden?

Ein Programm, das eine Aufgabe erfüllt, ist umfangreicher als das bisher vorliegende Beispielprogramm, das gerade mal über 6 Zeilen Quellcode verfügt. Wenn man sich diese 6 Zeilen genauer ansieht, stellt man auch fest, dass sogar diese eine ganze Menge an Funktionalität haben, von der man zunächst nicht erklären kann, woher sie stammt. So wird beispielsweise eine Funktion mit dem Namen strcpy und eine andere mit dem Namen Puts aufgerufen.

Beide wurden aber nicht selbst programmiert, sondern - im Fall von C - vom Hersteller des Compilers in Form von Bibliotheken zur Verfügung gestellt. Man kann Funktionen auch selbst programmieren. Das ist praktisch, denn damit ist man in der Lage, Programmfunktionalität in kleinere, übersichtlichere Päckchen aufzuteilen.

Bei der Gelegenheit könnte man auch gleich den Hauptteil des Programms in einer Funktion verpacken und die einzelnen Teilaufgaben in separaten Funktionen unterbringen. Diese Hauptfunktion hat in C den Namen main. Eine derartig aufgeschlüsselte Version des bisherigen Beispielprogramms könnte dann in etwa wie folgt aussehen:

Main() {

char Name[ 15];

char Vorname[ 15];

CopyName( Name);

CopyVorname( Vorname);

PrintName( Name);

PrintVorname( Vorname);

}

CopyName( char* Name) {

strcpy( "Kofler", Name);

}

CopyVorname( char* Vorname) {

strcpy( "Heinz", Vorname);

}

PrintName( char* Name) {

Puts( Name);

}

PrintVorname( char* Vorname) {

Puts( Vorname);

}

Das so aufgeteilte Programm erfüllt dieselbe Aufgabe wie das ursprüngliche Programm. Der Hauptteil besteht nun im Wesentlichen daraus, dass dort die selbst definierten Funktionen nacheinander aufgerufen werden.

Puffer-Schutz

Um die Sache wieder ein wenig übersichtlicher zu gestalten, wird im Folgenden neben dem Hauptteil nur noch eine der neuen eigenen Funktionen betrachtet: Die Funktion CopyName.

Diese Funktion erweitern wir nun ein wenig, um dem Buffer-Overflow-Problem aus dem Weg zu gehen: Zunächst wird der Name in einen temporären Puffer kopiert und dort die Länge des Strings überprüft. Ist der Text länger als die 15 zur Verfügung stehenden Zeichen, so gibt die Routine eine Fehlermeldung aus und kopiert den String nicht. Die Länge eines Textes liefert die Funktion strlen().

Damit das Problem des Pufferüberlaufs bei der temporären Variablen nicht auftreten kann, erhält sie mehr Speicher und wird 250 Bytes lang. Sieht man zunächst von böswilligen Angriffen ab, ist dies für einen Namen auf jeden Fall ausreichend.

CopyName( char* Name) {

char temporaer[ 250];

int laenge;

strcpy( "Kofler", temporaer);

laenge = strlen( temporaer);

if( laenge > 15) Puts( "Warnung: Der Name ist zu lang");

else strcpy( "Kofler", Name);

}

Funktionsweise von Funktionen

Jetzt muss man noch etwas mehr darüber wissen, wie Funktionen eigentlich intern funktionieren. Der - etwas gekürzte - Aufbau des Beispielprogramms ist dazu als Grundlage für eine Erklärung ganz gut geeignet:

Main() {

char Name[ 15];

CopyName( Name);

Puts( Name);

}

CopyName( char* Name) {

strcpy( "Kofler", Name);

}

In der gekürzten Variante gibt es nur noch die Variable Name und eine Funktion, die diese Variable mit dem Text Kofler beschreibt. Danach wird der Inhalt der Variable mit Puts() ausgegeben.

In diesem Zusammenhang stellt sich eine Frage: Wenn die Funktion CopyName ihre Arbeit erledigt hat - woher weiß der Computer dann, an welcher Stelle er mit dem Programm weitermachen soll?

Wichtig: Die Rücksprungadresse

Jede Quellcodezeile wird beim Kompilieren eines Programms in eine Reihe von CPU-Instruktionen übersetzt, die dazu führen, dass die CPU die gewünschte Arbeit erledigt. Da die CPU immer nur mit Daten arbeiten kann, die sich in der CPU selbst (im Gegensatz zum RAM) befinden, müssen außerdem alle für eine bestimmte Aufgabe benötigten Daten vorher in die CPU kopiert und nach der Aufgabe zurück kopiert werden. Den Speicherplatz der CPU, der für solche Arbeiten zur Verfügung steht, nennt man Register. Dabei ist die Anzahl der Register begrenzt und die Register haben feste Namen. Das erste Register heißt zum Beispiel AX, das zweite BX, der dritte CX und so weiter.

Hat man beispielsweise eine Ganzzahl-Variable an der Adresse 12, die den Wert 5 hat und deren Wert um eins erhöht werden soll, so sähe der zugehörige Quelltext wie folgt aus:

int variable = 5;

variable = variable + 1;

Werden diese Quellcodezeilen nun kompiliert, dann sieht die von Menschen lesbare Form der binären CPU-Instruktionen in etwa wie folgt aus:

Mov #5, 12

Mov 12, AX

Inc AX, #1

Mov AX, 12

Das bedeutet Folgendes:

Mov #5, 12: Schreibe den Wert 5 an die Adresse 12.

Mov 12, AX: Schreibe den an Adresse 12 gespeicherten Wert in das AX Register. Im AX-Register steht dann der Wert 5.

Inc AX, #1: Erhöhe den Wert im AX Register um 1. Im AX-Register steht dann der Wert 6.

Mov AX, 12: Schreibe den Wert aus dem AX-Register an die Adresse 12. An Adresse 12 (also in der Variable) steht dann der Wert 6.

Die binäre Repräsentation dieser Instruktionen befindet sich natürlich auch im RAM des Computers und wird Schritt für Schritt zur Ausführung in die CPU kopiert. Das bedeutet natürlich, dass auch jede dieser Instruktionen eine Adresse im RAM hat - soweit es den Speicher betrifft, sind Instruktionen also nicht von den Daten zu unterscheiden.

Für die Ausführung der Instruktionen gibt es nun wieder einen speziellen Speicher in der CPU, den Befehlszähler. Dieser enthält die Adresse der nächsten auszuführenden Anweisung. Die Anweisung wird dann von dieser Adresse in die CPU kopiert und ausgeführt. Sofern dabei nichts Besonderes passiert, wird dann der Wert der nächsten Adresse ermittelt und die nächste Anweisung wird in die CPU transferiert. Die CPU führt dann die nächste Anweisung aus. Wie eine CPU exemplarisch funktioniert, finden Sie im Artikel "So funktioniert ein Prozessor" näher beschrieben.

So wird zurück gesprungen

Der Befehlszähler kann aber auch durch spezielle Sprungbefehle direkt beeinflusst werden. Statt einfach die Anweisungen so, wie sie im Speicher stehen, nacheinander auszuführen, springt das Programm dabei an eine völlig andere Stelle im Speicher, und arbeitet dort die Anweisungen der Reihe nach ab - bis wieder ein Sprung-Befehl kommt. Die von Menschen lesbare Form hat in etwa das folgende Aussehen:

JMP 123

Dabei würde der Programmcode jetzt ab Adresse 123 abgearbeitet. Mit diesem Mechanismus werden Funktionsaufrufe, die sich im Quellcode befinden, im binären Code abgebildet.

So hat die erste Instruktion der Funktion main() beispielsweise eine bestimmte Adresse - zum Beispiel 1000. Ebenso hat die erste Instruktion der Funktion CopyName() eine Adresse - zum Beispiel die 1400.

Damit ist nun klar, wie der Fluss des Programms bei Funktionen stattfindet: Der Compiler fügt bei Funktionsaufrufen einfach Sprungbefehle zu den Adressen der aufgerufenen Funktionen ein. Das klärt aber noch nicht, wie das Programm am Ende einer Funktion wieder an die Stelle zurückfindet, von der sie aufgerufen wurde.

Um die Antwort auf diese Frage zu klären, müssen Sie noch ein letztes Stück Hintergrundwissen auf dem Weg zum Verständnis von Buffer-Overflow-Attacken sammeln. Es geht um einen bestimmten Speicherbereich eines Programms: Den Stack.

Für ein Programm sind nicht alle Daten gleich: Allein zum Speichern gibt es zwei unterschiedliche Bereiche, den Stack und den Heap. Den Heap können Sie zunächst einmal getrost vergessen - der hilft bei der Lösung der Ausgangsfrage nicht weiter.

Wichtiger Speicher: Der Stack

Der Stack ist für den Programmablauf sehr wichtig - und zwar für das Ablegen bestimmter Variablen und für den Programmfluss. Beim Stack handelt es sich um einen Speicherbereich, der eine relativ einfache Verwaltung hat: Wie bei einem Stapel Karten, werden die Daten immer oben auf den Stack aufgelegt. Man kann immer nur das oberste Element vom Stapel wieder herunternehmen: Will man ein darunter liegendes Element haben, muss man zuvor alle darüber befindlichen Elemente herunternehmen. Für die jeweils aktuelle Speicheradresse des obersten Elementes des Stacks gibt es in der CPU extra ein eigenes Register: Den Stack-Pointer "SP".

Soll ein neues Datum auf den Stack gelegt werden, dann wird zunächst die Adresse ermittelt, auf den der Stack-Pointer momentan zeigt. An diese Adresse wird das Datum dann kopiert. Danach wird der Stack-Pointer um die entsprechende Anzahl Bytes erhöht. Danach zeigt er also auf den nächsten zu verwendenden Speicherplatz.

Mit diesem Mechanismus werden auch Parameter an Funktionen übergeben. Angenommen, Sie haben zwei Funktionen: Die erste erhält als Parameter einen Ganzzahlwert und die zweite ruft die erste auf:

ErsteFunktion( int wert) {

}

ZweiteFunktion() {

ErsteFunktion (4);

}

In einem solchen Fall passiert im Binärcode folgendes:

Zunächst wird der aktuelle Wert des SP ausgelesen und an diese Adresse der der Wert 4 geschrieben.

Der SP wird um die Byte-Größe einer Ganzzahl erhöht.

Dann wird ein JMP an die Adresse von ErsteFunktion ausgeführt.

ErsteFunktion weiß, dass sie einen Integer-Wert als Parameter erhält und holt diesen vom Stack, indem sie den SP ausliest, ihn um die Byte-Größe eines Integers vermindert und ab dieser Adresse die Daten ausliest.

Da das oberste Elemente (der Integer) dabei vom Stack genommen wurde, wird der SP wieder runtergesetzt und zeigt nun auf die Adresse, an die er gezeigt hat, bevor der Integer auf den Stack geschrieben wurde.

Lokale Variablen liegen im Stack

Der Stack hat noch eine weitere Funktion: Dort wird der Speicherplatz für lokale Variable zur Verfügung gestellt. Lokale Variable sind solche, die nur innerhalb einer Funktion verwendet werden. Im Beispiel

CopyName() {

char temporaer[ 10];

// ...und so weiter

}

handelt es sich bei der Variablen temporaer um eine solche lokale Variable. Der Speicher für temporaer befindet sich also im Stack. Beim Einsprung in die Funktion merkt sich der Binärcode zunächst den aktuellen Wert des SP. Das ist die dann die Adresse von temporaer. Da die Variable 10 Bytes belegt, wird der SP dann um 10 erhöht: So ist genug Platz für die Variable. Danach kann die Funktion abgearbeitet werden.

Nun kommt der Trick: Nach dem Aufruf einer Funktion soll das Programm ja mit der nächsten Instruktion nach dem JMP weiter ausgeführt werden. Diese Instruktion hat natürlich auch eine Adresse: Man nennt dies die Rücksprungadresse.

Was nun einfach passiert ist, dass diese Adresse vor dem Aufruf einfach auf den Stack geschoben wird. Hat die aufgerufene Funktion Parameter, werden diese zusätzlich auf dem Stack abgelegt. Dann wird die Funktion per JMP aufgerufen.

In der Funktion angelangt, nimmt der Binärcode alle erwarteten Parameter - nicht aber die Rücksprungadresse - vom Stack. Der SP zeigt dann auf die Rücksprungadresse im Stack. Danach wird der SP für die lokalen Variablen der Funktion erhöht. Man hat dann also in etwa das folgende Bild im Speicher:

Stack: Ein theoretischer Stack mit Rücksprungadresse, lokaler Variable und Stackpointer (SP).

Im Beispiel liegt der Speicher für die lokale Variable im Bereich zwischen den Adressen 11 und 20. Es gibt also 10 Bytes Platz für die lokale Variable.

Buffer-Overflow: So passierts

An dieser Stelle ist es notwendig, einen geistigen Klimmzug zu veranstalten, denn die bisher dargestellten Graphiken sind nur in der Theorie korrekt. In der Praxis sieht die Sache ein klein wenig anders aus. Tatsächlich ist es nämlich so, dass die Adressen des Stacks von oben nach unten verlaufen. Neue Elemente, die auf den Stack gelegt werden, haben also kleinere Adressen als die zuvor auf den Stack gelegten. Dieses Faktum ist der ganze Knackpunkt, der Buffer Overflows so gefährlich macht.

Einmal angenommen, der Stack hat eine Gesamtgröße von 30 Bytes. Die Daten werden dann in der eingehenden Reihenfolge nach unten hin in diesen Speicherbereich geschrieben. Das Bild, das sich dadurch aus dem vorhergehenden Beispiel ergibt, ist ein völlig anderes, denn das Speicherlayout sieht dann wie folgt aus:

Wichtig: In der Praxsi wächst der Stack nicht nach rechts sondern nach links.

Wenn nun Daten in den Puffer für die lokale Variable geschrieben werden, dann wird der Puffer zunächst ab Adresse 9 voll geschrieben. Folgende Daten für lokale Variable landen dann in Bytes 10, 11, 12 und so weiter in aufsteigender Reihenfolge. Mit anderen Worten: In der Abbildung würden die Daten von links nach rechts in den Puffer geschrieben. Am rechten Rand des Puffers für die lokale Variable befindet sich aber die Rücksprungadresse!

Gibt es also bei den lokaler Variablen einen Buffer Overflow - werden also mehr als 10 Bytes ab der Anfangsadresse der lokalen Variable in deren Puffer kopiert -, dann wird dadurch die Rücksprungadresse überschrieben. Diesen Wert verwendet der Binärcode dann zur Laufzeit für den Rücksprung. Mit anderen Worten: Das Programm wird an einer völlig anderen Stelle weitergeführt, als es das eigentlich sollte.

Im Fall eines normalen Programmierfehlers handelt es sich dabei um einen rein zufälligen Wert: Die CPU liest dann den an dieser Adresse befindlichen Wert und versucht ihn als eine Instruktion zu interpretieren und diese auszuführen. Im Regelfall wird das aber nicht gehen: Das Programm wird an dieser Stelle - oder spätestens ein paar Instruktionen später - abstürzen.

Um damit haben Sie genau den Fall, den ein Angreifer ausnutzen kann, um ein Rechnersystem anzugreifen.

So wird angegriffen

Angenommen ein Angreifer weiß, dass sich in einem Programm ein Fehler befindet, der durch geschicktes Ausnutzen zu einem Buffer Overflow führt. Weiterhin angenommen, der Angreifer kann dieses Programm ganz nach Wunsch selbst mit Daten bestücken: Das ist zum Beispiel immer dann der Fall, wenn es sich um ein Server-Programm handelt oder um irgendeine Komponente, die mit Daten von außerhalb bestückt wird.

Ein ganz einfaches Beispiel ist ein Web-Browser: Der Web-Browser wird auf verschiedene Arten mit Daten von außen versorgt. Zum Beispiel dadurch, dass der Anwender auf einen Link auf einer Webseite klickt. Hinter einem Link befinden sich Informationen, mit deren Hilfe der Browser ein neues Ziel annavigieren kann. Befindet sich im Web-Browser ein Fehler beim Interpretieren dieser Daten, so kann ein bösartiger Betreiber einer Webseite diese Daten so zusammenstellen, dass ein Buffer Overflow im Web-Browser auftritt.

Will der bösartige Betreiber den Browser eines Besuchers nur abstürzen lassen, so genügt es, den Link umfangreicher zu gestalten als es der Browser verkraftet: Ein Klick auf den Link führt dann unweigerlich zum Absturz des Browsers.

Mit etwas mehr Geschick und Aggressivität, kann der Betreiber der Website aber durchaus mehr tun. So könnte er zum Beispiel die Daten hinter dem Link so organisieren, dass sie die Rücksprungadresse mit einem Wert überschreiben, der nicht zufällig irgendwo hin zeigt, sondern auf eine Stelle im Speicher, wo ausführbarer Code liegt. Diesen würde der Browser ganz normal ausführen - nur würde es sich dabei eben um Instruktionen handeln, die der Angreifer ausgesucht hat.

Mit anderen Worten: Der Angreifer könnte beliebigen, von ihm ausgesuchten Code auf dem Rechner des Surfers ausführen. Damit erhält der Angreifer praktisch alle Möglichkeiten und Privilegien auf dem Rechner des Surfers, die der Surfer selbst auch besitzt. Der Angreifer hätte damit die Kontrolle über den Rechner des Surfers de facto übernommen.

Ausführen beliebigen Codes

Stellt sich nun die Frage, wie der Angreifer den gewünschten Code auf den Rechner des Opfers bekommt - das ist zumindest in der Theorie leicht zu beantworten. In der Praxis sieht die Sache jedoch deutlich komplexer aus. Ein einfacher Hinweis auf eine (wenn auch in der Praxis nicht mögliche) Variante soll hier genügen. Wir wollen ja keine Hacker-Anleitung bieten, sondern nur einen technischen Überblick verschaffen.

Man muss man sich einfach vergegenwärtigen, wie der Angreifer den Buffer-Overflow-Fehler im Browser ausgenutzt hat: Er hat einen ganz normalen Mechanismus zum Bestücken des Browsers mit Daten verwendet - und diese Daten nach seinem Wunsch zusammengestellt. Davon ausgehend hält niemand den Angreifer davon ab, noch mehr Daten auf dem gleichen Weg an den Browser zu senden. Ein Teil davon ist für das Auslösen des Buffer Overflows zuständig, ein kleiner weitere Teil ist dafür zuständig, die Rücksprungadresse so zu manipulieren, wie es der Angreifer wünscht - und der letzte Teil enthält den auszuführenden Code, der nach dem erfolgreichen Eindringen in das angegriffene System ausgeführt werden soll.

Fazit

Gegen Buffer Overflows kann man als Anwender oder Administrator von Fremd-Software nur wenig machen, außer ständiger Vorsicht. Also nur vertrauenswürdige Web-Seiten aufsuchen, alle nicht zwingend benötigten Ports schließen und nur Dokumente aus vertrauenswürdigen Quellen öffnen.

Angriffe auf Server kann man teilweise verhindern, indem man Proxies einsetzt, die potenziell gefährliche Abfragen abfangen und gar nicht erst zum eigentlichen Server gelangen lassen. Darüber hinaus können Sie deb CW-Security-Newsletter bestellen, der Sie über neu entdeckte Sicherheitslücken und mögliche Gegenmaßnahmen auf dem Laufenden hält.

Programmierer sollten ein genaues Auge darauf haben, dass in einen Puffer nur so viele Bytes kopiert werden, wie auch tatsächlich hineinpassen. Sich also nicht darauf verlassen, dass beispielsweise ein eingehender String tatsächlich nur so lang ist, wie irgendwelche Spezifikationen es vorsehen. Stattdessen sollte man die paar Taktzyklen investieren, die eine Überprüfung braucht. Das gilt ganz besonders für Applikationen, die Daten von außen entgegennehmen - sei es durch Dokumente oder als Request über das Netzwerk. (lex)

Kostenloser Security-Newsletter