Synaptic Sync CTF: AES-GCM Nonce Reuse and the Forbidden Attack
Synaptic Sync: Challenge Summary
The Sync Protocol Gateway encrypts JSON sync payloads with AES-GCM but derives the GCM nonce deterministically from the Node ID. Every request for a given node therefore reuses the same nonce. Nonce reuse in AES-GCM is catastrophic: it leaks the keystream (two-time pad) and, via the forbidden attack (Joux), the GHASH authentication key
H. WithHrecovered we can forge a valid authentication tag for any ciphertext under that nonce, defeating the "authenticated" part of authenticated encryption.
The end goal: forge a /process_sync request whose decrypted plaintext carries the privileged command export_master_sequence, which returns the flag.
Target Surface
Service on port 5000:
| Method | Endpoint | Purpose |
|---|---|---|
GET | /, /health | Health check, returns {"status":"ok"} |
POST | /sync_request | Body {"node_id","payload"}; returns {"nonce","ciphertext","tag"} |
POST | /process_sync | Body {"nonce","ciphertext","tag"}; decrypts, verifies tag, parses JSON, acts on command |
Recon: Confirming Nonce Reuse
Two /sync_request calls with the same node_id returned the same nonce:
node_id=nodeA payload="ping one" -> nonce e91104e590fe97a6b5fc9ec2
node_id=nodeA payload="ping two" -> nonce e91104e590fe97a6b5fc9ec2 (reused)
node_id=nodeB payload="ping ..." -> nonce 5a303e912d0250f88b55ed42 (different)
The nonce is a deterministic function of the Node ID, exactly the design flaw the briefing hinted at.
The two nodeA ciphertexts were byte-identical except for three bytes. Those three bytes XOR to 1b 19 0a, which is precisely "one" XOR "two". That confirms AES-GCM runs as CTR mode under the hood and both messages share the same keystream, a textbook two-time pad.
Structure of the 62-byte plaintext envelope:
{"node_id": "nodeA", "command": "sync", "payload": "<our payload>"}
^------------------ 52-byte prefix --------------------^^payload^^"}
The command field sits in the fixed prefix and /sync_request hardcodes it to "sync". We never get to set it through the normal API, so we must forge.
The Vulnerability: AES-GCM Nonce Reuse
AES-GCM authenticates with GHASH over GF(2^128). For a message with empty AAD and m ciphertext blocks, the tag is:
T = GHASH_H(C) XOR E_K(J0)
GHASH_H(C) = C_1·H^(m+1) + C_2·H^m + ... + C_m·H^2 + L·H
where L is the length block and all arithmetic is in GF(2^128).
E_K(J0) depends only on the key and nonce. Reuse the nonce across two messages and E_K(J0) is identical for both, so XORing the two tags cancels it:
T1 XOR T2 = sum_i (C1_i XOR C2_i)·H^(m+2-i)
This is a polynomial in the single unknown H, the forbidden attack (Joux). Recovering its root recovers H, the GHASH key.
In this challenge both probe messages had equal length (8-char payloads, so identical L and identical block count m = 4) and differed only in the last ciphertext block. The polynomial therefore collapses dramatically:
D·H^2 = T1 XOR T2 where D = C1_4 XOR C2_4
=> H^2 = D^-1 · (T1 XOR T2)
=> H = sqrt( D^-1 · (T1 XOR T2) )
In a field of characteristic 2, squaring is a bijection, so the square root is unique: sqrt(x) = x^(2^127). No general root-finding needed, H drops out directly. (For messages differing in multiple blocks you would instead find all roots of the difference polynomial and disambiguate against a third message.)
Once H is known, recover the per-nonce keystream block used for the tag:
E_K(J0) = T1 XOR GHASH_H(C1)
We verified H and E_K(J0) independently against a third message ("ping six"): the predicted tag matched the server's tag exactly.
Implementation Pitfall: The GF(2^128) Identity
The one non-obvious bug while building the exploit: GCM's GF(2^128) uses a bit-reflected representation. The multiplicative identity is not the integer 1, it is 0x80000000000000000000000000000000 (1 << 127).
Initialising the exponentiation accumulator to integer 1 made gf_inv and gf_sqrt wrong by a constant factor. The tell-tale symptom: H recovered from two different message pairs agreed with each other (the error factor is constant, so the consistency check still passed) but failed to predict the real tag. Fixing the identity to 1 << 127 resolved it. Adding GF self-tests (mul(ONE,x)==x, mul(x,inv(x))==ONE, sqrt(x)^2==x) makes this immediate to catch.
Forging the Malicious Request
We don't need to read or rewrite the secret 52-byte prefix. We keep the original ciphertext bytes C1[0:52] untouched, they still decrypt to the genuine prefix {"node_id": "nodeA", "command": "sync", "payload": ", and only rewrite the payload region from offset 52 onward, where we do know the keystream (recovered from a request with a long known payload).
The plaintext is parsed as JSON. Python's json.loads keeps the last occurrence of a duplicate key, so we append a second command key that overrides the hardcoded "sync":
forged plaintext:
{"node_id": "nodeA", "command": "sync", "payload": "x", "command": "export_master_sequence"}
^------------ injected tail ----------^
Steps:
- Keep
ciphertext[0:52] = C1[0:52]. - For the injected tail, set
ciphertext[52+i] = keystream[52+i] XOR tail[i]. - Compute a valid tag:
tag = GHASH_H(forged_ciphertext) XOR E_K(J0). - POST to
/process_sync.
A sanity check confirmed the pipeline: forging the tail ping one"} reproduced msg1's ciphertext and tag byte-for-byte, proving H, E_K(J0) and the keystream were all correct, so any forged tag would verify server-side.
Finding the Privileged Command
/sync_request only ever sets command: "sync", so the privileged command name had to be guessed/brute-forced. Each forge is a cheap local computation, so we sprayed candidates at /process_sync. Normal commands returned {"data":"Sync processed for node nodeA","status":"success"}; the winning one returned the flag:
[export_master] 200: {"data":"Sync processed for node nodeA","status":"success"}
[export_master_sequence] 200: {"data":"LEVELUP{REDACTED}","status":"success"}
Synaptic Sync CTF Flag
LEVELUP{REDACTED}
Synaptic Sync Attack Chain
POST /sync_request twice with same node_id, payloads of equal length
("ping one" / "ping two") and observe identical nonce returned
|
v
Nonce reuse means the same E_K(J0) and the same keystream are used
for both messages -- a two-time pad
|
v
XOR ciphertext bytes that correspond to payload differs -> recovers
("one" XOR "two") and confirms keystream identity
|
v
Tag equation: T1 XOR T2 = polynomial in H (the GHASH key) over GF(2^128)
Equal-length messages differing only in the last block reduce it to
D * H^2 = T1 XOR T2, so H = sqrt(D^-1 * (T1 XOR T2))
|
v
Recover E_K(J0) = T1 XOR GHASH_H(C1); verify against a third probe message
|
v
Recover keystream offset 52..N from a request with a long known payload
|
v
Forge ciphertext: keep C1[0:52], append keystream XOR tail where tail is
x", "command": "export_master_sequence"}
which JSON-parses with duplicate-key override of command
|
v
Compute tag = GHASH_H(forged_ct) XOR E_K(J0); POST /process_sync
|
v
Server decrypts, verifies tag, executes the override command, returns flag
Synaptic Sync: Key Takeaways
- AES-GCM provides authentication only if nonces are never reused under a given key. Deriving the nonce deterministically from a stable identifier (the Node ID) guarantees reuse and voids every security guarantee of the mode.
- Nonce reuse leaks the keystream (two-time pad) and, via the forbidden / Joux attack, the GHASH key
H, which lets an attacker forge arbitrary valid tags. "Authenticated encryption" does not protect you here. - Defense: use a random 96-bit nonce per message, or a strict monotonic counter that can never repeat; or switch to a nonce-misuse-resistant mode such as AES-GCM-SIV.
- App-layer hardening: never trust decrypted plaintext blindly. Reject JSON with duplicate keys, and don't drive privileged actions from a field an attacker can influence via ciphertext manipulation.
Appendix: Exploit Outline
# GF(2^128), GCM bit-reflected convention
R = 0xE1 << 120
ONE = 1 << 127 # multiplicative identity (NOT integer 1)
def gf_mul(x, y):
z, v = 0, x
for i in range(127, -1, -1):
if (y >> i) & 1: z ^= v
v = (v >> 1) ^ R if (v & 1) else (v >> 1)
return z
def gf_pow(x, e):
r = ONE
while e:
if e & 1: r = gf_mul(r, x)
x = gf_mul(x, x); e >>= 1
return r
gf_inv = lambda x: gf_pow(x, (1 << 128) - 2)
gf_sqrt = lambda x: gf_pow(x, 1 << 127)
# GHASH (empty AAD)
def ghash(H, C):
y = 0
for i in range(0, len(C), 16):
y = gf_mul(y ^ int.from_bytes(C[i:i+16].ljust(16, b'\0'), 'big'), H)
return gf_mul(y ^ (len(C) * 8), H)
# 1. forbidden attack: messages differ only in last block, equal length
D = blocks(C1)[-1] ^ blocks(C2)[-1]
H = gf_sqrt(gf_mul(gf_inv(D), T1 ^ T2))
EJ0 = T1 ^ ghash(H, C1)
# verified: ghash(H, C3) ^ EJ0 == T3
# 2. keystream from a long known-plaintext request -> ks[52:]
# 3. forge: keep ct[0:52]=C1[0:52]; ct[52+i]=ks[52+i]^tail[i]
# tail injects a duplicate command key:
# x", "command": "export_master_sequence"}
# 4. tag = ghash(H, forged_ct) ^ EJ0 -> POST /process_sync
Synaptic Sync: Final Thoughts
AES-GCM is the workhorse authenticated encryption mode on the modern internet, and it is rock-solid when used correctly. "Correctly" in GCM has one very sharp edge: never reuse a nonce under the same key. Cross that line and the mode falls apart in spectacular fashion, leaking both the keystream (so confidentiality is gone) and the GHASH key (so integrity is gone). The challenge is a clean demonstration that "authenticated encryption" is not a property of the algorithm alone, it is a property of the algorithm plus a usage discipline.
The Joux forbidden attack is the textbook consequence of breaking that discipline, and the equal-length / single-differing-block case used here reduces the polynomial to a clean closed-form square root, no general root-finding needed. The GF(2^128) bit-reflected identity is the kind of detail that costs you an evening if you write GHASH yourself; pulling a well-tested library or copying a verified reference is a better idea for anything that isn't a CTF.
If a system absolutely cannot guarantee nonce uniqueness, the correct response is to switch modes. AES-GCM-SIV (RFC 8452) is designed precisely for the nonce-reuse-misuse threat model and is what should be reached for when, for example, nonces are derived from a stable identifier.
Challenge hosted on LevelUpCTF. Writeup completed: May 2026.