$ cat writeup.md…
$ cat writeup.md…
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.
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.
The challenge breaks because the client is trusted with too much information.
/api/sessioninfo returns both:
session_idsecretThat secret is the HMAC key used by the browser to authorize moves.
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.
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.
The same file defines when a round counts as a max win:
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:
k, we must cross exactly k times and then cash outk == 0, we should cash out immediately24 lanes and then cash outFrom the notes and solver behavior:
session_id stays validround_idx increments with the streaksession_id and secretTherefore, once we fetch the current session and keep winning, every future round is locally predictable as:
generate_game(session_id, streak)
The bug is a combination of three design failures:
Together, this turns a gambling challenge into full oracle access: we can predict the hidden state and still produce valid authenticated actions.
Request /api/sessioninfo and save:
session_id,secret.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.
After connecting to /ws, the server sends the current public game object. We verify the public fields match our local prediction:
grid_size, num_minessteps, riskThis confirms our local generator is synchronized with the server.
For mines:
For chicken:
max_safe_steps(cars),cross until crossed == max_safe_steps,cashout,max_safe_steps == 0, cash out immediately.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}
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.
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
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 complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar