$ cat writeup.md…
$ cat writeup.md…
hackthebox
A Pokémon-themed challenge where you connect to a remote service that simulates a "Poketmon" game. You need to obtain a shiny Pokémon to get the flag. The title "ShinyHunter" hints at Pokémon shiny hunting and PRNG manipulation.
A Pokémon-themed challenge where you connect to a remote service that simulates a "Poketmon" game. You need to obtain a shiny Pokémon to get the flag. The title "ShinyHunter" hints at Pokémon shiny hunting and PRNG manipulation.
Remote: 154.57.164.78:32331
chall.py — Python challenge server (runs via socat on port 1337 inside Docker)The challenge implements a Pokémon starter selection game with a shiny check:
get_mac() generates a random MAC address displayed in the boot logosystem_time is reset to 0, eliminating real-world clock entropytime_passed = time.time() - boot_time dialog_time = system_time + time_passed # = 0 + time_passed formatted_time = int(dialog_time) initial_seed = int(formatted_time + int(device_mac.replace(":", ""), 16)) seed = lcg(initial_seed)
def generate_ids(seed): random.seed(seed) tid = random.randint(0, 65535) sid = random.randint(0, 65535) return tid, sid
shiny_value = ((tid ^ sid) ^ (pid & 0xFFFF) ^ (pid >> 16)) — shiny if < 8lcg(seed, a=1664525, c=1013904223, m=2**32) — standard Numerical Recipes LCGseed+0, seed+1, seed+2The PRNG seed is completely predictable:
| Component | Status | Why |
|---|---|---|
| MAC address | Known | Displayed in the ASCII art logo output |
system_time | Zero | battery_died = True resets it to 0 |
time_passed | Controllable | We control when we send input (name), which triggers seed computation |
formatted_time | Predictable | int(time_passed) — integer seconds since boot |
Given the seed, all random values (tid, sid, pid) are fully predictable, so we can determine which starters will be shiny before choosing.
The shiny check shiny_value < 8 with 16-bit XOR values gives probability 8/65536 ≈ 0.012% per Pokémon. With 3 starters and ~62 possible time values, the chance of finding a shiny per connection is roughly 3 × 8/65536 × 62 ≈ 2.3%, requiring ~40 reconnections on average.
formatted_time (16–78), compute seed → tid/sid → check all 3 starters for shininessboot_time is set ~2 seconds after TCP connection (socat fork + Python startup), so server_formatted_time ≈ our_elapsed - 2#!/usr/bin/env python3 from pwn import * import time, random, re, sys HOST = "154.57.164.78" PORT = 32331 OFFSET = 2 # Calibrated: server_ft = our_int_elapsed - OFFSET def lcg(seed, a=1664525, c=1013904223, m=2**32): return (a * seed + c) % m def generate_ids(seed): random.seed(seed) tid = random.randint(0, 65535) sid = random.randint(0, 65535) return tid, sid def check_shiny(seed, tid, sid): random.seed(seed) # Skip 6 IV rolls for _ in range(6): random.randint(20, 31) # Skip nature natures = [ "Adamant","Bashful","Bold","Brave","Calm","Careful","Docile", "Gentle","Hardy","Hasty","Impish","Jolly","Lax","Lonely","Mild", "Modest","Naive","Naughty","Quiet","Quirky","Rash","Relaxed", "Sassy","Serious","Timid" ] random.choice(natures) # Get PID pid = random.randint(0, 2**32 - 1) shiny_value = ((tid ^ sid) ^ (pid & 0xFFFF) ^ (pid >> 16)) return shiny_value < 8 def find_shiny_in_range(mac_str, min_t, max_t): mac_int = int(mac_str.replace(":", ""), 16) results = [] for ft in range(min_t, max_t): seed = lcg(ft + mac_int) tid, sid = generate_ids(seed) for i in range(3): if check_shiny(seed + i, tid, sid): results.append((ft, i + 1)) # (formatted_time, starter_choice) return results context.log_level = 'info' for attempt in range(1, 501): log.info(f"Attempt {attempt}") r = remote(HOST, PORT, timeout=60) boot_time = time.time() # Step 1: Read logo and extract MAC data = b"" while b"Mac Address:" not in data: data += r.recv(4096, timeout=10) mac_match = re.search(r"Mac Address:\s*([0-9a-f:]{17})", data.decode(errors="replace")) mac_str = mac_match.group(1) log.info(f"MAC: {mac_str}") # Step 2: Check if this MAC has a shiny in feasible time range shiny_times = find_shiny_in_range(mac_str, 16, 78) if not shiny_times: log.info("No shiny possible with this MAC, reconnecting...") r.close() time.sleep(0.2) continue log.info(f"Shiny possible at: {shiny_times}") # Step 3: Wait for name prompt while b"Enter your name:" not in data: data += r.recv(4096, timeout=5) # Step 4: Pick the best target time (first one still reachable) elapsed = time.time() - boot_time target_ft, target_choice = next( ((ft, c) for ft, c in shiny_times if ft + OFFSET > elapsed + 1), (None, None) ) if not target_ft: log.info("All shiny times already passed, reconnecting...") r.close() continue # Step 5: Wait until the right moment and send name wait = target_ft + OFFSET + 0.5 - (time.time() - boot_time) if wait > 0: log.info(f"Waiting {wait:.1f}s for formatted_time={target_ft}") time.sleep(wait) r.sendline(b"Ash") # Step 6: Select the correct starter data2 = b"" while b"Choose your starter" not in data2: data2 += r.recv(4096, timeout=10) r.sendline(str(target_choice).encode()) log.info(f"Selected starter {target_choice}") # Step 7: Check for flag time.sleep(2) result = b"" try: while True: result += r.recv(4096, timeout=30) except: pass output = result.decode(errors="replace") flag = re.search(r"HTB\{[^}]+\}", output) if flag: log.success(f"FLAG: {flag.group(0)}") r.close() sys.exit(0) else: log.warning("Not shiny, timing may be off. Retrying...") r.close()
The critical insight is the ~2 second offset between our connection time and the server's boot_time:
Timeline:
T+0.0s — Our TCP connect (boot_time on our side)
T+0.5s — socat accepts, forks child
T+1.5s — Python interpreter starts, sets boot_time
T+2.0s — Server's boot_time is set (OFFSET = 2)
T+20s — "Enter your name:" prompt arrives
T+Xs — We send name → time_passed = X - 2 → formatted_time = int(X - 2)
To hit formatted_time = 30, we send at T + 30 + 2 + 0.5 = T + 32.5s from our connection.
system_time to 0 removes real-world clock randomness, a common CTF pattern for signaling weak PRNGtime.time() - boot_time at the moment we send input, we control the elapsed timeboot_time (socat fork + interpreter startup) had to be measured empirically$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar