← Back to Writeups
CUSTOMN/AOther

Fair Gambling

XESXOR8/23/20263 min read
#other#custom#n/a

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)

ConstantValue
START_CASH$1,000
SPIN_COST$25
FLAG_COST$1,000,000
STREAK_MULTIPLIERx3 per consecutive win
  • Symbols weighted out of exactly 1000: 🍒500, 🍋260, 🍇130, 🍉60, 💎25, 🔔20, ⭐5.
  • prepareSpin() commits sid + 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 a userid cookie (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

  1. 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.
  2. Free re-roll: sending {type:"spin", sid:"junk"} discards a bad prepared spin without paying the $25 spin cost.

Exploitation

Loop:

  1. Crack current committed hash against a 343-entry rainbow table.
  2. Triple (win) -> play the real sid: pay $25, collect payout, streak++.
  3. 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

FlagLocationValue
flagredeem via WS after $1Mbrunner{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 hit Infinity by streak ~643 (JSON serializes as null, crashing naive parsers).
  • Proxy-fronted WSS may need open_timeout bumped (slow handshake >10s default) and speaks HTTP/1.1 only for upgrades.

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.