Crypto Writeup - 2026 Edition
Table of contents
In this article, we will present the various solutions to the challenges of the Jeanne d’Hack CTF 2026 for the crypto category.
Intro - Crypto
A mysterious message has been intercepted, containing vital information for national security.
Your mission is to unveil this secret…
- --.- -..- -..- .-/--- .- -.-- -.-- -- --.. .--. --.- -.. --..--/- --.- -.. --.-/..- ./..-. - --.-/. --.- --- -.. --.- ..-./-.-- . ... ---.../...- .--. - -- --- .-- - ..... -..- -..- .- ..--.- .. ..--- -.. -..- .--. ..--.- --- -.. -.- -... ..-. .- `
The intercepted message resembles Morse code. By using an online Morse decoder, the following text is obtained:
Tqxxa oayymzpqd, tqdq ue ftq eqodqf yes: VPTMOW{T5xxa_I2dxp_oDkbfa}
A familiar structure is present in the message, with the string “VPTMOW{…}” resembling the format of the flag: “JDHACK{…}”. Given this known format, it’s possible to calculate the distance between the encrypted characters and the plaintext.
Analyzing this distance reveals that the difference between the characters is always 12. This suggests that the text is encrypted using a Caesar cipher, with 12 as a potential key for decrypting the message. By applying this key, the decrypted text is:
Hello commander, here is the secret msg: JDHACK{H3llo_W0rld_cRypto}
The flag is therefore: JDHACK{H3llo_W0rld_cRypto}.
Crypto Adventure
Category: Cryptography — Difficulty: Easy
Description
🎮 Crypto Adventure is a small text-based adventure game where you explore multiple rooms in a dungeon.
In each room, a message is encrypted with a different technique, and you must decrypt it to progress until you reach the final message containing the flag.
You are given:
generate.py: generates the encrypted data (including the flag) intogame_data.py.game.py: the interactive game itself.
Flag format:
JDHACK{this_is_a_flag}
Solution
Step 1 — Generate data and start the game
First, generate the encrypted data, then launch the game:
python3 generate.py
python3 game.py
The early rooms introduce classic concepts (Caesar, monoalphabetic substitution). The final room contains the flag, encrypted with a simple XOR stream cipher using a repeated key.
Room 1 — Caesar cipher
The first message is encrypted using a standard Caesar cipher: each letter is shifted by a fixed number of positions in the alphabet.
A basic brute-force attack (trying all possible shifts) or letter-frequency analysis quickly reveals a readable plaintext, allowing you to clear the room.
Room 2 — Monoalphabetic substitution
The second message uses a simple substitution cipher: each plaintext letter is consistently replaced by another letter (a substitution key).
You can break it using:
- frequency analysis (most common letters),
- word length patterns,
- common words (
the,and, etc.).
By progressively recovering the substitution key, you restore a coherent plaintext and move on to the final room.
Room 3 — Final message: XOR + repeated key
The final message (last room) contains the flag, encrypted using XOR with a short repeated key.
The provided solve.py script shows the approach:
CT_HEX = "1c0d0c040c1d3207710a057d161a18621a1b0410117d09761d2b"
ct = binascii.unhexlify(CT_HEX)
# We know the flag starts with JDHACK{
known = b"JDHACK{"
In this CTF, flags always start with JDHACK{, giving you a known-plaintext.
If ct[i] = pt[i] XOR key[i], then key[i] = ct[i] XOR pt[i].
So you can recover the beginning of the key:
key_partial = bytes(ct[i] ^ known[i] for i in range(len(known)))
Printing key_partial reveals it corresponds to the ASCII string VIDEO. You can then assume the key is the repeated bytes b"VIDEO".
Now decrypt the full flag by XORing each ciphertext byte with the repeated key:
key = b"VIDEO"
flag = bytes(ct[i] ^ key[i % len(key)] for i in range(len(ct)))
print(flag.decode())
This reveals the flag:
JDHACK{C4ES4R_W4S_A_G4M3R}
Flag: JDHACK{C4ES4R_W4S_A_G4M3R}
Abusing Encrypted Saves
The sources for the challenge are available here.
A simple game: rock–paper–scissors. And yet a very complex goal: achieve 100 consecutive victories! Will you be able to rise to the challenge?
The objective of the challenge is clear: achieve 100 consecutive wins in a game of rock-paper-scissors. However, we are facing a bot that plays completely randomly.
Victory therefore relies entirely on chance. The probability of winning is $(1/3)^{100} = 1.94*10^{-48}$, which can reasonably be considered impossible.
Server Analysis
The server, however, provides a save system. You can export and import a save file to preserve your score. To protect saves, they are encrypted using AES in CTR mode and encoded in 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()
)
The key is generated securely, and so is the nonce. The issue comes from the fact that the nonce is generated when the server starts and then reused to encrypt all saves within the same session.
When using AES in CTR mode, the generated keystream is exactly the same if the same key and nonce are used. This can be quickly understood by looking at the mode’s diagram (if Nonce and Key are identical, a given plaintext block will always produce the same ciphertext block):

Therefore, the keystream used to encrypt (and decrypt) saves will remain the same throughout a session.
Exploitation
To obtain the flag, you must win at least 100 games in a row without a single loss. The server checks these conditions when attempting to display the 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")
The total number of games must be at least 100, and the win rate must be exactly 100%.
We will exploit the nonce reuse to recover the keystream from a known plaintext save and its corresponding encrypted version returned by the server. To retrieve the keystream, simply XOR the plaintext with the ciphertext.
Then we modify the save parameters (total_games and winrate set to 100), and re-encrypt it by applying XOR again with the recovered keystream on the modified save. We must also pay attention to the save length: the keystream has the size of the original save, so the modified save must not exceed that size.
We can reduce the size of the new save by modifying other parameters such as the number of losses (losses) or draws (draws):
stats["losses"] = "0"
stats["draws"] = "0"
stats["total_games"] = "100" # Set total_games to 100
stats["winrate"] = "100.0" # Set winrate to 100%
The modified save can then be loaded into the rock-paper-scissors game to retrieve the flag.
Solve Script
The following script modifies the save file and adjusts the required parameters to solve the 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()
By providing a plaintext save and its encrypted counterpart, the script generates a new valid save that can be loaded to obtain the flag.

Bigger is not always better
The sources for the challenge are available here.
I heard about RSA key length which needs now to be at least 4096 bit to be secure…
To be sure nobody will read my secret message, I will use a bigger key ;)
Retrieve the message encrypted with the
chall.pyscript. Here is the output of the script:N = 3434174781555557435778661741042500350456275506328603060487 e = 78676413582343569846949828607 The secret message is: 2142067835947378805635865892031195895336375309151350031041
The message is encrypted using RSA, which can be identified thanks to the public elements of a key (N and e).
The size of an RSA key is defined by the size of the modulus N, and to achieve a satisfactory level of security nowadays, it is recommended to use 4096-bit RSA keys for encryption, as stated in the challenge description.
However, even though N = p * q, the size of N does not correspond to the product of the sizes of the prime numbers p and q that compose it, but to their sum. Therefore, the key used in this challenge does not provide 96 * 96 = 9216 bits of security, but only 96 + 96 = 192 bits:
>>> len(bin(3434174781555557435778661741042500350456275506328603060487)[2:])
192
Thus, it is entirely feasible to factor the modulus N in order to recover the prime numbers p and q, which will allow us to decrypt the message. To do this, there are several algorithms one could try to reimplement, but the simplest approach is to use online tools (e.g. https://www.alpertron.com.ar/ECM.HTM):

We therefore recover the prime factors:
p = 46875535636223175101301594203
q = 73261558186906160606731596229
With p and q, we can compute phi, then d, and finally decrypt the secret message!
Here is an example of a complete script to solve the 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
The sources for the challenge are available here.
Would you find the secret Pokemon chosen by the server ? An encrypted hint is given to you to achieve this…
The goal of the challenge is to recover the Pokémon randomly chosen by the server. To achieve this, an encrypted hint is provided.
Code analysis
By analyzing the available source code, we can see that the hint simply corresponds to the name of the Pokémon to guess, encrypted using AES-256-CBC.
At first glance, AES is initialized with a random IV, and the key corresponds to the SHA256 hash of a random value. The key may therefore appear unpredictable initially.
However, when looking at the documentation for the uniqid function (https://www.php.net/manual/en/function.uniqid.php), which is used as the random value to generate the AES encryption key, we find the following:
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.
An identifier based on time? And just below:
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.
The uniqid function therefore appears to be the weak point of this game’s implementation.
Moreover, by sheer coincidence, when a game is initialized, a timestamp is returned to the player in order to compute the game duration. This gives us the time at which uniqid is called, accurate to the second.
Exploiting uniqid
The PHP documentation states that the uniqid function returns an identifier “based” on time. In reality, it is precisely the current time with microsecond precision, represented as a hexadecimal timestamp. For example:
php > echo uniqid(); # Call to uniqid()
69708c6742ac3
By breaking down this timestamp, we can see that it can be split into two parts:
- the first 8 characters correspond to a standard Unix timestamp in seconds;
- the last 5 characters correspond to the current microseconds (6-digit precision).
Both values are then converted to hexadecimal and concatenated:
php > echo microtime() . "\n"; echo substr(uniqid(), -5)." ".substr(uniqid(), 0, 8);
0.40160700 1768984926 # microtime
620d6 6970915e # uniqid (microseconds / timestamp)
php > echo 0x620d6 . " " . 0x6970915e;
401622 1768984926 # Decoding both parts of uniqid shows the correspondence
When a game is initialized, an AES key is therefore generated using uniqid, and the game start timestamp, computed immediately afterwards, corresponds to the first part of this key:
// Generate a random AES key
$_SESSION['aes_key'] = hash('sha256', uniqid(), true);
// Set game start time
$_SESSION['game_start_time'] = (int)time();
However, part of the information required to recover the full uniqid value—and thus the key—is still missing.
Specifically, 5 hexadecimal characters are unknown, which corresponds to $16^5 = 1,048,576$ possibilities. This search can be performed quickly via brute force if a known plaintext/ciphertext pair is available.
Continuing the analysis, we can see that when attempting to guess which Pokémon it is, the server returns the ciphertext of the Pokémon name that was submitted:
// Encrypt the guessed Pokemon name
$encrypted_guess = encryptPokemonName($guess_normalized);
// JSON response
$response = [
...
'encrypted_guess' => $encrypted_guess,
...
];
Thanks to this, we now have a plaintext/ciphertext pair, which allows us to recover the missing part of uniqid by enumerating all possible values.
To do so, for each integer i between $0$ and $16^5$:
- we generate a candidate
uniqidvalue from the timestamp andi; - we hash this value using SHA256;
- we encrypt the Pokémon name using AES-256-CBC with this hash as the key and the IV returned by the server;
- if the result matches the ciphertext returned by the server, then the corresponding
uniqidvalue—and thus the AES key—has been recovered.
With a simple Python implementation, enumerating all values takes less than 30 seconds. All that remains is to decrypt the initial hint provided by the server, then retrieve the flag by guessing the correct Pokémon.
Proof-of-work resolution
One final obstacle remains to fully solve the challenge. A proof-of-work (PoW) mechanism is implemented to limit brute-force attempts. Otherwise, it would be possible to repeatedly submit the same Pokémon and restart games until randomly guessing the correct one. With 1,025 Pokémon currently available, such an attack could succeed in a few minutes—or even seconds with some luck.
To mitigate this risk, a PoW must be solved whenever a Pokémon guess is submitted, and also when starting a new game. The idea is to force the player to perform a large amount of computation to slow them down, while keeping verification fast on the server side.
In this challenge, the PoW consists of computing the SHA256 hash of a value (the challenge) concatenated with a nonce, until a hash starting with a fixed number of zeros is obtained, within a given time limit. This number of leading zeros defines the difficulty. The PoW parameters are defined in the API source code:
$challenge = bin2hex(random_bytes(8)); // random challenge
$difficulty = 5; // number of leading zeros required
$expires = time() + 60; // time limit to solve the challenge (1 min)
By inspecting the client-side code, we can find the JavaScript implementation that solves this PoW when using the web application:
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++;
}
All that remains is to adapt this code to the desired language (Python in this case) and integrate PoW solving into the script to complete the challenge.
Full script
Below is an example Python implementation that solves the 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()