Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
CryptoEasy

Hash Vegas

CTF writeup for Hash Vegas from niteCTF

//Hash Vegas

>Overview

  • Category: Crypto
  • Goal: Reach $1,000,000,000 balance to print the flag nite{...}.
  • Key bugs:
  1. MT19937 PRNG outputs leak fully through roulette and slot machine games, allowing complete state recovery.

  2. 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.py draws random.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.py draws two 32-bit choices per spin and displays all 16 nybbles as symbols (lossless mapping back to the 32-bit words).
  • chall.py allows at most 64 roulette rounds and 56 slot spins before the machines break. That yields 64*8 + 56*2 = 624 tempered outputs: exactly one full MT19937 state window.
  • lottery.py shuffles an array of 2048 hash functions (1024 sha256, 1023 sha3_224, 1 sha1) once, then for each ticket draws ticket_id = randint(1, 11) and hash_idx = randint(0, len(hash_funcs)-1). A voucher is issued only when ticket_id > 5.
  • Voucher generation: ticket_hash = hash_func((secret + ticket_data).encode()).digest()[:20], with ticket_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

  1. 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 random draws, including the hash function choice and ticket id.
  2. 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 |1000000000 to the voucher data and forge a winning payout.

>Exploit plan

  1. Connect and input username.
  2. 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.
  3. 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.
  4. Untemper all 624 words and rebuild the MT state via random.setstate.
  5. Recreate hash_funcs, shuffle it with the recovered PRNG, and simulate lottery draws until ticket_id > 5 and the chosen hash function is SHA-1. Count how many tickets to skip.
  6. 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).
  7. Buy the predicted winning ticket (pay 1). Capture voucher_data and voucher_code.
  8. Perform SHA-1 length extension with known original length = 32 (secret hex) + len(voucher_data). Append |1000000000, obtain forged digest and data.
  9. Redeem the forged voucher to set balance to $1,000,000,000.
  10. 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 setstate reproduces 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.