//Hash Vegas
>Overview
- Category: Crypto
- Goal: Reach $1,000,000,000 balance to print the flag
nite{...}. - Key bugs:
-
MT19937 PRNG outputs leak fully through roulette and slot machine games, allowing complete state recovery.
-
Lottery voucher uses a secret-prefix hash (secret || data) with SHA-1/sha256/sha3-224, but redemption accepts SHA-1 and truncates to 20 bytes, enabling a length-extension attack when SHA-1 is selected.
>Recon of the services
roulette.pydrawsrandom.randrange(0, 2**256-1)each round and prints the exact number when you guess, leaking eight tempered 32-bit MT19937 outputs per round.slotmachine.pydraws two 32-bit choices per spin and displays all 16 nybbles as symbols (lossless mapping back to the 32-bit words).chall.pyallows at most 64 roulette rounds and 56 slot spins before the machines break. That yields64*8 + 56*2 = 624tempered outputs: exactly one full MT19937 state window.lottery.pyshuffles an array of 2048 hash functions (1024 sha256, 1023 sha3_224, 1 sha1) once, then for each ticket drawsticket_id = randint(1, 11)andhash_idx = randint(0, len(hash_funcs)-1). A voucher is issued only whenticket_id > 5.- Voucher generation:
ticket_hash = hash_func((secret + ticket_data).encode()).digest()[:20], withticket_data = username|amount. The secret is 16 random bytes, hex-encoded (32 chars). Redemption tries sha256, sha3_224, then sha1 against the same secret-prefix message and pays the parsed amount on match.
>Vulnerability summary
- Full PRNG state leak: 624 MT outputs are observable, so the MT19937 internal state can be untempered and reconstructed. This lets us predict all future
randomdraws, including the hash function choice and ticket id. - SHA-1 length extension: When the predicted ticket uses SHA-1 (and is winning), the printed voucher is
SHA1(secret||data)[:20]. Because redemption also truncates SHA-1 to 20 bytes, we can perform a standard SHA-1 length-extension attack to append|1000000000to the voucher data and forge a winning payout.
>Exploit plan
- Connect and input username.
- Play roulette 64 times, always guessing 0/red. Each round, read the printed number and split it into eight 32-bit words; collect 512 words.
- Spin the slot machine 56 times, parse the 16 symbols back to two 32-bit words per spin; collect the remaining 112 words. Total: 624 outputs.
- Untemper all 624 words and rebuild the MT state via
random.setstate. - Recreate
hash_funcs, shuffle it with the recovered PRNG, and simulate lottery draws untilticket_id > 5and the chosen hash function is SHA-1. Count how many tickets to skip. - Burn that many lottery attempts with
pay=0(the service allows it; if pay==0 it just says you cannot pay nothing, no state change beyond PRNG advance). - Buy the predicted winning ticket (pay 1). Capture
voucher_dataandvoucher_code. - Perform SHA-1 length extension with known original length = 32 (secret hex) + len(voucher_data). Append
|1000000000, obtain forged digest and data. - Redeem the forged voucher to set balance to $1,000,000,000.
- Choose "Get Flag" to receive the flag.
>Solver code
Full solver used remotely: handout/solve.py
python
import re
import ssl
import struct
import socket
import hashlib
import random
import time
from typing import List, Tuple
HOST = "vegas.chals.nitectf25.live"
PORT = 1337
USERNAME = "a"
TARGET_AMOUNT = 1_000_000_000
MASK_32 = 0xFFFFFFFF
SYMBOLS = [
"\\U0001F352", "\\U0001F34B", "\\U0001F34A", "\\U0001F347",
"\\U0001F349", "\\U0001F353", "\\U0001F34D", "\\U0001F34E",
"\\U0001F34F", "\\U0001F350", "\\U0001F351", "\\U0001F348",
"\\U0001F34C", "\\U0001F96D", "\\U0001F95D", "\\U0001F965",
]
SYMBOL_TO_VAL = {s: i for i, s in enumerate(SYMBOLS)}
def recv_until(sock: socket.socket, marker: bytes) -> bytes:
data = b""
while marker not in data:
try:
chunk = sock.recv(4096)
except TimeoutError as exc:
raise TimeoutError(f"timeout waiting for {marker!r}, got: {data!r}") from exc
if not chunk:
raise ConnectionError("connection closed")
data += chunk
return data
def send_line(sock: socket.socket, line: str) -> None:
sock.sendall((line + "\\n").encode())
def split_words(num: int, words: int = 8) -> List[int]:
return [(num >> (32 * i)) & MASK_32 for i in range(words)]
def undo_right_xor(y: int, shift: int) -> int:
x = 0
for i in range(32):
idx = 31 - i
bit = (y >> idx) & 1
if idx + shift < 32:
bit ^= (x >> (idx + shift)) & 1
x |= bit << idx
return x & MASK_32
def undo_left_xor_mask(y: int, shift: int, mask: int) -> int:
x = 0
for idx in range(32):
bit = (y >> idx) & 1
if idx - shift >= 0 and (mask & (1 << idx)):
bit ^= (x >> (idx - shift)) & 1
x |= bit << idx
return x & MASK_32
def untemper(y: int) -> int:
y = undo_right_xor(y, 18)
y = undo_left_xor_mask(y, 15, 0xEFC60000)
y = undo_left_xor_mask(y, 7, 0x9D2C5680)
y = undo_right_xor(y, 11)
return y & MASK_32
def parse_roulette_num(blob: str) -> int:
m = re.search(r"number is\\s+(\\d+)", blob)
if not m:
raise ValueError(f"could not parse roulette number from: {blob}")
return int(m.group(1))
def parse_wheels(blob: str) -> Tuple[int, int]:
wheels: List[str] = []
for line in blob.splitlines():
if any(sym in line for sym in SYMBOLS):
tokens = [tok for tok in line.split() if tok in SYMBOL_TO_VAL]
if tokens:
wheels.extend(tokens)
if len(wheels) != 16:
raise ValueError("failed to parse wheels")
vals = [SYMBOL_TO_VAL[t] for t in wheels]
out1 = sum(vals[i] << (4 * i) for i in range(8))
out2 = sum(vals[i + 8] << (4 * i) for i in range(8))
return out1, out2
def rebuild_state(outputs: List[int]) -> random.Random:
if len(outputs) != 624:
raise ValueError("need exactly 624 outputs")
state = [untemper(x) for x in outputs]
r = random.Random()
r.setstate((3, tuple(state + [624]), None))
return r
def sha1_padding(message_len: int) -> bytes:
padding = b"\\x80"
while (message_len + len(padding)) % 64 != 56:
padding += b"\\x00"
padding += struct.pack(">Q", message_len * 8)
return padding
def sha1_length_extend(orig_digest: bytes, orig_len: int, append_data: bytes):
if len(orig_digest) != 20:
raise ValueError("orig_digest must be 20 bytes")
h = list(struct.unpack(">5I", orig_digest))
total_len_before = orig_len + len(sha1_padding(orig_len))
new_message = append_data + sha1_padding(total_len_before + len(append_data))
def sha1_compress(data: bytes, hvals):
h0, h1, h2, h3, h4 = hvals
for chunk_start in range(0, len(data), 64):
chunk = data[chunk_start : chunk_start + 64]
w = list(struct.unpack(">16I", chunk))
for i in range(16, 80):
val = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]
w.append(((val << 1) | (val >> 31)) & MASK_32)
a, b, c, d, e = h0, h1, h2, h3, h4
for i in range(80):
if i < 20:
f = (b & c) | (~b & d)
k = 0x5A827999
elif i < 40:
f = b ^ c ^ d
k = 0x6ED9EBA1
elif i < 60:
f = (b & c) | (b & d) | (c & d)
k = 0x8F1BBCDC
else:
f = b ^ c ^ d
k = 0xCA62C1D6
temp = ((a << 5) | (a >> 27)) + f + e + k + w[i]
temp &= MASK_32
e, d, c, b, a = d, c, (b << 30 | b >> 2) & MASK_32, a, temp
h0 = (h0 + a) & MASK_32
h1 = (h1 + b) & MASK_32
h2 = (h2 + c) & MASK_32
h3 = (h3 + d) & MASK_32
h4 = (h4 + e) & MASK_32
return [h0, h1, h2, h3, h4]
new_state = sha1_compress(new_message, h)
new_digest = struct.pack(">5I", *new_state)
forged_data = sha1_padding(orig_len) + append_data
return new_digest, forged_data
def main():
ctx = ssl.create_default_context()
for attempt in range(3):
try:
with ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM), server_hostname=HOST) as s:
s.settimeout(30)
s.connect((HOST, PORT))
banner = recv_until(s, b"Enter your username:")
print("received banner bytes", len(banner))
send_line(s, USERNAME)
time.sleep(0.2)
menu_intro = recv_until(s, b"Enter your choice:")
print("received menu bytes", len(menu_intro))
outputs: List[int] = []
has_menu = True
for i in range(64):
if i % 10 == 0:
print(f"roulette {i}/64")
if not has_menu:
recv_until(s, b"Enter your choice:")
send_line(s, "2")
recv_until(s, b"Enter your guess:")
send_line(s, "0")
recv_until(s, b"Enter color")
send_line(s, "R")
chunk = recv_until(s, b"Enter your choice:")
num = parse_roulette_num(chunk.decode(errors="ignore"))
outputs.extend(split_words(num, 8))
has_menu = True
for i in range(56):
if i % 10 == 0:
print(f"slot {i}/56")
if not has_menu:
recv_until(s, b"Enter your choice:")
send_line(s, "1")
chunk = recv_until(s, b"Enter your choice:")
out1, out2 = parse_wheels(chunk.decode(errors="ignore"))
outputs.append(out1)
outputs.append(out2)
has_menu = True
assert len(outputs) == 624
predictor = rebuild_state(outputs)
funcs = [hashlib.sha256] * 1024 + [hashlib.sha3_224] * 1023 + [hashlib.sha1]
predictor.shuffle(funcs)
skips = 0
while True:
ticket_id = predictor.randint(1, 11)
hash_idx = predictor.randint(0, len(funcs) - 1)
func = funcs[hash_idx]
if ticket_id > 5 and func == hashlib.sha1:
break
skips += 1
print("skips", skips)
for _ in range(skips):
if not has_menu:
recv_until(s, b"Enter your choice:")
send_line(s, "3")
recv_until(s, b"How much are you going to pay")
send_line(s, "0")
recv_until(s, b"Enter your choice:")
has_menu = True
if not has_menu:
recv_until(s, b"Enter your choice:")
send_line(s, "3")
recv_until(s, b"How much are you going to pay")
send_line(s, "1")
chunk = recv_until(s, b"Enter your choice:")
has_menu = True
text = chunk.decode(errors="ignore")
m_data = re.search(r"Voucher data:\\s*([0-9a-fA-F]+)", text)
m_code = re.search(r"Voucher code:\\s*([0-9a-fA-F]+)", text)
if not (m_data and m_code):
raise RuntimeError("failed to capture voucher")
voucher_data_hex = m_data.group(1)
voucher_code_hex = m_code.group(1)
orig_data = bytes.fromhex(voucher_data_hex)
orig_digest = bytes.fromhex(voucher_code_hex)
orig_len = 32 + len(orig_data)
append = f"|{TARGET_AMOUNT}".encode()
new_digest, forged_suffix = sha1_length_extend(orig_digest, orig_len, append)
forged_data_hex = (orig_data + forged_suffix).hex()
forged_code_hex = new_digest.hex()
if not has_menu:
recv_until(s, b"Enter your choice:")
send_line(s, "4")
recv_until(s, b"Enter voucher code")
send_line(s, forged_code_hex)
recv_until(s, b"Enter voucher data")
send_line(s, forged_data_hex)
recv_until(s, b"Enter your choice:")
has_menu = True
send_line(s, "6")
flag_chunk = recv_until(s, b"Enter your choice:")
print(flag_chunk.decode(errors="ignore"))
return
except Exception as exc:
print(f"attempt {attempt+1} failed: {exc}")
time.sleep(2)
raise SystemExit("all attempts failed")
if __name__ == "__main__":
main()
>Local strategy check
- By simulating draws locally with a fixed seed, we verified that 64 roulette and 56 slot outputs produce exactly 624 MT outputs and that untempering plus
setstatereproduces future outputs. - The SHA-1 length extension helper was unit-tested by comparing its forged digest against
hashlib.sha1(secret+forged_data).
>Remote run
- The solver was executed against the provided endpoint and printed the flag:
nite{9ty%_0f_g4mbler5_qu17_b3f0re_th3y_mak3_1t_big}- Progress logs (roulette/slot counters) were kept minimal to watch for drops; a retry loop handled occasional EOF/timeout.