The Architect's Gambit - EH4X CTF 2026
Category: Miscellaneous | Points: 493 | Solves: 3 | Author: CyC
Flag: EH4X{4rch173c7s_g4mb17_cryp70_n1m_w4rf4r3}
Solved by: Smothy @ 0xN1umb
The Architect has constructed a game of impossible Nim...
nc 20.244.7.184 41337
493 points, 3 solves. Encrypted pile values, game theory, GF(2^8) drain mechanics. CyC really woke up and chose violence with this one.
First Look
We connect and get hit with a PoW (SHA256 hash with 6 leading hex zeros). Standard stuff - brute force it and move on. After that we get a SESSION_NONCE and the game begins.
The challenge is basically: win 10 rounds of modified Nim across 3 escalating phases. Each phase adds more crypto and more pain.
me: "how hard can Nim be?"
the architect: *adds AES encryption, GF(2^8) multiplication, and commit-reveal*
me: "ah"
Understanding the Protocol
After a bunch of connecting, reading server output, and furious note-taking, here's what each phase throws at you:
Phase 1 (Rounds 1-3): AES-ECB Nim
The simplest phase. Pile values come AES-128-ECB encrypted with a key the server just... gives you. So decrypt, play Nim, win.
But there's a twist: drain links. Taking k stones from a pile adds floor(k/2) to some linked pile. This completely changes the Nim game tree because removing stones can actually increase the total.
Phase 2 (Rounds 4-7): AES-CBC + Key Derivation
Now it gets spicy. Pile values are AES-128-CBC encrypted (format: IV:ciphertext), and you have to derive the key yourself:
key = SHA256(session_nonce + ":" + round_index)[:16]A KEY_CHECK field gives you the first 4 bytes of the correct key so you can verify. Drain mechanics upgrade to GF(2^8) multiplication with hex weights like 0->1[w=0x6c]. Because why not.
Phase 3 (Rounds 8-10): Interactive Commit-Reveal
Full interactive mode. Pile values are AES-CTR encrypted with no key provided. You get 2 PROBE oracle queries that return (pile[i] * scalar + offset) mod p with unknown parameters.
Then you play an interactive game against an AI opponent using commit-reveal: hash your move, send the hash, then reveal. The AI does the same.
I spent way too long trying to crack the probes before realizing something beautiful.
The Breakthroughs
Discovery 1: Phase 3 just... tells you the state??
I was knee-deep in modular arithmetic trying to solve the probe system when I noticed the server literally prints the game state after every move:
STATE: [3 2 12] (total=17)
YOUR_TURN> Send COMMIT first:
COMMIT>
Bro. The probes were a complete red herring. Skip them, send PLAY immediately, and just read the state. Several hours of my life I'll never get back on those probe equations.
Discovery 2: MAX_VAL is a hard cap (the run-killer)
This one cost me like 7 failed runs. The MAX_VAL field isn't just a bound on initial pile sizes - it's a hard cap enforced during drain:
ns[dst] = min(ns[dst] + drain_amount, max_val)Without this cap, the solver explores states that can never exist on the server. My minimax was returning WIN for positions where the server said LOSE. I was losing my mind until I diffed my game simulation against actual server behavior and caught the discrepancy.
Discovery 3: Drains are directional
Phase 3 has drain links like 2->1[w=0x6f]. I assumed they were bidirectional (taking from pile 1 also drains to pile 2). Nope. They're one-way. Found out the hard way when the AI took from pile 1 and pile 2 didn't budge.
Discovery 4: The HMAC binding format
Took me a few 403s to nail down:
msg = f"{session_nonce}:{round_idx}:{answer}" # 0-based index!
binding = HMAC_SHA256(session_nonce.encode(), msg.encode()).hexdigest()[:8]Colons as separators, 0-based round index, first 8 hex chars. The challenge description actually clarified this, but I only noticed after debugging for 30 minutes.
The Solver
The core is recursive minimax with memoization. For each position, check if the player to move is losing:
def is_losing(state):
if all(s == 0 for s in state):
return True # no moves = you lose
for each valid move (pile i, take k):
next_state = apply_move(state, i, k)
if sum(next_state) >= sum(state):
continue # drain made things worse, skip
if is_losing(next_state):
return False # found a move that puts opponent in a losing spot
return True # every move leaves opponent winningThe sum check prevents infinite loops from drain mechanics where removing stones could increase the total. Without it the recursion goes brrrr into a stack overflow.
GF(2^8) Multiplication
Phases 2-3 use Galois Field multiplication with the AES irreducible polynomial for drain computation:
def gf_mul(a, b):
result = 0
a, b = a & 0xff, b & 0xff
for _ in range(8):
if b & 1:
result ^= a
a <<= 1
if a & 0x100:
a ^= 0x11b # x^8 + x^4 + x^3 + x + 1
b >>= 1
return result & 0xffIf you've done AES internals before this is familiar. If not - it's just multiplication in a finite field where addition is XOR and you reduce by the AES polynomial when things overflow. The drain amount becomes gf_mul(k, weight) % k.
Putting It All Together
Phases 1-2 flow:
- Solve PoW
- Parse round data (NUM_PILES, TAKE_LIMIT, MAX_VAL, DRAIN_LINKS)
- Decrypt piles (ECB for phase 1, CBC with derived key for phase 2)
- Run minimax to find WIN/LOSE and optimal move
- Submit with HMAC binding
Phase 3 flow:
- Skip probes entirely - send
PLAY - Read the revealed STATE
- Game loop: solve position → commit → reveal → read AI response → repeat
- If we're in a losing position, just take 1 from the first non-empty pile and pray the AI messes up (it doesn't, but hey)
The full solver (solver.py) runs all three phases automatically. Takes about 60 seconds per session, mostly spent on the PoW.
Bug Timeline (aka my suffering)
Because no solve is complete without a record of all the ways I shot myself in the foot:
| Bug | What happened | Time wasted |
|---|---|---|
| Wrong field name | Server sends ENCODED_PILES not ENCRYPTED_PILES | 20 min |
| Key derivation | Used nonce + idx instead of nonce + ":" + idx | 30 min |
| Drain weights | Parsed as decimal instead of hex (0x45) | 15 min |
| MAX_VAL capping | Solver explored impossible states, wrong WIN/LOSE calls | 3 hours |
| Prompt parsing | ORACLE prompt has > in description text, broke my delimiter | 45 min |
| Bidirectional drains | Assumed drains go both ways. They don't. | 1 hour |
The MAX_VAL bug was the worst. Everything almost worked - I'd win 7-8 rounds then randomly lose one. Turns out my solver was computing a "winning" move that relied on a drain pushing a pile past MAX_VAL, which the server silently capped.
Key Takeaways
- Always simulate the exact server rules. My solver was technically correct for a slightly different game. That "slightly different" meant wrong answers on edge cases.
- Red herrings exist. The probe oracle in Phase 3 was completely unnecessary since the server reveals state anyway. Don't tunnel-vision on a mechanic before checking if there's an easier path.
- Game theory + crypto is a nasty combo. The crypto is straightforward on its own, and Nim is well-studied on its own, but combining them with drain mechanics creates a much harder problem space.
- Diff your simulation against reality. When the solver says WIN but the server says LOSE, the bug is in your model, not the server.
Flag: EH4X{4rch173c7s_g4mb17_cryp70_n1m_w4rf4r3}
GG CyC. 493 points well earned. The MAX_VAL cap was brutal and I respect the craft.