Crypto Writeup - Edition 2026
Table des matières
Dans cet article, nous vous présenterons les différentes solutions aux challenges du Jeanne d’Hack CTF 2026 pour la catégorie crypto.
Intro - Crypto
Un message mystérieux a été intercepté, contenant des informations vitales pour la sécurité nationale.
Votre mission consiste à dévoiler ce secret…
- --.- -..- -..- .-/--- .- -.-- -.-- -- --.. .--. --.- -.. --..--/- --.- -.. --.-/..- ./..-. - --.-/. --.- --- -.. --.- ..-./-.-- . ... ---.../...- .--. - -- --- .-- - ..... -..- -..- .- ..--.- .. ..--- -.. -..- .--. ..--.- --- -.. -.- -... ..-. .-
Le message intercepté ressemble à du code morse. En utilisant un décodeur de morse en ligne, on obtient le texte suivant:
Tqxxa oayymzpqd, tqdq ue ftq eqodqf yes: VPTMOW{T5xxa_I2dxp_oDkbfa}
Une structure familière est présente dans le message, la chaîne “VPTMOW{…}” évoquant le format du flag: “JDHACK{…}”. Étant donné ce format connu, il est possible de calculer la distance entre les caractères chiffrés et le texte clair.
En analysant cette distance, on remarque que la différence entre les caractères est toujours de 12. Cela suggère qu’il s’agit d’un texte chiffré par décalage de César, avec 12 comme clé potentielle pour déchiffrer le message. En appliquant cette clé, le texte déchiffré est:
Hello commander, here is the secret msg: JDHACK{H3llo_W0rld_cRypto}
Le flag est donc: JDHACK{H3llo_W0rld_cRypto}.
Crypto Adventure
Catégorie : Cryptographie — Difficulté : Facile
Description
🎮 Crypto Adventure est un petit jeu d’aventure texte dans lequel tu explores plusieurs salles d’un donjon.
Dans chaque salle, un message est chiffré avec une technique différente, et tu dois le déchiffrer pour progresser jusqu’au message final contenant le flag.
On te fournit :
generate.py: génère les données chiffrées (dont le flag) dansgame_data.py.game.py: le jeu interactif en lui‑même.
Format du flag :
JDHACK{this_is_a_flag}
Solution
Étape 1 — Générer les données et lancer le jeu
On commence par générer les données chiffrées, puis on lance le jeu :
python3 generate.py
python3 game.py
Les premières salles introduisent des notions classiques (César, substitution mono‑alphabétique). La salle finale contient le flag, chiffré avec un XOR à clé répétée.
Salle 1 — Chiffrement de César
Le premier message est chiffré avec un César classique : chaque lettre est décalée d’un certain nombre de positions dans l’alphabet.
Une simple attaque par force brute (tester tous les décalages possibles) ou l’utilisation des fréquences des lettres permet de retrouver un texte lisible et de valider la salle.
Salle 2 — Substitution mono‑alphabétique
Le deuxième message utilise une substitution simple : chaque lettre du texte en clair est remplacée de manière cohérente par une autre lettre (clé de substitution).
On peut la casser en combinant :
- analyse de fréquences (lettres les plus fréquentes),
- longueur des mots,
- mots courants (
de,et,le,la, etc.).
En identifiant progressivement la clé de substitution, on restaure un texte en clair et on passe à la salle suivante.
Salle 3 — Message final : XOR + clé répétée
Le dernier message (salle finale) contient le flag, chiffré par XOR avec une clé courte répétée.
Le script solve.py fourni montre la démarche :
CT_HEX = "1c0d0c040c1d3207710a057d161a18621a1b0410117d09761d2b"
ct = binascii.unhexlify(CT_HEX)
# On sait que le flag commence par JDHACK{
known = b"JDHACK{"
On sait que les flags du CTF commencent par JDHACK{ : on dispose donc d’un known‑plaintext. Si ct[i] = pt[i] XOR key[i], alors key[i] = ct[i] XOR pt[i].
On récupère ainsi le début de la clé :
key_partial = bytes(ct[i] ^ known[i] for i in range(len(known)))
En affichant key_partial, on voit qu’il correspond au mot VIDEO (en ASCII). On en déduit que la clé est b"VIDEO" répétée.
Il reste alors à déchiffrer tout le flag en XORant chaque octet du ciphertext avec la clé répétée :
key = b"VIDEO"
flag = bytes(ct[i] ^ key[i % len(key)] for i in range(len(ct)))
print(flag.decode())
Ce qui révèle le flag :
JDHACK{C4ES4R_W4S_A_G4M3R}
Flag : JDHACK{C4ES4R_W4S_A_G4M3R}
Abusing Encrypted Saves
Les sources du challenge sont disponibles ici.
Un jeu simple : pierre-feuille-ciseaux. Et pourtant un objectif très complexe : atteindre 100 victoires d’affilée ! Saurez-vous relever le défi ?
L’objectif du challenge est clair : atteindre 100 victoires d’affilée dans un jeu de pierre-feuille-ciseaux. Cependant, on a en face de nous un bot jouant de manière complètement aléatoire. La victoire relève donc complètement du hasard, la probabilité de gagner est donc de $(1/3)^{100} = 1.94*10^{-48}$, on peut donc dire impossible.
Analyse du serveur
Le serveur propose cependant un système de sauvegarde, on peut exporter et importer une sauvegarde pour conserver son score. Pour protéger les sauvegardes, celles-ci sont chiffrés avec AES CTR et encodé en Base64 :
# Initialize AES cipher
key = os.urandom(16)
nonce = os.urandom(16)
self.cipher = Cipher(algorithms.AES(key), modes.CTR(nonce))
[...]
def save_progress(self, client_socket, player_progress: dict):
[...]
# Encrypt the player's progress to avoid cheating
encryptor = self.cipher.encryptor()
secure_save = base64.b64encode(
encryptor.update(save.encode()) + encryptor.finalize()
)
La clé est bien géneré de manière sécurisée, et le nonce également. Le problème vient du fait que le nonce est généré à l’initialisation du serveur et réutilisé ensuite pour le chiffrement de toutes les sauvegardes pour une même session.
Lorsqu’on utilise AES avec le mode opératoire CTR, la suite chiffrante généré (keystream) est exactement la même si la même clé et le même nonce est utilisé, on peut s’en rendre compte rapidement en regardant le schéma de ce mode (si Nonce et Key sont identiques, un bloc en clair renverra toujours le même bloc chiffré) :

Et donc la suite chiffrante utilisée pour chiffrer (et déchiffrer) les sauvegardes sera la même pour une même session.
Exploitation
Pour obtenir le flag, il faut donc gagner au moins 100 parties d’affilée sans subir une seule défaire. Le serveur vérifie ces informations lorsqu’on tente d’afficher le flag (option 5) :
def show_flag(self, client_socket, player_progress):
"""
Show the secret flag
"""
# Players needs to win 100 games in a row to retrieve flag. Good luck!
if player_progress["total_games"] >= 100 and player_progress["winrate"] == 100.0:
client_socket.send(b"\nCongratulations! You're the master of Rock-Paper-Scissors!\n")
client_socket.send(f"The flag is : {FLAG}\n".encode())
else:
client_socket.send(b"Sorry, the secret belong to the master...\n")
Il faut donc que le nombre de partie soit au moins égale à 100 et que le taux de victoire soit égale à 100%.
On va donc exploiter la réutilisation du nonce pour récupérer la suite chiffrante (keystream) à partir de la sauvegarde en clair et celle chiffrée renvoyée par le serveur. Pour retrouver ce keystream il suffit d’effectuer un XOR entre le clair et le chiffrée.
Ensuite on peut modifier les paramètres de la sauvegarde (total_games et winrate à 100) puis de nouveau chiffrer en réappliquant le XOR avec le keystream sur la sauvegarde modifiée. Il faudra aussi faire attention à la longueur des sauvegardes, le keystream fait la taille de la sauvegarde originale, la sauvegarde modifiée ne doit donc pas dépasser cette taille.
On peut réduire la taille de la nouvelle sauvegarde en modifiant d’autres paramètres comme le nombre de défaite (losses) ou d’égalité (draws) :
stats["losses"] = "0"
stats["draws"] = "0"
stats["total_games"] = "100" # Set total_games to 100
stats["winrate"] = "100.0" # Set winrate to 100%
La sauvegarde ainsi modifiée pourra être chargée dans le jeu de pierre-feuille-ciseaux pour récupérer le flag.
Script de solve
Le script suivant permet de modifier la sauvegarde et d’y modifier les bons paramètres pour résoudre le challenge :
#!/usr/bin/env python3
import json
import base64
"""
AES nonce reuse : challenge solve script
"""
# XOR bytes function
def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def main():
# Retrieve user inputs (stats and encrypted save)
raw_input = input("Enter your stats (JSON format):\n> ")
stats = json.loads(raw_input)
raw_input = input("Enter the encrypted save:\n> ")
encrypted_save = base64.b64decode(raw_input)
# Perform a XOR between the cleartext and ciphertext to retrieve the keystream
keystream = xor_bytes(json.dumps(stats).encode(), encrypted_save)
print("Original stats length:", len(json.dumps(stats)))
# Modify the save to meet challenge requirements
stats["losses"] = "0"
stats["draws"] = "0"
stats["total_games"] = "100" # Set total_games to 100
stats["winrate"] = "100.0" # Set winrate to 100%
print("Modified stats length:", len(json.dumps(stats)))
# Since the nonce is reused between encryption, the keystream will be the same,
# so we can reuse it to encrypt our modified save
result = base64.b64encode(
xor_bytes(json.dumps(stats).encode(), keystream)
)
print(f"Modified save :\n> {result.decode()}")
if __name__ == "__main__":
main()
En lui donnant une sauvegarde clair et une sauvegarde chiffrée, on obtient une nouvelle sauvegarde valide que l’on peut charger pour obtenir le flag !

Bigger is not always better
Les sources du challenge sont disponibles ici.
J’ai entendu dire que de nos jours les clés RSA devaient faire au moins 4096 bit pour être sécurisées…
Afin d’être certain que personne ne lire mon message secret, je vais utiliser une clé plus grande ;)
Retrouver le message chiffré avec le script
chall.py. Voici la sortie du script :N = 3434174781555557435778661741042500350456275506328603060487 e = 78676413582343569846949828607 The secret message is: 2142067835947378805635865892031195895336375309151350031041
Le message est chiffré avec RSA, reconnaissable avec les éléments publiques d’une clé (N et e).
La taille de la clé RSA est défini par la taille du modulo N, et pour obtenir un niveau de sécurité satisfaisant de nos jours, il est recommandé pour du chiffrement d’utiliser des clés RSA de 4096 bits, comme indiqué dans l’énoncé.
Cependant, même si N = p * q, la taille de N ne correspond pas au produit des premiers p et q qui la compose, mais de leur somme. Et donc la clé utilisé dans ce challenge ne fait pas 96 * 96 = 9216 bit de sécurité mais seulement 96 + 96 = 192 bit :
>>> len(bin(3434174781555557435778661741042500350456275506328603060487)[2:])
192
Ainsi, il est tout à fait possible de factoriser le modulo N afin de retrouver nos premiers p et q qui nous permettront de déchiffrer ce message. Pour ce faire, il existe plusieurs algorithmes que l’on peut tenter de réimplémenter si l’on souhaite, mais le plus simple reste de passer par des outils en ligne (ex. https://www.alpertron.com.ar/ECM.HTM) :

On retrouve donc nos facteurs premiers :
p = 46875535636223175101301594203
q = 73261558186906160606731596229
Et avec p et q on peut calculer phi puis d et déchiffrer le message secret !
Voici un exemple de script complet pour résoudre le challenge :
#!/usr/bin/env python3
from Crypto.Util.number import inverse, bytes_to_long, long_to_bytes
ciphertext = 2142067835947378805635865892031195895336375309151350031041
N = 3434174781555557435778661741042500350456275506328603060487
e = 78676413582343569846949828607
# From online factorisation tool
p = 46875535636223175101301594203
q = 73261558186906160606731596229
# Check factorisation is correct
assert p * q == N
# Compute phi and d
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
plaintext = pow(ciphertext, d, N)
# Here is the flag
print(long_to_bytes(plaintext).decode())
Guess My Pokemon
Les sources du challenge sont disponibles ici.
Saurez-vous retrouver le Pokémon mystère choisi par le serveur ? Un indice chiffré vous ai donné pour y parvenir…
Le but du challenge est de retrouver le Pokémon choisi au hasard par le serveur. Pour cela, un indice chiffré nous est donné.
Analyse du code
En analysant le code source disponible, on peut voir que l’indice correspond tout simplement au nom du Pokémon à deviner, chiffré en AES-256-CBC.
A priori, l’AES est initialisé avec un IV aléatoire et la clé correspond au hash SHA256 d’une valeur aléatoire. La clé peut donc sembler imprévisible dans un premier temps.
Cependant, si l’on regarde la documentation de la fonction uniqid (https://www.php.net/manual/en/function.uniqid.php), utilisée comme valeur aléatoire pour générer la clé de chiffrement AES, on tombe sur ceci :
uniqid(string $prefix = "", bool $more_entropy = false): string
Gets an identifier based on the current time with microsecond precision, prefixed with the given prefix and optionally appending a randomly generated value.
Un identifiant basé sur le temps ? Et juste après :
Caution : This function does not generate cryptographically secure values, and must not be used for cryptographic purposes, or purposes that require returned values to be unguessable.
La fonction uniqid semble donc être le point faible de l’implémentation de ce jeu.
De plus, par le plus grand des hasards, lors de l’initialisation d’une partie, un timestamp est renvoyé au joueur afin de calculer la durée de la partie. Cela nous permet donc d’avoir l’heure à laquelle l’appel à uniqid est effectué, à la seconde près.
Exploitation de uniqid
La documentation PHP indique que la fonction uniqid renvoie un identifiant « basé » sur le temps. En réalité, il s’agit précisément de l’heure courante à la microseconde près, sous forme d’un timestamp en hexadécimal. Par exemple :
php > echo uniqid(); # Appel à uniqid()
69708c6742ac3
En décomposant ce timestamp, on constate qu’il peut être découpé en deux parties :
- les 8 premiers caractères correspondent à un timestamp Unix classique en secondes ;
- les 5 derniers caractères correspondent aux microsecondes courantes (précision à 6 chiffres).
Les deux valeurs sont ensuite converties en hexadécimal puis concaténées :
php > echo microtime() . "\n"; echo substr(uniqid(), -5)." ".substr(uniqid(), 0, 8);
0.40160700 1768984926 # microtime
620d6 6970915e # uniqid (microsecondes / timestamp)
php > echo 0x620d6 . " " . 0x6970915e;
401622 1768984926 # En décodant les deux parties d'uniqid, on peut voir la correspondance
Lors de l’initialisation d’une partie, nous avons donc une clé AES générée avec uniqid, et le timestamp du début de partie, calculé juste après, correspond à la première partie de cette clé :
// Generate a random AES key
$_SESSION['aes_key'] = hash('sha256', uniqid(), true);
// Set game start time
$_SESSION['game_start_time'] = (int)time();
Cependant, il manque une partie de l’information pour récupérer la valeur complète de uniqid et ainsi retrouver la clé.
Il manque précisément 5 caractères hexadécimaux, ce qui correspond à $16^5 = 1,048,576$ possibilités. Cette recherche peut être effectuée rapidement par force brute si l’on dispose d’un couple clair/chiffré.
En poursuivant l’analyse, on observe justement que lorsqu’on tente de deviner de quel Pokémon il s’agit, le serveur renvoie le chiffré du Pokémon que l’on a envoyé :
// Encrypt the guessed Pokemon name
$encrypted_guess = encryptPokemonName($guess_normalized);
// JSON response
$response = [
...
'encrypted_guess' => $encrypted_guess,
...
];
Grâce à cela, nous disposons désormais d’un couple clair/chiffré, ce qui nous permet de retrouver la partie manquante de uniqid en énumérant toutes les possibilités.
Pour ce faire, pour chaque entier i entre $0$ et $16^5$ :
- on génère une valeur candidate de
uniqidà partir du timestamp et dei; - on hash cette valeur en SHA256 ;
- on chiffre la chaîne de caractères correspondant à notre Pokémon en AES-256-CBC en utilisant ce hash comme clé et l’IV renvoyé par le serveur ;
- si le résultat correspond au chiffré retourné par le serveur, alors la valeur de
uniqidutilisée, et donc la clé AES, a été retrouvée.
Avec une implémentation simple en Python, l’énumération de toutes les valeurs prend moins de 30 secondes. Il ne reste alors plus qu’à déchiffrer l’indice initial fourni par le serveur, puis à récupérer le flag en devinant le bon Pokémon.
Résolution de la proof-of-work
Un dernier point subsiste pour résoudre le challenge. Une proof-of-work (PoW par la suite) est mise en place pour limiter les tentatives de brute-force. Autrement, il serait possible d’envoyer en boucle le même Pokémon, puis de recommencer une partie jusqu’à espérer tomber sur le bon par hasard. Avec 1 025 Pokémon possibles actuellement, une telle attaque pourrait être réalisée en quelques minutes, voire en quelques secondes avec un peu de chance.
Pour réduire ce risque, une PoW doit être résolue lorsqu’on tente de deviner de quel Pokémon il s’agit, et également lorsqu’on recommence une partie. Le principe est de forcer le joueur à effectuer un grand nombre de calculs afin de le ralentir, tout en garantissant une vérification rapide côté serveur.
Dans ce challenge, la PoW consiste à hasher en SHA256 une valeur (le challenge) concaténée avec un nonce, jusqu’à obtenir un hash commençant par un nombre fixé de zéros, et ce dans un temps imparti. Ce nombre de zéros correspond à la difficulté. Les paramètres de cette PoW sont présents dans le code source de l’API :
$challenge = bin2hex(random_bytes(8)); // challenge aléatoire
$difficulty = 5; // nombre de zéros à obtenir
$expires = time() + 60; // délai pour résoudre le challenge (1 min)
En observant le code côté client, on peut retrouver le code JavaScript qui résout cette PoW lorsqu’on utilise l’application Web :
const { challenge, difficulty } = e.data;
const prefix = '0'.repeat(difficulty);
let nonce = 0;
while (true) {
const input = challenge + nonce;
const hash = await hashwasm.sha256(input);
if (hash.startsWith(prefix)) {
self.postMessage({
nonce,
hash,
});
return;
}
nonce++;
}
Il ne reste plus qu’à adapter ce code dans le langage souhaité (ici Python) et à intégrer la résolution de la PoW dans le script afin de valider le challenge.
Script complet
Voici un exemple d’implémentation en Python permettant de résoudre le challenge :
#!/usr/bin/env python3
import requests
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from tqdm import tqdm
APP = "http://REDACTED:5000"
# Helper for solving proof-of-work
def solve_pow(challenge: str, difficulty: int):
prefix = "0" * difficulty
nonce = 0
while True:
data = f"{challenge}{nonce}".encode("utf-8")
hash_hex = hashlib.sha256(data).hexdigest()
if hash_hex.startswith(prefix):
return {
"challenge": challenge,
"nonce": nonce,
"hash": hash_hex
}
nonce += 1
def encrypt_pokemon(pokemon: str, iv: bytes, timestamp: str) -> str:
# Compute the candidate key (SHA256(uniqid))
key = hashlib.sha256(timestamp.encode()).digest()
# Init AES-256-CBC cipher
cipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(pad(pokemon.encode(), AES.block_size))
return base64.b64encode(iv + encrypted).decode()
def decrypt_pokemon(encrypted: bytes, key: str, iv: bytes) -> str:
# Compute the key (SHA256(uniqid))
key_hash = hashlib.sha256(key.encode()).digest()
# Init AES-256-CBC cipher
cipher = AES.new(key_hash, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted)
return unpad(decrypted, AES.block_size).decode()
def main():
sess = requests.Session()
resp = sess.get(f"{APP}/api/game").json()
# Get game infos
start_time = resp["game_start_timestamp"]
encrypted_hint = resp["game"]["encrypted_hint"]
# Solve the PoW
pow_data = sess.get(f"{APP}/api/pow-challenge").json()
pow = solve_pow(pow_data["challenge"], pow_data["difficulty"])
# Guess a random Pokemon
guess = "Charizard"
body = {
"guess": guess,
"pow": pow
}
resp = sess.post(f"{APP}/api/guess", json=body).json()
encrypted_guess = resp["encrypted_guess"]
print("[*] Game infos")
print("Timestamp (hex):", hex(start_time))
print("Guess:", guess)
print("Encrypted guess:", encrypted_guess)
print("[*] Brute-force microseconds of uniqid key")
iv = base64.b64decode(encrypted_guess)[:16]
tmstp_hex = hex(start_time)[2:]
uniqid_key = None
for ms in tqdm(range(0x100000)):
candidate = f"{tmstp_hex}{ms:05x}"
if encrypt_pokemon(guess, iv, candidate) == encrypted_guess:
print("[+] Found candidate ! Key is:", candidate)
uniqid_key = candidate
break
if uniqid_key is None:
print("[-] No key found")
return
print("[*] Decrypt Pokemon to flag")
encrypted_pokemon = base64.b64decode(encrypted_hint)[16:]
iv = base64.b64decode(encrypted_hint)[:16]
pokemon = decrypt_pokemon(encrypted_pokemon, uniqid_key, iv)
print("[+] Pokemon is:", pokemon)
# Solve the PoW (again)
pow_data = sess.get(f"{APP}/api/pow-challenge").json()
pow = solve_pow(pow_data["challenge"], pow_data["difficulty"])
body = {
"guess": pokemon,
"pow": pow
}
# Send the correct guess
resp = sess.post(f"{APP}/api/guess", json=body).json()
print("[+] Flag:", resp["flag"])
if __name__ == "__main__":
main()