Peter Amthor, Winfried E. Kühnhauser [email] Department of Computer Science and Automation Distributed Systems and Operating Systems Group Ilmenau University of Technology Systemsicherheit (SS 2015) 30. April 2015 Übungsblatt 2 Buffer-Overflow-Angriffe Buffer-Overflow-Angriffe nutzen den Umstand aus, dass ein Programm empfangene Eingaben nicht auf ihre Länge hin überprüft. Die Eingaben können daher über den vorgesehenen Puffer hinaus gespeichert werden und so wichtige Informationen überschreiben, beispielsweise Zieladressen für Sprungbefehle. In dieser Übung führen Sie selbst einen Buffer-Overflow-Angriff durch. Gegenstand ist ein unter Linux lauffähiges Programm (x86-32), welches Sie im Anhang finden. Ihre Werkzeuge sind ein Disassembler, ein Debugger sowie ein Compiler, welche am Ende des Übungsblattes beschrieben werden. Die Aufgaben sind für alle Gruppen identisch, liegen aber in mehreren Schwierigkeitsgraden vor. Beginnen Sie mit dem leichtesten Grad und nutzen Sie gewonnenes Wissen bei nachfolgenden Aufgaben. Beachten Sie stets die Hilfestellungen am Ende des Aufgabenblattes sowie das Beispiel in Kapitel 2 der Vorlesung. Szenario/Grundlagen: Starten Sie das entpackte Programm in einem Terminal über den Befehl./victim (ggf. müssen Sie die Datei zunächst mittels chmod +x victim ausführbar machen). Sie sehen eine Aufforderung zur Passworteingabe. Ihr Ziel ist es, diesen Authentifizierungsmechanismus zu überwinden. (Die Passworteingabe können Sie mit der Eingabetaste quittieren.) Hierzu werden Sie einen Pufferüberlauf im Stack des Programmes provozieren. Dieser baut sich, ausgehend von den oberen Speicheradressen, in Richtung niedriger Speicheradressen auf. Wird eine Funktion aufgerufen, werden zuerst ihre Parameter auf den Stack gelegt, anschließend die Werte der Instruction-Pointer- und Base-Pointer-Register, sodass diese bei Verlassen der Funktion wiederhergestellt werden können. Zum Schluss wird die Funktion ihre lokalen Variablen ebenfalls auf dem Stack platzieren. Ein Angriff nutzt die Tatsache aus, dass aufgrund des Stackaufbaus der gesicherte Instruction-Pointer im Speicher hinter dem Puffer (einer lokalen Variable) platziert wird. Der Wert lässt sich daher durch den Pufferüberlauf überschreiben. Am Ende des Funktionsaufrufs dient er als Rücksprungadresse der Angreifer kann so zu beliebigen Adressen springen. Ilmenau University of Technology Department of Computer Science and Automation Helmholtzplatz 5 (Zusebau), 98693 Ilmenau www.tu-ilmenau.de/ia Distributed Systems and Operating Systems Group Phone: +49 3677 69 4577 Fax: +49 3677 69 4541 Secretariat: Zusebau, Room 3026 www.tu-ilmenau.de/vsbs
Aufgabe 1: Ihr erster Schritt besteht darin, eine Rücksprungadresse zu überschreiben, um so eine andere Funktion aufzurufen. (Tatsächlich rufen Sie die Funktion nicht auf, sondern springen lediglich zu ihrem ersten Befehl. [Was ist der Unterschied?]) Das victim-programm liest Ihr Passwort über die Funktion password_prompt ein, welcher folgender C-Quelltext zugrunde liegt: void password_prompt(void) { char buf[12]; gets(buf); strncpy(password, buf, 12); Die Funktionen gets sowie strncpy sind Teil der C-Bibliothek unter Linux und entsprechend dokumentiert. Konstruieren Sie einen Angriff, der die Rücksprungadresse des password_prompt-aufrufs überschreibt, um so zu einer nur intern zu Debugging-Zwecken verwendeten Funktion memtest zu gelangen. Diese beendet den Programmablauf nach ihrer Ausführung. Erstellen Sie den Angriff wie am Ende des Übungsblattes beschrieben. Nutzen Sie den Debugger, um nötige Informationen zu erhalten. Führen Sie den Angriff im Debugger aus, damit sich die Speicheradressen nicht ändern. Achten Sie auf die Byte-Order (0x89abcdef wird in der Eingabe als 0xefcdab89 geschrieben). Beachten Sie auch, dass gets so lange einliest, bis ein Zeilenumbruch (0x0A) oder ein End-Of-File-Byte eingegeben wird (0x04). Diese sollten also in der Eingabe vermieden werden! Die Aufgabe ist gelöst, wenn memtest seine Ausgabe zeigt und das Programm beendet. Aufgabe 2: Parameter für Funktionsaufrufe werden ebenfalls im Stack abgelegt und können somit auch während Buffer-Overflow-Angriffen übergeben werden. Mithilfe eines Disassemblers lässt sich die konkrete Position der Parameter feststellen. Versuchen Sie, sich Zugang zum geschützten Programmteil in victim zu verschaffen! Sie werden eine Funktion grant_access vorfinden, welche genau dies bewerkstelligt allerdings einen Parameter verlangt: die uid des Nutzers, dem sie Zugang gewähren soll. Die Nutzer- ID Ihres Linux-Accounts erfahren Sie an der Konsole über id ur. Zur Konversion in eine Hexadezimalzahl kann Ihnen ebenfalls die Kommandozeile nützlich sein, beispielsweise über printf %x\n 500. Der grant_access-funktion liegt folgender Quelltext zugrunde: void grant_access(unsigned int uid) { unsigned int current_uid = get_user_id(); if(uid!= current_uid) { puts("falsche Nutzer-Id übergeben!"); exit(1); grant = 1; grant_uid = uid; 2
puts("zugriff gewährt!"); Die Aufgabe wurde erfolgreich gelöst, wenn die Meldung Zugriff gewährt! ausgegeben wird. Ein nachfolgender Absturz des Programmes ist normal und dem Umstand geschuldet, dass Ihr Angriff den Stack zerstört. (Wieso?) Aufgabe 3: Solange das Programm abstürzt, sobald Sie Zugang erlangt haben, ist Ihr Angriff nutzlos. Konstruieren Sie daher nun einen Angriff, der den Stack unbeschädigt lässt! Nutzen Sie dazu die Möglichkeit, während eines Buffer-Overflow-Angriffs eigenen Code im Stack auszuführen. Den Code erzeugen Sie, indem Sie ihn als Assembler-Programm formulieren, von einem Compiler zu Maschinencode umwandeln lassen und diesen in Ihrem Angriff unterbringen. Legen Sie die Rücksprungadresse auf den Beginn des eigenen Codes die Position im Stack ist bei jedem Programmaufruf (unter ähnlicher Umgebung) identisch. Der Code führt dann Ihren Angriff aus (gewährt Ihnen Zugang) und repariert den Stack, sodass korrekt zum Aufrufer von password_prompt zurückgesprungen wird. Aufgrund einiger Compiler-Interna stehen Ihnen im Stack insgesamt 20 Bytes für eigenen Code zur Verfügung dies sollte ausreichen, um Zeiger zu reparieren, Parameter vorzubereiten und den Sprung zu grant_access durchzuführen. Eine einfache Art, zu einer Adresse zu springen, besteht darin, die Zieladresse auf den Stack zu legen und den ret-befehl auszuführen. Wichtig für einen konsistenten Stackzustand sind insbesondere die gesicherten Instructionund Base-Pointer, sowie der richtige Wert im Stack-Pointer-Register. Aufgabe 4: In Aufgabe 3 haben Sie den Angreifer-Code ausgeführt, indem Sie die Rücksprungadresse auf den Beginn des Codes gelegt haben. Das victim-programm ermöglicht Ihnen dies, indem es die Stack-Position stabilisiert. In einer normalen Umgebung sind die Stackadressen allerdings von einer Vielzahl an Faktoren abhängig und ändern sich gewöhnlich bei jedem Programmablauf. Wie könnte ein Angreifer trotzdem Code auf dem Stack ausführen? Aufgabe 5: Sie haben nun erste Erfahrungen mit Buffer-Overflow-Angriffen gewonnen. Diskutieren Sie die Voraussetzungen, die nötig sind, um solch einen Angriff erfolgreich durchzuführen! Im Rahmen eines Buffer-Overflow-Angriffes lassen sich Funktionen aufrufen und unter bestimmten Umständen sogar Code ausführen. Welche Ergebnisse kann ein Angreifer erzielen? 3
Werkzeuge/Hilfestellungen: Um einen Angriff zusammenzustellen, schreiben Sie die Eingabe mit einem Hexeditor (unter Linux z.b. hexedit oder bless, in den Distributionen verfügbar) in eine Datei und leiten Sie sie dann an das Programm weiter. In einer Linux-Konsole sähe das so aus: [user@host]$ touch exploit.bin (Datei anlegen) [user@host]$ hexedit exploit.bin [user@host]$./victim < exploit.bin Beachten Sie die Byte-Reihenfolge (Little Endian)! Um zu verstehen, wie victim arbeitet, ist sein Quelltext nützlich. Lassen Sie sich diesen in einer Konsole mit dem Disassembler objdump anzeigen: [user@host]$ objdump d victim less leitet die Ausgabe von objdump an den Textbetrachter less (oder einen Texteditor Ihrer Wahl) weiter. Um das Verhalten des Programms analysieren zu können, nutzen Sie den Debugger gdb, ebenfalls ein Konsolenwerkzeug. Es verfügt über eine eigene Kommandozeile. Hier eine Beispielsitzung mit gekürzten Ausgaben: [user@host]$ gdb victim (gdb) b password_prompt Breakpoint 1 at 0x80486de (gdb) r < exploit.bin Starting program (Aufrufen des Debuggers) (Setzen eines Haltepunktes bei password_prompt) (Ausführen des Programmes mit Eingabe) Breakpoint 1, 0x080486de in password_prompt() (gdb) p/x $esp (Inhalt des ESP-Registers als Hexadezimal-Zahl) $1 = 0xabcdef10 (gdb) x/24w $esp (24 Speicherworte ab der Adresse in ESP ausgeben) 0xabcdef10: 0x00000000 0x00000000 (gdb) x/i $eip (Befehl an Adresse des EIP-Registers ausgeben) 0x80403020: movl $0x0,%eax (gdb) c (Programmablauf fortsetzen) gdb verfügt über eine eingebaute Hilfefunktion: nutzen Sie den Befehl help. Um eigenen Code einzuschleusen, notieren Sie diesen als Assembler-Code, lassen Sie ihn mit dem Compiler gcc in Maschinencode umwandeln und entnehmen die entstehenden Byte-Folgen mit objdump: [user@host]$ vim exploit.s (oder ein anderer Editor) [user@host]$ gcc m32 c exploit.s [user@host]$ objdump d exploit.o 4
Beachten Sie, dass gcc den AT&T-Assemblersyntax nutzt. Befehlen wie mov und push wird ein Präfix nachgestellt, um die Größe der bewegten Daten anzugeben: meist ein l für long-werte (32 Bit). Ein gültiges Eingabeprogramm für gcc ist:.text movl $0xa,(%esp) pop %eax mov %eax,0xabcd # obersten Stack-Eintrag durch 0xa ersetzen # Wert von Stack nehmen und in EAX speichern # Wert in EAX an Adresse 0xabcd schreiben Eventuell müssen einige Programme oder Bibliotheken zuerst installiert werden. Die hier empfohlenen Werkzeuge sind in allen gängigen Linux-Distributionen Teil der Softwaredatenbank. Weitere Anweisungen lassen sich im Internet finden. Bitte beachten Sie auch die Handbücher zu gdb und dem gcc-assembler: http://sourceware.org/gdb/current/onlinedocs/gdb/ http://sourceware.org/binutils/docs-2.17/as/ 5