miscfreehard

PyDome

HackTheBox

A Python TCP server challenge requiring you to send 100 comma-separated values that pass 4 levels of validation. The values index into a `story.txt` file to construct a string. The first 64 characters must match a SHA256 hash of a randomly generated secret, and the remaining characters must match a

$ ls tags/ techniques/
prng_stream_reusezip_empty_iterator_bypasssha256_hash_constructionoracle_leak

PyDome — HackTheBox

Description

Swing through the jungle of filters to snatch the flag!

A Python TCP server challenge requiring you to send 100 comma-separated values that pass 4 levels of validation. The values index into a story.txt file to construct a string. The first 64 characters must match a SHA256 hash of a randomly generated secret, and the remaining characters must match a "forest" ASCII art string. Successfully passing all 4 levels prints the flag. You get 5 attempts per connection.

Architecture

  • Server: Python TCP server (server.py)
  • PRNG: Python random module seeded from ANACONDA environment variable
  • Validation: 4 sequential levels, each checking different properties of the input
  • Attempts: 5 per connection, seed is fixed across connections

Validation Flow

Input (100 CSV values)
  │
  ├─ Level 1: len(input_arr) == 0x64 (100)
  │
  ├─ Level 2: Parse values → int_arr (indices into story.txt)
  │            - Digits → direct index (if in range)
  │            - Non-digits → ord(char) as index
  │            - If non_int_arr exists: r = randrange(0, len(non_int_arr))
  │              If r is odd: prints 100 random selections from non_int_arr
  │
  ├─ Level 3: user_input[:0x40] must == SHA256(secret)
  │            where secret = ''.join(chr(randrange(0,100)) for _ in range(randrange(0,100)))
  │
  └─ Level 4: all(x==y for x,y in zip(forest, user_input[0x40:]))

Analysis

Key Insight 1: Level 4 — zip() Empty Iterator Bypass

The forest string contains characters (/, ~, \, <, #, >, -) that do NOT exist in story.txt. It's impossible to construct the forest string from story.txt indices. However:

charset_check = [x == y for (x, y) in zip(forest, user_input[0x40:])] if not all(charset_check): return False

If user_input is exactly 64 characters long, then user_input[0x40:] is an empty string. zip(forest, "") produces an empty iterator, and all([]) returns True in Python. This completely bypasses Level 4.

How to achieve exactly 64 chars: Send 64 valid story.txt indices (for the SHA256 hash) + 36 out-of-range digit values (e.g., "9999"). Out-of-range digits pass isdigit() but fail 0 <= int(i) < len(story_data), so they're silently skipped and never added to int_arr.

Key Insight 2: PRNG Stream Reuse Attack

The critical breakthrough is recognizing that the random number stream is shared between the non-integer processing path (Level 2) and the secret generation (Level 3), and both paths consume randrange(0, N) with the same N = 100.

Non-integer path (when sending 100 single-char non-digit values):

# Level 2 consumes: r = random.randrange(0, len(non_int_arr)) # randrange(0, 100) → call #1 # If r is odd, prints 100 selections: for i in range(len(non_int_arr)): # 100× randrange(0, 100) → calls #2-101 random_non_int = non_int_arr[random.randrange(0, len(non_int_arr))]

All-digit path (when sending only digit values, 0 non-ints):

# Level 3 consumes (no Level 2 random calls since non_int_arr is empty): L = random.randrange(0, 0x64) # randrange(0, 100) → call #1 secret = [chr(random.randrange(0, 0x64)) for _ in range(L)] # L× randrange(0, 100)

Since 0x64 = 100 and len(non_int_arr) = 100, both paths use identical randrange(0, 100) calls from the same starting random state. Therefore:

  • L = r (same first call)
  • secret[i] = chr(selections[i]) (same subsequent calls)

The non-integer path leaks the selections when r is odd (prints "For example: X to Y" lines), giving us the exact random stream needed to compute the secret and its SHA256 hash.

Key Insight 3: story.txt as Hex Alphabet

story.txt is 1001 characters and contains all hex characters (0-9, a-f) at known positions. By mapping each hex character to its position in story.txt, any SHA256 hash (which is a 64-char hex string) can be constructed as 64 story.txt indices.

Solution

Phase 1: Leak the Random Stream

Connect to the server and send 100 distinct single-character non-digit values:

# Build charset: printable non-digit, non-comma chars # Avoid space at position 0 (input().strip() would remove it) # Avoid comma (used as separator) # Avoid digits (would take the int path) chars = [] for c in range(33, 127): ch = chr(c) if ch.isdigit() or ch == ',': continue chars.append(ch) # Add Unicode chars if needed to reach 100 for c in range(128, 200): if len(chars) >= 100: break chars.append(chr(c)) chars = chars[:100]

Send ','.join(chars) to the server. When r is odd (50% chance, deterministic for a given seed), the server prints 100 lines:

For example: X to Y

Parse each line to extract the selected character and its ord value. Since we know the mapping from character to its index in our array, we can reconstruct the exact randrange(0, 100) output for each of the 100 selection calls.

Phase 2: Compute Candidate SHA256 Hashes

We know r is odd but don't know its exact value. Compute 50 candidate hashes:

for r_candidate in range(1, 100, 2): # r = 1, 3, 5, ..., 99 secret = ''.join(chr(selections[i]) for i in range(r_candidate)) sha256hash = SHA256.new(secret.encode()).hexdigest() candidates.append(sha256hash)

Phase 3: Build story.txt Index Map

story = open("story.txt").read() hex_chars = "0123456789abcdef" char_to_index = {} for ch in hex_chars: idx = story.index(ch) char_to_index[ch] = idx

Phase 4: Try Each Candidate

For each candidate hash, open a new connection (attempt 1 always starts from the same random state when sending all-digits):

for candidate_hash in candidates: # Build payload: 64 story indices for hash + 36 padding indices = [str(char_to_index[ch]) for ch in candidate_hash] padding = ["9999"] * (100 - len(indices)) payload = ','.join(indices + padding) # Connect and send r = remote(host, port) r.sendline(payload.encode()) response = r.recvall() if "Level 4/4 PASSED" in response: # Found the correct hash! print(response) # Contains the flag break

The correct value of r was 31, found on the 16th connection attempt.

What Didn't Work (Dead Ends)

ApproachWhy It Failed
Brute-forcing ANACONDA seed (1-4 char printable, dictionary, rockyou)Seed space too large, none matched
z3 symbolic modeling of MT19937 init_by_arrayToo slow — 624 iterations of multiply-XOR-add
Trying all length-1 secrets (one per connection)100 connections needed, server kept expiring
C brute forcer for 5+ char seedsCompilation issues, and ultimately unnecessary

Lessons Learned

  1. PRNG stream reuse is devastating — When two code paths consume the same random stream with identical parameters, information leaked from one path directly reveals the other path's "secret" values.
  2. all([]) is a classic Python gotcha — An empty iterable always passes all(), enabling bypass of seemingly impossible checks.
  3. Silent failures create bypass opportunities — Out-of-range indices being silently skipped (not added to int_arr) allows controlling the exact length of the constructed string.
  4. Oracle attacks compound — Even a 50% chance of leaking (odd r) combined with 50 candidate hashes and fresh connections per attempt makes the attack highly practical.
  5. Read the code, not the description — The "forest" check looks impossible until you realize zip() with an empty string produces nothing to check.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups