Das Sicherheitsloch

Buffer-Overflows und wie man sich davor schützt

Wissen | Know-how

Fast täglich kann man in den einschlägigen Mailinglisten und Security-Online-Archiven Nachrichten von neuen Einbruchsmöglichkeiten und Verwundbarkeiten in Applikationen oder Betriebssystemen lesen. In vielenl Fällen ist die Ursache ein Pufferüberlauf, englisch Buffer-Overflow. Mit diversen Tricks können Hacker solche Buffer-Overflows nutzen, um in ein System einzubrechen.

Aufmacher

Verfolgt man die Sicherheits-Mailinglisten, könnte man glauben, dass es kaum ein Programm gibt, welches nicht durch einen Buffer-Overflow angreifbar ist. Als aktuelles und wohl bekanntestes Beispiel sei hier Microsofts Webserver IIS angeführt, bei dem Code Red einen Pufferüberlauf ausnutzte, um das System zu befallen. Weniger bekannt ist dabei, dass auch der Wurm selbst durch eine Buffer-Overflow-Attacke verwundbar ist.

Doch nicht nur Windows-Software hat mit solchen Sicherheitsproblemen zu kämpfen. In der Unix-Welt erlangten der Nameserver BIND und der FTP-Server wu-ftpd durch Pufferüberläufe und damit verbundene Einbrüche traurige Berühmtheit. Selbst Sicherheits-Software ist nicht davor gefeit: Anfang September schreckte die Veröffentlichung eines Buffer-Overflows in der Network Associates Gauntlet Firewall die Internet-Gemeinde auf. Dabei entpuppte sich der Mail-Proxy csmap, der den eigentlichen Mail-Server vor Angreifern aus dem Internet schützen soll, selbst als Sicherheitsrisiko.

Doch obwohl die ersten Angriffe dieser Art schon 1988 stattgefunden haben, liegt immer noch ein mystischer Schleier über diesem Thema. Was passiert wirklich bei einem Buffer-Overflow? Wie können Hacker einen solchen Buffer-Overflow ausnutzen? Gibt es Möglichkeiten, diese Art des Systemeinbruchs zu verhindern?

Warnungen vor Buffer-Overflows
Vergrößern
Solche Warnungen vor Buffer-Overflows gehören für Netzwerk-Admins zum traurigen Alltag.

Für einen Buffer-Overflow ist letztlich immer der Programmierer der Anwendung oder des Systems verantwortlich. Jedes Programm legt zur Laufzeit lokale Variablen, Übergabeparameter für Funktionen sowie Rücksprungadressen für Unterprogramme im Arbeitsspeicher ab. Dieser spezielle Bereich wird als Stack bezeichnet und ist durch das Betriebssystem nicht vor ungewollten Änderungen geschützt. Schreibt das Programm beispielsweise eine zu lange Zeichenkette in eine lokale Puffervariable, überschreibt es dabei die darauf folgenden Variablen und unter Umständen auch die Rücksprungadresse - es kommt zu einem Buffer-Overflow.

Besonders häufig tritt dieser Fall bei Programmen auf, die in der Programmiersprache C geschrieben sind. Sie bietet Funktionen wie strcpy() für das Kopieren eines Strings, die nicht kontrollieren, ob der Speicherbereich an der Zieladresse groß genug für die Daten ist. Ist dieser Puffer zu klein, überschreibt die Kopierfunktion gnadenlos die folgenden Datenfelder. Viele Programmierer versäumen immer noch, stattdessen die Funktion strncpy() zu verwenden, bei der man die Zahl der zu schreibenden Zeichen begrenzen kann.

In den meisten Fällen führt ein Buffer-Overflow zum Absturz des betroffenen Programms. Entweder, weil Variablen mit unsinnigen Werten überschrieben wurden, sodass das Programm nicht wie erwartet funktioniert, oder weil die Rücksprungadresse nicht mehr stimmt. Landet an deren Stelle ein quasi zufälliger Wert, springt das Programm nach dem Beenden der Funktion an eine zufällige Speicheradresse und meldet einen ‘Speicherzugriffsfehler’.

Viel schlimmer ist es jedoch, wenn diese Rücksprungadresse auf speziell präparierte Speicherbereiche verweist, die ‘sinnvollen’ Code enthalten. Gelingt es einem Angreifer - zum Beispiel über einen Eingabestring - eigene Anweisungen auf dem Stack abzulegen, kann er durch gezielte Manipulation der Rücksprungadresse dafür sorgen, dass dieser angesprungen und ausgeführt wird: Er hat einen ‘Exploit’ entwickelt.

Zum Verständnis, wie solche Exploits funktionieren, ist ein kleiner Exkurs in die Speicherverwaltung notwendig. Die nachfolgenden Beispiele beziehen sich auf die i386-Architektur, lassen sich aber prinzipiell auch auf andere Prozessorarchitekturen übertragen.

Der Prozessor ist eine Recheneinheit, ergänzt um Register. Die Register kann man sich als interne Speicherstellen vorstellen, welche über symbolische Namen (EAX, EIP, ...) angesprochen werden. Die meisten dienen als allgemeine Zwischenspeicher für Rechenoperationen, Parameterübergabe und so weiter. Doch einige dieser Register erfüllen spezielle Aufgaben. Dazu gehören insbesondere der Instruction Pointer (EIP), der Base Pointer (EBP) und der Stack Pointer (ESP). Das vorangestellte ‘E’ steht für Extended und unterscheidet die 32-Bit-Register von ihren 16 Bit großen Pendants.

Der Instruction Pointer zeigt immer auf die Adresse des nächsten auszuführenden Maschinenbefehls. Nachdem der Prozessor eine Anweisung abgearbeitet hat, lädt er den nächsten Befehl von dieser Adresse und setzt den Zeiger weiter. Beim Verzweigen in eine Unterfunktion überschreibt das Programm den Instruction Pointer mit deren Startadresse. Base Pointer (EBP) und Stack Pointer (ESP) beschreiben den lokalen Speicherbereich einer Unterfunktion.

Heap und Stack zur Laufzeit des Programms
Sowohl der Heap als auch der Stack können zur Laufzeit des Programms wachsen.

Bei modernen Betriebssystemen arbeitet jedes Programm in einem eigenen, virtuellen Adressraum, dessen Adressen die Hardware - konkret die Memory Management Unit (MMU) - erst bei Bedarf ‘echten’ Speicher zuordnet. In diesem legt das Betriebssystem beim Start eines Programms drei so genannte Segmente an: das Code-Segment, das Daten-Segment (auch Heap genannt) und das Stack-Segment. ‘Unten’ im Adressraum - also an den niedrigen Adressen - befindet sich das Code-Segment mit den eigentlichen Maschinenbefehlen des Programms. Es ist in Größe und Inhalt fest und zumeist gegen Überschreiben geschützt. Das heißt, ein Schreibversuch auf diese Adresse produziert eine Speicherschutzverletzung (‘segmentation violation’).

Globale Daten und Konstanten legt das System im Daten-Segment ab, das direkt ‘über’ dem Code-Segment liegt. Dort reserviert es auch Platz für Speicherbereiche, die das Programm zur Laufzeit mit dem Systemaufruf malloc() anfordert. Deshalb kann der Heap zur Laufzeit nach ‘oben’ wachsen.

Das Stack-Segment ist ein Zwischenspeicher für lokale Variable und gesicherte Prozessorregister, die das Programm zu einem späteren Zeitpunkt wieder benötigt. Der Stack beginnt am oberen Ende des Adressraums und wächst nach ‘unten’. Er funktioniert als Last-In-First-Out-Puffer, ähnlich wie ein Stapel, den man erst abräumen muss, bevor man an früher abgelegte Daten kommt.

Die beiden Basisoperationen für den Stack sind ‘push’ und ‘pop’.

push %eax

sichert den Inhalt des Prozessorregisters auf dem Stack, zum Beispiel bevor das Programm in eine Unterfunktion verzweigt, die deren Inhalt verändern könnte. Nach der Rückkehr holt es den gespeicherten Wert mit pop %eax wieder zurück. Dabei zeigt der Stack-Pointer (ESP) immer auf das aktuelle Ende des Stacks.

C-Programme legen die Übergabeparameter einer Funktion, die Rücksprungadresse und lokale Variable auf dem Stack ab. Auf die lokalen Variablen kann die Funktion über einen Offset zum so genannten Base Pointer zugreifen (EBP), der auf den Anfang des Datenbereichs einer Funktion zeigt (Stackframe).

Der [#kasten1 Kasten] auf Seite 218 zeigt ein einfaches Beispielprogramm in C, Auszüge aus dem zugehörigen Assembler-Code und den Stack-Inhalt während der Ausführung der Funktion. Auf dem Stack befinden sich unter anderem die lokalen Puffer buffer1 und buffer2 und die gespeicherte Rücksprungadresse. Kopiert man in der Funktion mit

strcpy(buffer1, buffer2); 

den Inhalt des größeren, zweiten Puffers in den ersten, überschreibt diese Operation auch diese Rücksprungadresse. Der abschließende Assemblerbefehl ‘ret’ holt diesen quasi zufälligen Wert vom Stack und schreibt ihn in den Instruction Pointer. Im nächsten Arbeitsschritt versucht der Prozessor, von dieser Adresse den nächsten Befehl zu laden - was in der Regel fehlschlägt und eine Speicherschutzverletzung erzeugt.

Dass das nicht immer so sein muss, demonstriert das Beispiellisting links. Hier erhöht das Programm den Wert der Rücksprungadresse um 8 - mit dem Resultat, dass es direkt den printf-Aufruf anspringt. Der Befehl ‘x=1’ kommt nicht zur Ausführung. Probieren Sie es aus: das Programm gibt ‘0’ aus.

Um in ein System einzubrechen, wollen die Angreifer jedoch eigenen Code ausführen. Dieser gelangt zumeist über lange Eingabestrings auf den Stack, die neben der neuen Sprungadresse auch die Maschinenbefehle enthalten. Dieser eigene Code wird auch als Payload bezeichnet und ist quasi das ‘Herz’ eines Buffer-Overflow-Exploits.

Beim Erstellen eines Exploits treten noch diverse Probleme auf, die sich jedoch in der Regel alle lösen lassen. Es beginnt mit der Adresse, an der sich der Code befindet. Sie muss als absoluter Wert an die Stelle der Rücksprungadresse geschrieben werden. Da jedoch die absolute Position der lokalen Variablen auf dem Stack nicht fest ist, weiß der Angreifer nicht genau, wo sein Code beginnt. Die genaue Adresse hängt unter anderem von der Anzahl und Länge der Umgebungsvariablen ab, die am Beginn des Stacks liegen. Als Workaround kommt das so genannte NOP-Sliding zum Einsatz. Dabei schreibt man vor den eigentlichen Code eine Reihe von No-Operation-Befehlen (NOPs). Landet der Sprung irgendwo in diesem NOP-Bereich, arbeitet die CPU die übrigen NOPs ab und kommt danach zum eigentlichen Exploit-Code.

Das nächste Problem hat damit zu tun, dass Strings in C immer mit einer ‘0’ beendet werden. Deshalb interpretiert das Programm das erste Auftreten des Werts 0x00 als das Ende des Eingabe-Strings. Dieser darf also nicht in der Payload vorkommen. Hier kann man zu Tricks greifen, und Zuweisungen wie ‘mov 0,eax’ durch das gleichwertige ‘xor eax,eax’ ersetzen. Eleganter ist es jedoch, den gesamten Code über die XOR-Verknüpfung mit einer Zahl Y zu kodieren. Diese darf dann jedoch nicht selbst im Maschinen-Code vorkommen, da dies wiederum zu einem 0x00 führen würde. Die Dekodierung erfolgt dann am Beginn des Programms mit wenigen Zeilen Assembler. Ähnliche Sonderbehandlung muss man unter Umständen den Zeichen für Tabulator und Linefeed angedeihen lassen.

XOR-Verknüpfung
Vergrößern
Die XOR-Verknüpfung mit 0x17 entfernt den Wert 0x00 aus dem String.

Schließlich muss man auch in einem solchen Exploit gelegentlich auf eigene Daten wie den String ‘/bin/bash’ zugreifen. Da die absolute Position des Strings im Speicher nicht bekannt ist, muss die Adressierung relativ erfolgen. Doch relativ wozu? Auch hier greifen die Programmierer von Exploits zu einem Trick: Sie springen mit einem relativen Jump-Befehl an das Ende ihres Codes, hinter dem sich die benötigten Variablen befinden. Dort simulieren sie via ‘call’ einen Funktionsaufruf auf den nächsten abzuarbeitenden Befehl. Dabei schiebt das System die nächste Adresse - also die des Stringanfangs - auf den Stack. Von dort kann man sie via ‘pop’ in ein beliebiges Register befördern. Schon hat der Programmierer seinen Zeiger auf den eigenen Datenbereich.

Aufbau eines ‘Strings’ für einen Buffer-Overflow-Exploit
Vergrößern
Der Aufbau eines ‘Strings’ für einen Buffer-Overflow-Exploit.

Damit steht dem eigentlichen Exploit nichts mehr im Weg. Doch natürlich will der Angreifer dabei Dateizugriffe oder den Start eines Programms nicht selbst in Assembler programmieren. Deshalb greift er auf Funktionen des jeweiligen Betriebssystems zurück. Linux bietet über den Software-Interrupt 0x80 Zugang zu allen wichtigen Funktionen - alte Hasen kennen dieses Verfahren noch vom DOS-Interrupt 0x21. Um eine neue Datei anzulegen, genügt es, vor dem Aufruf von ‘int 0x80’ einige Register entsprechend zu präparieren. EBX muss die Adresse des Strings mit dem Dateinamen enthalten, ECX sorgt für passende Zugriffsrechte und EAX wählt mit dem Wert 0x8 den Systemaufruf create() aus. Mit 0x80 erhält man Zugriff auf die Funktion execve(), über das man externe Programme wie die Shell ‘/bin/sh’ starten kann.

Unter Windows kann der Exploit-Code direkt alle Funktionen des Windows-APIs nutzen, die das Programm einbindet. Befindet sich darunter die Funktion LoadLibrary, kann der Angreifer auch beliebige Funktionen nachladen. Beim Aufruf der Windows-API-Funktionen müssen sich die Übergabeparameter wie bei einem normalen Funktionsaufruf in der richtigen Reihenfolge auf dem Stack befinden.

Der beste Schutz gegen Buffer-Overflows ist sicherheitsbewusste Programmierung. Software-Entwickler sollten sich unbedingt über die entsprechenden Fallstricke informieren und eine bewusst defensive Programmierung anstreben. Ganz besonders gilt das für die Entwickler von Programmen mit Netzwerkfunktionen, da durch diese auch Angriffe übers Netz möglich sind. Leider ist es mit dem auch hier zitierten strcpy() längst nicht getan - auch viele andere C-Funktionen bergen die Gefahr eines unerwarteten Pufferüberlaufs [[#literatur 1,2]].

Fein raus ist, wer Java statt C beziehungsweise C++ verwenden kann. Die Java-Plattform überwacht zur Laufzeit die Grenzen der Speicherbereiche und nimmt damit dem Entwickler eine große Last ab. Aber auch mit C/C++ kann man auf diverse Hilfsmittel zurückgreifen, die zumindest helfen, solche Sicherheitslücken zu vermeiden.

relativer Sprung
Über einen relativen Sprung beschaffen sich die Entwickler eines Exploits die Adressen der eigenen Daten.

StackShield [[#literatur 3]] sichert in Linux-Programmen bei jedem Funktionsaufruf die Return-Adresse und korrigiert sie bei Bedarf vor dem Rücksprung. Das Programm fügt dazu entsprechenden Code am Beginn und Ende jedes Funktionsaufrufs ein.

Auch StackGuard [[#literatur 4]] versucht, auf Unix-Systemen die Rücksprungadresse bei Funktionsaufrufen zu schützen. Dazu platziert es auf dem Stack direkt daneben ein so genanntes Canary. Dahinter verbirgt sich ein spezielles Kontrollzeichen, dessen Wert StackGuard vor jedem Rücksprung aus einer Funktion überprüft. Hat er sich geändert, schreibt das Programm eine Warnmeldung ins Syslog und beendet sich. Stackguard ist als Compiler-Patch zu gcc realisiert.

Sowohl bei StackShield als auch bei StackGuard muss man den Quelltext des Programms neu übersetzen - sofern man Zugang dazu hat. Ist das nicht der Fall, hilft die Funktionsbibliothek libsafe [[#literatur 5]] weiter. Sie ersetzt gefährliche Bibliotheksaufrufe durch eigene Versionen, die zusätzlich zur eigentlichen Funktion das Überschreiben des eigenen Stackframes verhindert. Damit können zwar immer noch Pufferüberläufe vorkommen; da aber die Rücksprungadresse außerhalb des Stack-Frames liegt, lässt sich der Kontrollfluss des Programms nicht mehr manipulieren. libsafe ist unter Linux als dynamische Bibliothek realisiert, die sich zwischen das Programm und die eigentlichen System-Bibliotheken schaltet.

SecureStack
Vergrößern
Hier warnt der SecureStack vor einem Buffer-Overflow.

Um das Problem an der Wurzel zu packen, könnte man den Stack als nicht ausführbar markieren. Damit löst der Versuch, Code auf dem Stack auszuführen, eine Speicherschutzverletzung aus. Dies muss allerdings auf Betriebssystemebene implementiert sein und bringt diverse Kompatibilitätsprobleme mit sich. Von Solar De-signer gibt es einen entsprechenden Patch für Linux [[#literatur 6]]. Allerdings haben Sicherheitsexperten bereits darauf hingewiesen, dass auch dies nicht der Weisheit letzter Schluss ist. So lassen sich Systemaufrufe wie system() auch direkt über die so genannte Procedure Linkage Table (PLT) anspringen [[#literatur 7]].

Auch das PaX-Team [[#literatur 8]] hat einen Linux-Patch entwickelt, der sich spezielle Fähigkeiten der Intel-Hardware zu Nutze macht, um Daten-und Stack-Segment als nicht ausführbar zu markieren. Außerdem gibt es von SecureWave mit SecureStack eine Implementierung für Windows NT/2000, bei der allerdings derzeit die Windows-2000-Version noch übermäßige Performanceeinbußen mit sich bringt [[#literatur 9]].

Ein ganz anderes Konzept verfolgen gesicherte Betriebssysteme wie Argus Pitbull oder das Security-Enhanced Linux der NSA. Diese unternehmen gar nicht erst den Versuch, Buffer-Overflows zu verhindern, sondern versuchen deren Konsequenzen durch sehr detaillierte Rechtevergabe unter Kontrolle zu halten. So hat ein Angreifer selbst mit einer Root-Shell, die er durch einen Buffer-Overflow im Web-Server erlangt hat, nur sehr eingeschränkte Zugriffsmöglichkeiten auf das System. Dass sich auch solche Systeme hacken lassen, zeigte im Frühjahr ein erfolgreicher Einbruch bei einem Hacking-Contest von Argus, bei dem die Angreifer ein Sicherheitsproblem des eingesetzten Solaris-Kernel ausnutzten - was ihnen 100 000 Mark Preisgeld einbrachte.

Buffer-Overflows werden sicher auch in den nächsten Jahren eines der zentralen Sicherheitsprobleme darstellen. Neben den Stack-orientierten Exploits dürften in nächster Zeit auch vermehrt Exploits auftauchen, die sich Pufferüberlaufe in statischen Variablen auf dem Heap zu Nutze machen. Erste Veröffentlichungen dazu sind bereits im Internet erschienen [[#literatur 11]]. Solange Entwickler unter Zeitdruck immer neue Funktionen und Features in Programme einbauen und Anwender sich mit regelmäßigen Patches für das Sicherheitsloch des Monats zufrieden stellen lassen, wird sich an dieser Situation auch nichts ändern. (ju)

[1] Secure Programming for Linux and Unix HOWTO: www.dwheeler.com/secure-programs/

[2] John Viega, Gary McGraw; Building Secure Software: How to Avoid Security Problems the Right Way; Addison Wesley Professional, ISBN: 020172152X

[3] StackShield

[4] StackGuard

[5] libsafe

[6] Solar Designers Linux-Patch: www.openwall.org

[7] Probleme bei non-executable Stacks

[8] PaX

[9] SecureStack

[10] Der Klassiker: ‘Smashing the Stack for Fun and Profit’

[11] Heap Overflows

[#anfang Seitenanfang]


Die Funktion überschreibt ihre eigene Rücksprungadresse, und das Programm gibt ‘0’ aus.

 1 void function(int a, int b, int c) { 2    char buffer1[8]; 3    char buffer2[16]; 4    int *ret; 5  6    ret = buffer1 + 12; 7    (*ret) += 8; 8 } 9  10 void main() { 11   int x; 12  13   x = 0; 14   function(1,2,3); 15   x = 1; 16   printf("%d\n",x); 17 } 

[#anfang Seitenanfang]


Aufbau eines ‘Strings’ für einen Buffer-Overflow-Exploit
Vergrößern

Bei jedem Funktionsaufruf landen Parameter, Rücksprungadresse und lokale Variablen auf dem Stack.


C-Code

 void function(int a, int b, int c) {      char buffer1[8];      char buffer2[16];      ... }    void main() {         function(1,2,3); } 

Assembler-Code
(Auszug aus "gcc -S -o example1.s example1.c")

 function:     pushl %ebp           # sichert EBP     movl %esp,%ebp       # kopiert ESP nach EBP     subl $24,%esp        # schafft Platz f. buffer1+2     movl %ebp,%esp       # korrigiert EBP     ...     popl %ebp     ret  main:     pushl %ebp     movl %esp,%ebp     pushl $3            # Parameter auf den Stack     pushl $2     pushl $1     call function       # Funktionsaufruf     addl $12,%esp       # Stack aufräumen 

[#anfang Seitenanfang]


Ein Auszug aus der Liste unsicherer Funktionen, die libsafe ersetzt.

 strcpy(char *dest, const char *src) strcat(char *dest, const char *src) getwd(char *buf) gets(char *s) fscanf(FILE *stream, const char *format, ...) scanf(const char *format, ...) realpath(char *path, char *reolved_path([])      sprintf(char *str, const char *format, ...) 

Kommentare