(English) BreizhCTF 2025 | Write-up - AutHentification 1 [Crypto]
My write-up for the BreizhCTF 2025 medium crypto challenge AutHentification 1, where I analyzed an AES-GCM misuse (fixed key/nonce and missing tag verification), treated it like a reusable stream cipher, recovered the keystream from a controllable user cookie, and forged a super_admin cookie to obtain the flag
Skills Required
- Basic understanding of symmetric cryptography (AES, nonce/IV, keystream).
- Familiarity with AES-GCM and the role of authentication tag verification.
- Understanding of CTR/stream-cipher XOR properties (ciphertext = plaintext XOR keystream).
- Ability to exploit known/chosen-plaintext conditions.
- Basic Python scripting for byte-wise XOR, JSON handling, and cookie forging.
Skills Learned
- How AES-GCM misuse (fixed key/nonce + no tag check) enables practical forgery.
- How to recover a reusable keystream from controlled plaintext-ciphertext pairs.
- How to craft a forged cookie by injecting a custom JSON role (super_admin).
- Why AEAD integrity checks are as critical as encryption itself.
- How to automate the full exploit flow cleanly in a short solver script.
Overview of the challenge
In this challenge, we interact with a web application that stores an authentication token in a cookie named auth. Our objective is to access /admin as super_admin to retrieve the flag.
The token is built from JSON:
1
{"username":"<name>","role":"<role>"}
and encrypted with a custom AES-GCM implementation.
At first glance, AES-GCM should provide confidentiality and authenticity.
However, two implementation mistakes make token forgery possible:
- The IV/nonce is fixed to a constant value.
- The authentication result returned by
decryptis ignored.
That turns the system into a reusable stream cipher where we can recover the keystream from known plaintext and then forge arbitrary roles.
Exploit summary
1
2
3
4
5
6
7
8
9
10
11
Exploit for 'Authentification 1' (Breizh CTF).
Bug chain:
1) App uses AES-GCM with a fixed IV (bad), BUT more importantly...
2) verif_token() ignores the authentication result from GCM.decrypt().
It decrypts and parses JSON even when the tag is invalid.
Because GCM encryption uses CTR under the hood, we can:
- login once to get (ciphertext, tag)
- reconstruct the plaintext JSON
- derive the keystream = C xor P
- craft a new plaintext JSON with role="super_admin"
- compute forged ciphertext and reuse any 16-byte tag (ignored)
Enumeration
From the provided source, the interesting files are:
server.py: Flask routes (/register,/login,/admin,/reset-db).crypto.py: token generation and token verification.gcm/gcm.py: custom GCM implementation.
The login flow is:
- Register a user (role is always
guest). - Login and receive cookie
auth. /admindecrypts that cookie and checks if role issuper_admin.
Analyzing the source code
crypto.py defines a global fixed IV:
1
IV = b"\x00"*IV_LEN
Token creation:
1
2
3
4
5
6
7
8
9
def build_token(key, username, role):
gcm = GCM(key, IV)
token = dumps({
"username": username,
"role": role
}).encode()
ct, tag = gcm.encrypt(token)
return ";".join([ct.hex(), tag.hex()])
Token verification:
1
2
3
4
5
6
7
8
9
def verif_token(key, token):
gcm = GCM(key, IV)
ct, tag = [bytes.fromhex(a) for a in token.split(";")]
pt, is_auth = gcm.decrypt(ct, tag)
if loads(pt.decode())["role"] != "super_admin":
return False
return True
The critical issue is obvious: is_auth is never checked.
So even when tag verification fails, the function still parses plaintext and only checks "role".
In gcm/gcm.py, decryption returns (P, False) on invalid tag:
1
2
if TT != T:
return (P, False)
but this status is discarded by verif_token.
Additionally, because IV is fixed and the key is the same during one session, encryption is effectively:
1
C = P XOR KS
with a reusable keystream KS.
Solution
Let:
P_userbe plaintext JSON for our own account (known because we choose username and role isguest).C_userbe ciphertext part from the cookie we receive after login.
Then:
1
2
C_user = P_user XOR KS
=> KS = C_user XOR P_user
Now choose a target plaintext:
1
{"username":"skilooooo","role":"super_admin"}
Call it P_admin. We forge:
1
C_admin = P_admin XOR KS
and send cookie:
1
auth = hex(C_admin) ; hex(any_16_byte_tag)
We can simply reuse the original tag from our valid login cookie.
Since authenticity is ignored, only the decrypted "role" matters.
Exploitation script
The following script automates the attack:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
from pwn import xor
from json import dumps
import requests
from sys import argv
def xor_bytes(a: bytes, b: bytes) -> bytes:
if len(a) != len(b):
raise ValueError("xor length mismatch")
return bytes(x ^ y for x, y in zip(a, b))
def token_plaintext(username: str, role: str) -> bytes:
# Must match server-side json.dumps default formatting.
return json.dumps({"username": username, "role": role}).encode()
def forge_admin_cookie(auth_cookie: str, original_username: str, target_username: str) -> str:
ct_hex, tag_hex = auth_cookie.split(";")
ct = bytes.fromhex(ct_hex)
p = token_plaintext(original_username, "guest")
p2 = token_plaintext(target_username, "super_admin")
if len(p) != len(ct):
raise ValueError("Plaintext length does not match ciphertext length; json formatting mismatch?")
if len(p2) != len(p):
raise ValueError(
f"Need same-length plaintexts for CTR bitflips (got {len(p)} vs {len(p2)}). "
"Adjust username lengths."
)
keystream = xor_bytes(ct, p)
forged_ct = xor_bytes(keystream, p2)
return f"{forged_ct.hex()};{tag_hex}"
def main() -> None:
if len(argv) != 2:
print("usage: python template_authentification.py <URL>")
print("example: python template_authentification.py http://archive.cryptohack.org:61277")
raise SystemExit(2)
url_base = argv[1].rstrip("/")
session = requests.Session()
session.headers.update({"User-Agent": "ctf-solver"})
# Optional but makes reruns deterministic: resets key + users.
session.get(f"{url_base}/reset-db", timeout=10)
# Choose usernames so that resulting JSON length stays constant.
# role: "guest" -> "super_admin" increases length by 6.
target_username = "admin"
original_username = target_username + ("A" * 6)
password = "demo"
# Register
r = session.post(
f"{url_base}/register",
data={"username": original_username, "password": password},
timeout=10,
)
# If MAX_USERS has already been hit and reset-db is disabled, this might 403.
# Login (disable redirects to capture Set-Cookie)
r = session.post(
f"{url_base}/login",
data={"username": original_username, "password": password},
allow_redirects=False,
timeout=10,
)
if "auth" not in r.cookies:
raise RuntimeError(f"No auth cookie received (status={r.status_code}).")
# Cookie values cannot safely contain ';', Werkzeug escapes it as octal \073.
# requests gives us the escaped form, so normalize back to the token format.
auth = r.cookies["auth"].strip('"').replace("\\073", ";")
forged = forge_admin_cookie(auth, original_username, target_username)
# Request /admin with forged cookie
forged_cookie_value = forged.replace(";", "\\073")
# Werkzeug only unescapes octal sequences (like \073 for ';') inside quoted values.
# requests won't quote cookie values for us, so craft the Cookie header explicitly.
admin_session = requests.Session()
admin_session.headers.update({"User-Agent": "ctf-solver"})
cookie_header = f'auth="{forged_cookie_value}"'
r = admin_session.get(
f"{url_base}/admin",
headers={"Cookie": cookie_header},
timeout=10,
)
print(r.text)
if __name__ == "__main__":
main()
Flag
Running the exploit returns the admin page containing the flag:
1
BZHCTF{ne_jamais_re-utiliser_le_nonce_e1d6ce70d3d1018c}
Getting the flag
- Register and login with a controlled username to get one valid token.
- Rebuild the exact JSON plaintext and recover keystream using XOR.
- Forge a new ciphertext for a plaintext containing
"role": "super_admin". - Reuse any 16-byte tag (for example the original one) and send forged cookie to
/admin. - Read the flag from the response.

