Overview
Category: Cryptography / Pseudo-Random Number Generation (PRNG)
Objective: The goal is to accumulate a balance of $10,000 to purchase the flag from the shop.
Core Mechanism: The server runs a dice game where the result relies on a server_seed generated by Python’s random module.
| Category | Difficulty | Flag |
|---|---|---|
| Crypto / PRNG | Easy | uoftctf{ez_m3rs3nne_untwisting!!} |
Code Analysis
We are provided with the source code chall.py. Upon reviewing the DiceGame class, we identify the following critical behaviors:
1. PRNG Initialization
The game initializes by reading a seed from a file named ./serverseed and seeding Python’s random number generator:
with open("./serverseed", 'r') as f: random.seed(f.read())[!NOTE] Python uses the Mersenne Twister (MT19937) algorithm for its
randommodule. This is a deterministic generator, meaning if you know the internal state, you can predict all future numbers.
2. The Information Leak
Inside the roll_dice function, the server generates a 32-bit integer called server_seed:
self.server_seed = random.getrandbits(32)While the actual “roll” result is derived from a secure HMAC operation using this seed, the code explicitly prints the server_seed back to the user after every round:
print(f"Game {i:05}: ... Server-Seed: {self.server_seed}")3. The Vulnerability
The Mersenne Twister state consists of 624 32-bit integers. The random.getrandbits(32) function outputs a “tempered” version of one of these state integers. Because the server prints the raw 32-bit outputs of the generator, we can:
- Collect 624 consecutive
server_seedoutputs. - Reverse the “tempering” process (a series of bitwise XORs and shifts) to recover the raw internal state.
- Clone the state into our local Python script.
- Predict every future
server_seedand, consequently, every future dice roll.
Exploitation Strategy
Phase 1: Data Collection
We need to survive long enough to collect 624 samples. We play the game betting the minimum possible amount to preserve our balance while gathering the data.
- Action: Place minimum bets on high-probability outcomes (e.g., betting the roll will be under 98) to minimize losses.
- Target: Collect 624 instances of
Server-Seed.
Phase 2: State Recovery (Untempering)
The Mersenne Twister output function applies the following transformation to a state value :
To recover the state, we must apply the inverse operations in reverse order. The solve script implements an untemper function to achieve this.
Phase 3: Prediction & Winning
Once the state is recovered using random.setstate(), our local Python script is synchronized with the server.
- Generate the next
server_seedlocally. - Pass this seed into a replica of the
predict_rollfunction (copied fromchall.py) to calculate the exact dice roll result. - Bet the entire balance (“All In”) knowing exactly what the result will be.
graph LR A[Collect 624 Outputs] --> B[Untemper Values] B --> C[Reconstruct MT State] C --> D[Sync Local RNG] D --> E[Predict Next Seed] E --> F[Calculate Roll & Win]Solution Script
The following script automates the attack. It uses pwntools for communication and implements the MT19937 untempering logic.
from pwn import *import randomimport hashlibimport hmacimport re
# [Untemper function reverses the bitwise operations of MT19937]def untemper(y): y ^= (y >> 18) y ^= (y << 15) & 0xEFC60000 t = y for _ in range(4): t = y ^ ((t << 7) & 0x9D2C5680) y = t t = y for _ in range(3): t = y ^ (t >> 11) y = t return y
def clone_mt_state(outputs): # Recover internal state from 624 outputs return [untemper(x) for x in outputs[:624]]
# [Replicates the server's roll logic exactly]def predict_roll(server_seed, client_seed, nonce): nonce_client_msg = f"{client_seed}-{nonce}".encode() sig = hmac.new(str(server_seed).encode(), nonce_client_msg, hashlib.sha256).hexdigest() # ... (logic matches chall.py) idx = 0 lucky = int(sig[idx*5:idx*5+5], 16) while lucky >= 1e6: idx += 1 lucky = int(sig[idx * 5:idx * 5 + 5], 16) if idx * 5 + 5 > 129: lucky = 9999 break return round((lucky % 1e4) * 1e-2)
def main(): io = remote("34.162.20.138", 5000) server_seeds = []
# --- Phase 1: Collect Seeds --- log.info("Collecting 624 seeds...") while len(server_seeds) < 624: # Play conservative games to keep balance while harvesting data # ... (sending bet inputs) data = io.recvuntil(b"Final Balance:").decode() # Parse seeds from output seeds_found = re.findall(r'Server-Seed:\s*(\d+)', data) for seed_str in seeds_found: server_seeds.append(int(seed_str))
# --- Phase 2: Recover State --- mt_state = clone_mt_state(server_seeds[:624]) random.setstate((3, tuple(mt_state + [624]), None)) log.success("RNG State Recovered!")
# --- Phase 3: Profit --- current_balance = 801.31 # Derived from logs while current_balance < 10000: # Predict the NEXT seed locally pred_seed = random.getrandbits(32) # Calculate the roll result pred_roll = predict_roll(pred_seed, "1337awesome", 624 + i)
# Bet EVERYTHING on the predicted number # ... (sending "All in" bet inputs)
# --- Phase 4: Buy Flag --- io.sendline(b"a") # Shop io.sendline(b"a") # Buy Flag print(io.recvall().decode())
if __name__ == "__main__": main()Execution Output
The script successfully collected the seeds, synchronized the RNG, and won massive bets to afford the flag.
[*] Phase 1: Collecting 624 server seeds...[*] Collected 624/624 seeds. Balance: 801.31[+] State recovered![*] Phase 3: Predicting rolls and betting to win...[*] [3] Predicted roll: 17, seed: 3493270643[*] Betting 2726.96 with greed 17 (multiplier: 5.82x)[+] Won! Balance: 2726.96 -> 15880.55[+] Reached balance: 15880.55![*] Phase 4: Buying flag![+] FLAG: uoftctf{ez_m3rs3nne_untwisting!!}Related Challenge
Funnily enough, I actually just wrote a PRNG Mersenne Twister challenge for another CTF recently! If you enjoyed untwisting the RNG in this challenge, you might want to try your hand at RPSLS (Rock Paper Scissors Lizard Spock).
You can check it out here: https://tcp.1pc.tf/games/22/challenges#494-RPSLS