$ cat writeup.md…
$ cat writeup.md…
hackthebox
"How fast can you keep score?"
A TCP service at 154.57.164.67:31990 runs a dice game with 100 rounds. Each round, 8-13 players roll dice (increasing count per round). You must identify the winner (highest sum, ties broken by last player) within 0.3 seconds.
Examining the challenge source code reveals the game mechanics:
player_nr = random.randint(8, 13) — random number of players (8-13)rnd has dice_nr = rnd * 2 + 2 dice per player (increasing each round)random.randint(1, 6)sorted(dice_sum.items(), key=lambda x:x[1])[-1][0][1].split('_')[1]time.sleep(0.1) per player line and time.sleep(0.05) before "Who wins"start = time.time() answer = input('> ') if time.time() - start > timeout: # timeout = 0.3 print("Mate... your are too slow!") return False
The key insight is that the 0.3s timer starts when time.time() is called BEFORE input(). If our answer is already in the TCP receive buffer when input() executes, it returns instantly, making time.time() - start ≈ 0.
input('> '), the answer is already waiting → instant read → timer ≈ 0TCP_NODELAY socket option (disable Nagle's algorithm) helps reduce send latency#!/usr/bin/env python3 """ Solver for Lucky Dice challenge. Strategy: send answer right after "Who wins this round?" line, before the server prints options and "> " prompt. The answer will be buffered in TCP and read instantly by input(). Retry until we get lucky with network timing for all 100 rounds. """ from pwn import * import re import socket import sys def solve(host, port): r = remote(host, port) r.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # Answer "Are you ready?" r.recvuntil(b"> ") r.sendline(b"1") # Wait for "Go!" r.recvuntil(b"Go!\n") for round_num in range(100): # Read all data until "Who wins this round?" data = r.recvuntil(b"Who wins this round?\n", timeout=30).decode() # Parse player lines players = {} for m in re.finditer(r"Player (\d+): ([\d ]+)", data): pnum = int(m.group(1)) dsum = sum(int(x) for x in m.group(2).split()) players[pnum] = dsum # Winner: highest sum; ties → highest player number (last roller) max_sum = max(players.values()) winner = max(p for p, s in players.items() if s == max_sum) # Send answer NOW — before server prints options and "> " prompt r.send(str(winner).encode() + b"\n") # Read through response resp = b"" while b"Correct" not in resp and b"slow" not in resp and b"off" not in resp: resp += r.recvuntil(b"\n", timeout=15) if b"Correct" not in resp: log.warning(f"Round {round_num + 1}: FAILED — {resp.decode().strip()[:80]}") r.close() return False if round_num % 20 == 0 or round_num == 99: log.info(f"Round {round_num + 1}/100: OK (winner={winner})") # Collect flag log.success("All 100 rounds passed!") flag_data = r.recvall(timeout=10).decode() print(flag_data) r.close() return True if __name__ == "__main__": host = sys.argv[1] if len(sys.argv) > 1 else "localhost" port = int(sys.argv[2]) if len(sys.argv) > 2 else 11337 context.log_level = "info" for attempt in range(50): log.info(f"=== Attempt {attempt + 1} ===") try: if solve(host, port): break except Exception as e: log.warning(f"Error: {e}")
socat TCP-LISTEN:11337,reuseaddr,fork EXEC:"python3 challenge.py"flag.txt with test flag in the challenge directoryinput() is called → timeout| Approach | Why It Failed |
|---|---|
| Wait for "> " prompt then send answer | Network RTT (~230ms) exceeds 0.3s timeout |
| Read player option lines then send | Still too slow — reading lines adds latency |
| TCP_NODELAY alone | Helps marginally but doesn't solve the fundamental latency issue |
| Brute-force PRNG seed (0-100000, timestamps) | Seed is from os.urandom(), space too large |
| z3 MT19937 state recovery from 3-bit observations | randint(1,6) only reveals top 3 bits of each 32-bit MT word; 3 bits per word is insufficient for unique state recovery |
| z3 with 0-rejection assumption | z3 finds SOME satisfying state but it doesn't match the real state — predictions are wrong (0/5 accuracy across 5 test seeds) |
| z3 with rejection modeling (acc_count variables, Array indexing) | Correct formulation but z3 still finds non-unique solutions due to insufficient bit information (3/32 bits known per word) |
| Iterative MT recovery (z3 solve → simulate rejections → re-solve) | Doesn't converge — z3 finds different valid states each iteration, state changes 600+/624 words per iteration |
| randcrack library | Requires 624 consecutive full 32-bit outputs; we only get 3 bits per output |
HTB{r0LL1ng-1n-t43_D33P-b0t_n3T-cRe4t10n}
Use this technique when:
input() — timer starts at input() call, not prompt displayinput() reads from stdin buffer — answer can be sent before prompt appearstime.time() measured around input() — buffer trick makes elapsed time ≈ 0$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar