UMDCTF 2026 - rainbet
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:
1 | 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.
1 | 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:
1 | const fs = require("fs"); |
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:
1 | 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:
1 | chicken:<streak>:<steps>:<crossed> |
3.3 HMAC forgery
The hmac_secret is a hex string. The server uses
HMAC-SHA256 to sign action payloads:
1 | signature = HMAC-SHA256(hmac_secret, payload_string) |
We construct the payload string, compute the signature, and include it in our WebSocket action message:
1 | function sign(payload) { |
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.
1 | async function solve() { |
4. Flag
After 25 consecutive correct predictions:
1 | UMDCTF{one_might_argue_that_gambling_is_the_best_vice_but_they_would_be_wrong} |