0xbbuddha
Retour aux writeups
CTF Writeup·Pwn·Insomni'hack 2026·2026-03-21

Escape - Insomni'hack 2026

Challenge par drp3ab0dy. Un binaire qui te donne une fuite mémoire, un overflow, et absolument aucun moyen d'afficher quoi que ce soit. Le genre de situation où il faut devenir créatif.

Contexte

Premier Insomni'hack pour moi. En arrivant sur ce challenge, j'ai tout de suite vu que le seccomp bloquait write. Du coup impossible d'exfiltrer le flag de manière classique. Pendant un moment j'ai tourné en rond en cherchant un bypass seccomp, avant de réaliser que la vraie question n'était pas "comment afficher le flag ?" mais "comment savoir si j'ai le bon caractère sans rien afficher ?".

C'est là que l'idée de l'oracle aveugle est apparue : transformer un timeout en "oui" et un crash en "non". Le reste, c'est de la plomberie ROP.

Flag
INS{No_wr1te!?_n0_Str3ss_g4dGet_3verywh3re!}

Sommaire

  1. Recon
  2. Seccomp, le mur
  3. Le plan d'attaque
  4. Leak libc
  5. Les gadgets qui font tout
  6. Pourquoi __strverscmp et pas strcmp
  7. La chaîne ROP complète
  8. Le cauchemar du bruit réseau
  9. Conclusion

1. Recon

On commence par les classiques. Le binaire est assez permissif côté protections :

checksec

Arch:     amd64
NX:       enabled
PIE:      disabled (non-PIE)
Canary:   disabled
RELRO:    partial

Pas de PIE, pas de canary, RELRO partiel : on va pouvoir écrire dans la GOT et utiliser des adresses fixes. Bon début.

Le programme se décompose en deux phases bien distinctes :

  1. Avant le seccomp : il lit un entier avec scanf("%lld", &ptr) puis affiche la valeur à cette adresse. Autrement dit, on a une lecture arbitraire de 8 octets. Un seul tir, mais c'est suffisant.
  2. Après le seccomp : il appelle read(0, buf, 0x1000) sur un buffer de pile ridiculement petit. L'offset jusqu'au RIP sauvegardé est de 40 octets. Overflow trivial.
En résumé : on a un leak avant le filtre et un overflow après. Deux primitives simples, mais le seccomp va sérieusement compliquer les choses.

2. Seccomp, le mur

Le filtre est brutal. Voici la liste complète de ce qu'on a le droit de faire :

syscalls autorisés

read     (0)
open     (2)
openat   (257)
exit     (60)

Vous avez bien lu : pas de write, pas de sendto, pas de mmap. On peut ouvrir le fichier flag, on peut le lire en mémoire, mais on n'a strictement aucun moyen de renvoyer son contenu.

C'est la contrainte centrale du challenge : il faut trouver un canal de communication sans écriture. Un side-channel.

3. Le plan d'attaque

L'idée est de ne jamais essayer d'afficher le flag, mais de le deviner caractère par caractère en observant le comportement du programme :

  1. Fuiter l'adresse de puts via la GOT pour calculer la base libc
  2. Construire une ROP chain avec ret2csu
  3. Ouvrir le flag avec syscall(SYS_open, ...)
  4. Lire le flag en mémoire
  5. Comparer un octet du flag avec un caractère deviné en utilisant __strverscmp
  6. Si égal : le programme reste en vie (timeout). Si différent : le programme crash.
Un oracle aveugle, caractère par caractère. C'est lent, c'est bruyant, mais ça marche.

4. Leak libc

La primitive de lecture avant le seccomp nous donne un seul coup. On le dépense sur puts@got :

adresses

PUTS_GOT = 0x404028
puts offset dans la libc : 0x84420

Le programme nous crache la valeur pointée, et on en déduit directement la base :

calcul

libc_base = leak_puts - 0x84420

À partir de là, on peut calculer toutes les adresses dont on a besoin :

offsets libc (remote)

puts         : 0x84420
syscall      : 0x118940
__strverscmp : 0x9f260

5. Les gadgets qui font tout

Comme le binaire n'est pas PIE, on peut utiliser les gadgets directement par adresse. Le classique ret2csu est présent et exploitable :

ret2csu - setup

0x40147a : pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret

ret2csu - call

0x401460 : call [r15 + rbx*8]
            rdi = r12d
            rsi = r13
            rdx = r14

Avec ça, on peut appeler n'importe quelle fonction dont on a l'adresse en mémoire, en contrôlant les trois premiers arguments. C'est suffisant pour read, open et __strverscmp.

Mais le vrai bijou, c'est celui-ci :

gadget oracle @ 0x4011c3

eax == 0  →  exécution continue → tombe sur un read → timeout
eax != 0  →  saut invalide → crash / EOF
Ce gadget est le coeur de l'oracle. Après l'appel à __strverscmp, eax vaut 0 si les chaînes sont identiques. Le gadget traduit ça en un comportement observable de l'extérieur : le programme reste vivant ou il meurt.

6. Pourquoi __strverscmp et pas strcmp

Premier réflexe : utiliser strcmp ou memcmp. Sauf que dans cette version de la libc, ces fonctions sont optimisées avec des instructions vectorielles qui compliquent l'exploitation (la valeur de retour n'est pas un simple 0 / non-zero dans eax comme on le voudrait).

__strverscmp est beaucoup plus coopérative :

  • Retourne 0 si les chaînes sont égales
  • Retourne non-zero sinon
  • Résultat proprement dans eax, sans optimisations exotiques

Exactement ce qu'il faut pour alimenter le gadget conditionnel.

7. La chaîne ROP complète

Chaque tentative de caractère envoie un payload complet qui fait tout ça d'un coup. C'est une seule ROP chain assez longue, mais chaque étape est logique :

  1. Préparer la GOT : écrire l'adresse de syscall() dans EXIT_GOT et celle de __strverscmp() dans PRINTF_GOT. On détourne des entrées GOT existantes pour y stocker nos pointeurs.
  2. Écrire le chemin : /home/pwn/flag dans la section .data du binaire.
  3. Ouvrir le flag : syscall(SYS_open, path, 0). Le fd retourné sera 3.
  4. Avancer dans le fichier : consommer index octets avec read(3, ...) pour se positionner sur le caractère qu'on veut tester.
  5. Lire le caractère cible : lire 1 octet du flag dans EXIT_GOT (préalablement remis à zéro).
  6. Écrire le candidat : lire l'octet deviné depuis stdin dans DUMMY.
  7. Comparer : __strverscmp(EXIT_GOT, DUMMY)
  8. Oracle : le gadget conditionnel fait le reste.
timeout = le caractère deviné est le bon. crash / EOF = mauvais caractère, on passe au suivant.

8. Le cauchemar du bruit réseau

En théorie, l'oracle est binaire : timeout ou crash. En pratique, le remote était instable, surtout vers la fin du CTF. Des connexions qui timeout alors qu'elles n'auraient pas dû, des crashs aléatoires sur le bon caractère... bref, du bruit.

Pour stabiliser l'extraction, j'ai dû ajouter pas mal de logique autour de l'oracle brut :

  • Un prefilter pour éliminer rapidement les caractères impossibles
  • Plusieurs tours de vérification par caractère
  • Des seuils de majorité : un caractère n'est accepté que s'il passe une majorité des tentatives
  • Des tests de suffixe pour les derniers octets, plus sujets aux erreurs
  • Un test d'EOF pour détecter la fin du flag
Un détail qui m'a fait perdre du temps : le fichier flag se termine par un \n. Le vrai flag s'arrête juste avant. Si on ne le sait pas, on cherche un caractère fantôme.

9. Conclusion

Ce challenge m'a beaucoup plu parce qu'il force à sortir des sentiers battus. On a toutes les primitives classiques (leak, overflow, ROP) mais l'absence de write rend la dernière étape non triviale.

La solution repose sur une idée simple : si tu ne peux pas parler, fais du bruit. En l'occurrence, la différence entre un programme qui vit et un programme qui meurt suffit à transmettre de l'information, un bit à la fois.

Les briques techniques ( open, read, __strverscmp, ret2csu) ne sont que de la plomberie. Le vrai saut, c'est le passage mental de "je dois afficher le flag" à "je dois deviner le flag".

Flag final

INS{No_wr1te!?_n0_Str3ss_g4dGet_3verywh3re!}