$ cat writeup.md…
$ cat writeup.md…
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
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.
server.py)random module seeded from ANACONDA environment variableInput (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:]))
zip() Empty Iterator BypassThe 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.
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.
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.
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.
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)
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
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.
| Approach | Why 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_array | Too 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 seeds | Compilation issues, and ultimately unnecessary |
all([]) is a classic Python gotcha — An empty iterable always passes all(), enabling bypass of seemingly impossible checks.int_arr) allows controlling the exact length of the constructed string.r) combined with 50 candidate hashes and fresh connections per attempt makes the attack highly practical.zip() with an empty string produces nothing to check.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar