GlacierCTF2025 crypto/C.M.P.R.W
Some say that 75% of rock paper scissor matches end up in a tie. We suggest a more interesting one.
This challenge looks like a fun little game at first: you pick one of five categories, the computer picks one too, and you win/lose based on a fixed set of rules.
But the real goal is:
Beat the computer 200 times in a row.
And for the first 100 rounds, the game tells you how your choice compares to the computer’s (win, lose, or tie). No punishment. Just “free practice”.
It’s bait.
These 100 practice rounds are our way in.
1 - Understanding the game rules
You can choose one of these:
Welcome to C.M.P.R.W. If you win against the computer 200 time in a row. You may claim our [SPECIAL] reward. Here, have some free trials as new-comer goodies. Choose one of 'crypto', 'misc', 'pwn', 'rev', 'web':
The computer also chooses one.
Some choices beat others, similar to rock-paper-scissors.
Imagine a circle: each category beats two others and loses to two others.
So if we know what the computer is going to choose, we can always pick the winning option.
This entire challenge becomes: Predict what the computer will choose next.
2 - The computer is not actually random
Inside the challenge code, we find how the computer decides its choice.
This is the key part:
choice = (state & 0xf) % 5
Here’s the idea:
- The computer keeps a big 64-bit number called
state. - To choose a move, it looks at the last 4 bits of this number.
- It turns that into one of the 5 categories.
- Then it updates the number using a fixed set of steps.
This means: The computer is not using real randomness. It is using a predictable pattern. This pattern is called an LFSR, a simple type of pseudo-random number generator.
The important part: If you know the state at any moment, you can know all future moves.
3 - The 100 practice rounds leak information
Every time we play, the server tells us:
- “tie” → the computer picked the same as us
- “win” → our choice beats the computer’s
- “lose” → the computer’s choice beats ours
This gives us constraints on the value of:
(state_t & 0xf) % 5
This doesn’t reveal the nibble directly but it tells us which subset of 0–4 it must belong to.
After 100 rounds, we have:
- 100 constraints
- on 100 successive RNG states
This is enough to reconstruct the initial 64-bit state.
4 - Recovering the Hidden RNG State
At this point, we know two things:
-
The computer’s choice each round is determined by the lowest nibble (4 bits) of a hidden 64-bit number (
state). -
We collected 100 rounds of feedback (“win / lose / tie”). These don’t tell us the exact nibble, but they do tell us which nibble mod 5 the computer must have had.
This means each round gives us a clue like:
(state_t & 0xF) % 5 ∈ {some subset of 0..4}
Now the challenge is: How do we reconstruct a 64-bit number knowing only 100 partial constraints on its lowest 4 bits across time, especially when the state changes every round via an LFSR?
A 64-bit number is simply:
N0 | N1 | N2 | ... | N15
where each Ni is a nibble (4 bits, value 0–15).
So instead of solving for one giant number, we solve for 16 tiny numbers, each between 0 and 15. This makes the search space manageable.
Here’s the key observation: During the first 16 rounds, the LFSR output nibble depends only on the nibble at the same position.
Why ? Because before the state gets mixed too much, each step of the LFSR shifts the state right, meaning:
- Round 0 output depends on N0
- Round 1 output depends on N1
- Round 2 output depends on N2
- …
- Round 15 output depends on N15
This gives us strong narrowing:
For nibble Ni, only the values v ∈ [0..15] that satisfy:
(v % 5) ∈ allowed residues from round i
are possible.
Often, this shrinks each nibble’s possibilities to 2–4 values instead of 16. This is phase 1 of the reconstruction.
After 16 rounds, things get more complex: Because the LFSR shifts and mixes bits, each output nibble becomes a combination of several original nibbles.
Your script computes these dependencies in advance.
Specifically, for each round t, you compute:
Which nibble positions {i, j, k, ...}
in the original 64-bit state
influence (state_t & 0xF)
This is done via the functions:
step_deps()one_rng_step_deps()
The result is out_dep_nibbles[t]:
Round t depends on nibble indices { n1, n2, ..., nk }.
Think of it like arrows:
Nibble 0 → influences rounds: {0, 4, 7, 10, 13, ...}
Nibble 1 → influences rounds: {1, 5, 8, 11, 14, ...}
...
Nibble 15 → influences rounds: {15, 18, 23, ...}
This mapping is crucial.
Now the script performs a depth-first search over the 16 nibbles:
- Pick
N0from its allowed values - Pick
N1 - Pick
N2 - …
- Up to
N15
After each assignment, the script asks: “Given the nibbles I’ve already chosen, are any of the early rounds guaranteed to be impossible ?”
To verify this, it:
- builds a partial 64-bit state
- simulates the LFSR forward
- checks all rounds where all dependencies are already known
- if any output contradicts constraints → prune the branch
This dramatically reduces the search space. Most incorrect states are eliminated before even trying to assign all 16 nibbles.
In practice:
- Instead of checking 16⁶⁴ possibilities
- You might only explore a few hundred valid partial states This finishes in under a second
When the DFS reaches nibble 15 and all constraints for all 100 rounds match your observations:
- the full 64-bit state has been successfully reconstructed
- this state reproduces exactly the outputs the server produced
- your local RNG is now perfectly in sync with the server
At that point:
- The RNG runs forward 100 rounds (to match server state)
- Then generates 200 predicted moves
- Each predicted move is countered with its winning option
- You win 200 rounds flawlessly
5 - Running the full exploit
This script:
- Connects to the server
- Plays 100 “crypto” moves to gather observations
- Uses constraint solving to recover the 64-bit RNG state
- Predicts the next 200 moves
- Sends the winning move each time
- Prints the final flag
#!/usr/bin/env python3
import socket
HOST = "challs.glacierctf.com"
PORT = 13375
TAGS = ["crypto", "misc", "pwn", "rev", "web"]
edges = []
for i in range(len(TAGS)):
j1, j2 = (i + 1) % len(TAGS), (i + 3) % len(TAGS)
edges.append((TAGS[i], TAGS[j1]))
edges.append((TAGS[i], TAGS[j2]))
def result_idx(u_idx, c_idx):
u, c = TAGS[u_idx], TAGS[c_idx]
if u_idx == c_idx:
return "tie"
if (u, c) in edges:
return "win"
if (c, u) in edges:
return "lose"
raise RuntimeError("invalid result")
WIN_MOVE = {}
for c_idx in range(5):
winners = []
for u_idx in range(5):
if result_idx(u_idx, c_idx) == "win":
winners.append(u_idx)
WIN_MOVE[c_idx] = winners[0]
def rng_from_state(state):
while True:
yield state & 0xF
for _ in range(4):
bit = (state ^ (state >> 3) ^ (state >> 7)) & 1
state = (state >> 1) | (bit << 63)
def step_deps(deps):
new = [set() for _ in range(64)]
tap = deps[0] ^ deps[3] ^ deps[7]
for i in range(63):
new[i] = deps[i + 1].copy()
new[63] = tap
return new
def one_rng_step_deps(deps_state):
out = deps_state[:4]
for _ in range(4):
deps_state = step_deps(deps_state)
return out, deps_state
MAX_T = 200
deps_state = [ {i} for i in range(64) ]
out_dep_nibbles = []
for t in range(MAX_T):
out_bits, deps_state = one_rng_step_deps(deps_state)
nibbles = set()
for bitset in out_bits:
for b in bitset:
nibbles.add(b // 4)
out_dep_nibbles.append(nibbles)
def recover_state_from_observations(allowed_residues, max_t_use=100):
T = min(max_t_use, len(allowed_residues))
domains = []
for i in range(16):
R = allowed_residues[i]
vals = [v for v in range(16) if (v % 5) in R]
if not vals:
raise RuntimeError("No values for nibble", i)
domains.append(vals)
partial = [None] * 16
found_state = None
depN = out_dep_nibbles
def check_prefix(k):
state = 0
for i in range(k):
state |= (partial[i] & 0xF) << (4 * i)
rng_state = state
assigned_set = set(range(k))
for t in range(T):
if depN[t].issubset(assigned_set):
out_nib = rng_state & 0xF
if (out_nib % 5) not in allowed_residues[t]:
return False
for _ in range(4):
bit = (rng_state ^ (rng_state >> 3) ^ (rng_state >> 7)) & 1
rng_state = (rng_state >> 1) | (bit << 63)
return True
def dfs(i):
nonlocal found_state
if found_state is not None:
return
if i == 16:
state = 0
for j in range(16):
state |= (partial[j] & 0xF) << (4 * j)
rng_state = state
for t in range(T):
nib = rng_state & 0xF
if (nib % 5) not in allowed_residues[t]:
return
for _ in range(4):
bit = (rng_state ^ (rng_state >> 3) ^ (rng_state >> 7)) & 1
rng_state = (rng_state >> 1) | (bit << 63)
found_state = state
return
for v in domains[i]:
partial[i] = v
if check_prefix(i + 1):
dfs(i + 1)
if found_state is not None:
return
partial[i] = None
dfs(0)
if found_state is None:
raise RuntimeError("State recovery failed")
return found_state
def recv_until(sock, marker: bytes):
data = b""
while marker not in data:
chunk = sock.recv(1)
if not chunk:
break
data += chunk
return data
def recv_line(sock):
data = b""
while not data.endswith(b"\n"):
c = sock.recv(1)
if not c:
break
data += c
return data.decode(errors="ignore").strip()
def main():
s = socket.create_connection((HOST, PORT))
print(recv_line(s))
print(recv_line(s))
print(recv_line(s))
allowed = []
user_idx = 0
for t in range(100):
recv_until(s, b": ")
s.sendall(b"crypto\n")
res = recv_line(s).strip()
print(f"[FREE {t:03d}] result = {res}")
if res == "tie":
allowed.append({0})
elif res == "win":
allowed.append({1, 3})
elif res == "lose":
allowed.append({2, 4})
else:
raise RuntimeError("Unexpected response:", res)
print("[*] Recovering RNG state...")
state0 = recover_state_from_observations(allowed, max_t_use=100)
print(f"[*] Recovered state0 = {state0:#018x}")
rng = rng_from_state(state0)
for _ in range(100):
next(rng)
for i in range(200):
recv_until(s, b": ")
choice_idx = next(rng) % 5
my_idx = WIN_MOVE[choice_idx]
my_tag = TAGS[my_idx]
print(f"[REAL {i:03d}] computer={TAGS[choice_idx]}, playing={my_tag}")
s.sendall((my_tag + "\n").encode())
res = recv_line(s)
print(f" -> server says: {res}")
if "lose" in res or "Sorry" in res:
print("[-] Something went wrong, lost a real round.")
return
try:
while True:
line = recv_line(s)
if not line:
break
print(line)
finally:
s.close()
if __name__ == "__main__":
main()
The exploit wins all 200 rounds:
[+] Success! Won 200/200 rounds!
[FLAG] gctf{y0u_4r3_7H3_TrUE_rN635u5_n0W_7rY_j3N5H1n_1Mp4C7}
Game over.
By Roockbye