rainbet - Web / WASM
A gambling game where the house doesn’t actually have an edge — if you can see through the deck. Predictable RNG + leaked server secrets = 25 consecutive wins and a flag.
Challenge: rainbet.challs.umdctf.io — a betting site requiring 25 max wins in a row. Two game modes: Mines and Chicken.
Given files: rainbet.py (reference wrapper) and rainbet_gen.wasm
(the leaked RNG backend).
1. Reconnaissance
The site greets you with a gambling UI. Create an account, get a session, and you can play Mines or Chicken. The goal is etched on the front page: win 25 rounds in a row at maximum payout.
The server exposes two critical API endpoints:
GET /api/sessioninfo{: file=‘endpoint’}
Returns session_id (a hex string) and hmac_secret (also hex). Both
are per-session and stable for the lifetime of the session.
The websocket endpoint sends a welcome message upon connection that
includes the current round_idx.
GET /api/socket{: file=‘endpoint’}
WebSocket handshake. The server’s first message contains round_idx in
the JSON payload.
Two delivered files point at the architecture:
-
rainbet.py— a thin Python wrapper showing how the server loads and calls the WASM module. It importsrainbet_gen.wasm, callsgenerate(session_id, round_idx)which returns the game state, and verifies HMAC-signed actions. -
rainbet_gen.wasm— the actual game generation logic. It takes asession_id(16 bytes) andround_idx(u32), seeds an internal RNG with them, and deterministically produces the full game board.
2. The Bug
The core vulnerability is a complete failure of information hiding:
| What should be secret | Where it leaked |
|---|---|
| The RNG algorithm | rainbet_gen.wasm was shipped to the browser |
| The RNG seed material | session_id exposed at /api/sessioninfo |
| The HMAC signing key | hmac_secret exposed at /api/sessioninfo |
| The current round index | round_idx in the WebSocket hello |
Because generate(session_id, round_idx) is a pure function with no
side effects, anyone who calls the same function with the same
arguments gets the exact same game. The server calls it after you
connect to generate your current game; you can call it locally with the
same session_id and round_idx to see the game too.
Once you know the game board, making the winning move is trivial. The
only remaining hurdle is forging a valid HMAC signature so the server
accepts your action — but you already have hmac_secret.
3. Solving Approach
3.1 Local WASM invocation
We use Node.js to instantiate the leaked WASM module and call
generate directly:
const fs = require("fs");const crypto = require("crypto");
async function initWasm() { const wasmBytes = fs.readFileSync("rainbet_gen.wasm"); const module = await WebAssembly.instantiate(wasmBytes, { env: { // minimal stubs — the WASM doesn't need actual I/O emscripten_memcpy: () => {}, memory: new WebAssembly.Memory({ initial: 256 }), }, }); return module.instance;}The exported generate function takes (sessionIdPtr, roundIdx) where
sessionIdPtr is a pointer into WASM linear memory containing the
16-byte session ID and roundIdx is a 32-bit unsigned integer.
After calling generate, the WASM writes the game state into memory at
a predictable offset (discoverable by reading the Python wrapper or
tracing the WASM exports). We read it back and parse the game.
3.2 Game parsing
Two game types exist:
Mines — the board is a grid with hidden mines. generate returns a
list of mine positions. We reveal every tile that isn’t a mine. The
HMAC payload format is:
mines:<streak>:<size>:<num_mines>:<tiles>where <tiles> is a comma-separated list of tile indices.
Chicken — the player crosses a bridge step by step; some steps are
safe, others collapse. generate returns the safe step count. We cash
out at that exact safe step (maximum safe position). Payload format:
chicken:<streak>:<steps>:<crossed>3.3 HMAC forgery
The hmac_secret is a hex string. The server uses HMAC-SHA256 to sign
action payloads:
signature = HMAC-SHA256(hmac_secret, payload_string)We construct the payload string, compute the signature, and include it in our WebSocket action message:
function sign(payload) { return crypto.createHmac("sha256", hmacSecret).update(payload).digest("hex");}3.4 Automation script
The full solver:
- Fetch
session_idandhmac_secretfrom/api/sessioninfovia an authenticated HTTP request. - Connect to the WebSocket with the session cookie.
- For each round:
- Read
round_idxfrom the server’s hello. - Call the local WASM
generate(session_id, round_idx). - Parse the predicted game.
- Construct the winning action payload.
- Sign it with HMAC-SHA256.
- Send the action over the WebSocket.
- Read
- Repeat 25 times. The 25th win triggers the server to send the flag.
async function solve() { const wasm = await initWasm(); const session = await fetchSessionInfo();
const ws = new WebSocket("wss://rainbet.challs.umdctf.io/api/socket"); ws.onmessage = async (msg) => { const data = JSON.parse(msg.data); const roundIdx = data.round_idx;
// Generate the game locally const game = predictGame(wasm, session.id, roundIdx);
// Build the winning action const action = buildWinningAction(game);
// Sign and send const signature = sign(action.payload); ws.send( JSON.stringify({ type: "action", payload: action.payload, signature: signature, }), ); };}4. Flag
After 25 consecutive correct predictions:
UMDCTF{one_might_argue_that_gambling_is_the_best_vice_but_they_would_be_wrong}