Fair Gambling
Fair Gambling
Platform: Brunnerne CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: HLVM (writeup: D3v0o0Nu11) | Date: 2026-08-22 | Status: Solved Techniques: commit-reveal preimage brute force, free re-roll via invalid sid, WebSocket automation, streak multiplier abuse
Summary
Bun/TypeScript slot-machine game over WebSocket. Goal: reach $1,000,000 to redeem the flag (start $1,000, spin $25). Two server-side flaws chain perfectly: the fairness proof leaks the outcome before payment (SHA-1 committed hash of 3 emojis = only 343 preimages), and an invalid sid re-rolls the prepared spin for free. Never revealing a loss keeps the x3 win streak compounding until cash explodes past $1M.
Recon
Source review (server.ts)
| Constant | Value |
|---|---|
| START_CASH | $1,000 |
| SPIN_COST | $25 |
| FLAG_COST | $1,000,000 |
| STREAK_MULTIPLIER | x3 per consecutive win |
- Symbols weighted out of exactly 1000: 🍒500, 🍋260, 🍇130, 🍉60, 💎25, 🔔20, ⭐5.
prepareSpin()commitssid+sha1(emojis.join(""))to the client before charging anything.- Invalid-SID path (server.ts:83-93): unknown/foreign
sid->discardPreparedSpins(userid)+ fresh prepared spin, no charge ("An invalid SID deliberately discards a prepared result without charging the user."). - Win = all 3 symbols equal; payout multiplied by
3^(streak-1).
Enumeration highlights
- WS endpoint
/ws; identity is auseridcookie (new UUID when absent -> fresh session on every connect). - Client JS confirms SHA-1 verification of results client-side, hinting the preimage space is meant to be attacked.
Foothold
Vulnerability / Misconfiguration
- Commit-reveal with tiny preimage space: only 7^3 = 343 possible committed triples; brute-force the SHA-1 offline and know each spin's result in advance.
- Free re-roll: sending
{type:"spin", sid:"junk"}discards a bad prepared spin without paying the $25 spin cost.
Exploitation
Loop:
- Crack current committed hash against a 343-entry rainbow table.
- Triple (win) -> play the real
sid: pay $25, collect payout, streak++. - Non-triple (loss) -> play junk
sid: discard for free, get new commitment.
Since losses are never revealed, winStreak only grows; even cherry triples pay 50*3^(k-1) and cash passes $1,000,000 around streak 10 (~74 total rolls at ~14.5% triple rate).
# /tmp/opencode/fair_gambling.py (key parts)
TABLE = {sha1(a+b+c): (a,b,c) for a,b,c in product(SYMBOLS, repeat=3)}
...
triple = TABLE.get(hash_)
if triple and triple[0] == triple[1] == triple[2]:
await ws.send(json.dumps({"type": "spin", "sid": sid})) # play winners
else:
await ws.send(json.dumps({"type": "spin", "sid": "reroll"})) # free mulligan
if cash >= FLAG_COST:
await ws.send(json.dumps({"type": "redeem"}))
Run log:
[WIN #9] 🍒🍒🍒 +328,050 cash=509,025 streak=9
[WIN #10] 🍒🍒🍒 +984,150 cash=1,493,150 streak=10
[*] Redeeming with cash=1,493,150 streak=10
FLAG: brunner{l3ts_g0_g4mbl1ng}
Privilege Escalation
N/A (web challenge; flag is purchased in-app).
Flags
| Flag | Location | Value |
|---|---|---|
| flag | redeem via WS after $1M | brunner{l3ts_g0_g4mbl1ng} |
Key Takeaways / Lessons
- Commit-reveal schemes are only fair if the committed space is large enough to brute force; 343 candidates is nothing.
- Any "discard/re-roll without charge" error path is a money-printing primitive when combined with foreknowledge of outcomes.
- Unbounded multipliers (
3^streak) with no cap turn small EV edges into instant overflow — JS floats hitInfinityby streak ~643 (JSON serializes asnull, crashing naive parsers). - Proxy-fronted WSS may need
open_timeoutbumped (slow handshake >10s default) and speaks HTTP/1.1 only for upgrades.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.