Logo
Overview

UofTCTF 2026 - Gambler's Fallacy

January 12, 2026
4 min read

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.

CategoryDifficultyFlag
Crypto / PRNGEasyuoftctf{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 random module. 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:

  1. Collect 624 consecutive server_seed outputs.
  2. Reverse the “tempering” process (a series of bitwise XORs and shifts) to recover the raw internal state.
  3. Clone the state into our local Python script.
  4. Predict every future server_seed and, 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 yy:

  1. y:=y((y11) & mask)y := y \oplus ((y \gg 11) \ \& \ \text{mask})
  2. y:=y((y7) & mask)y := y \oplus ((y \ll 7) \ \& \ \text{mask})
  3. y:=y((y15) & mask)y := y \oplus ((y \ll 15) \ \& \ \text{mask})
  4. y:=y(y18)y := y \oplus (y \gg 18)

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.

  1. Generate the next server_seed locally.
  2. Pass this seed into a replica of the predict_roll function (copied from chall.py) to calculate the exact dice roll result.
  3. 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 random
import hashlib
import hmac
import 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!!}

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