Architects Gambit

Eh4x CTFby smothy

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:

python
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:

python
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:

python
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:

python
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 winning

The 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:

python
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 & 0xff

If 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:

  1. Solve PoW
  2. Parse round data (NUM_PILES, TAKE_LIMIT, MAX_VAL, DRAIN_LINKS)
  3. Decrypt piles (ECB for phase 1, CBC with derived key for phase 2)
  4. Run minimax to find WIN/LOSE and optimal move
  5. Submit with HMAC binding

Phase 3 flow:

  1. Skip probes entirely - send PLAY
  2. Read the revealed STATE
  3. Game loop: solve position → commit → reveal → read AI response → repeat
  4. 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:

BugWhat happenedTime wasted
Wrong field nameServer sends ENCODED_PILES not ENCRYPTED_PILES20 min
Key derivationUsed nonce + idx instead of nonce + ":" + idx30 min
Drain weightsParsed as decimal instead of hex (0x45)15 min
MAX_VAL cappingSolver explored impossible states, wrong WIN/LOSE calls3 hours
Prompt parsingORACLE prompt has > in description text, broke my delimiter45 min
Bidirectional drainsAssumed 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.