ShinyHunter
ShinyHunter
Platform: HackTheBox | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-09 | Status: Solved Techniques: lcg_prediction, prng_seed_recovery, reconnection_bruteforce, timing_calibration
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260209_hackthebox_shinyhunter - Tags: prng, timing_attack, lcg, seed_prediction, pokemon, entropy_elimination, random
- Indicators: random.seed() with predictable value, LCG (Linear Congruential Generator), MAC address displayed to user, system_time reset to 0, battery_died = True
- Source:
20260209_hackthebox_shinyhunter.md
Foothold
Vulnerability / Misconfiguration
- Lcg_prediction
- Prng_seed_recovery
- Reconnection_bruteforce
- Timing_calibration
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- lcg_prediction
- prng_seed_recovery
- reconnection_bruteforce
- timing_calibration
- Tags: prng, timing_attack, lcg, seed_prediction, pokemon, entropy_elimination, random
Original Writeup
<details><summary>Click to expand original content</summary>Description
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
Files
chall.py— Python challenge server (runs via socat on port 1337 inside Docker)
Analysis
Source Code Structure
The challenge implements a Pokémon starter selection game with a shiny check:
- Random MAC generation:
get_mac()generates a random MAC address displayed in the boot logo - Battery died:
system_timeis reset to 0, eliminating real-world clock entropy - Seed computation:
time_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)
- Trainer IDs from seed:
def generate_ids(seed):
random.seed(seed)
tid = random.randint(0, 65535)
sid = random.randint(0, 65535)
return tid, sid
- Shiny check:
shiny_value = ((tid ^ sid) ^ (pid & 0xFFFF) ^ (pid >> 16))— shiny if< 8 - LCG:
lcg(seed, a=1664525, c=1013904223, m=2**32)— standard Numerical Recipes LCG - 3 starters generated with
seed+0,seed+1,seed+2
Vulnerability: Fully Deterministic PRNG
The 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.
Shiny Probability
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.
Solution
Strategy
- Extract MAC from the server's ASCII art logo
- Brute-force shiny times — for each possible
formatted_time(16–78), compute seed → tid/sid → check all 3 starters for shininess - Calibrate timing offset — server's
boot_timeis set ~2 seconds after TCP connection (socat fork + Python startup), soserver_formatted_time ≈ our_elapsed - 2 - Reconnect until favorable MAC — most MACs won't have a shiny in the feasible time window; keep reconnecting until finding one
- Wait and send — sleep until the target time, send name, select the correct starter
Solve Script
#!/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()
Timing Calibration Detail
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.
Lessons Learned
- "Battery died" = entropy elimination — resetting
system_timeto 0 removes real-world clock randomness, a common CTF pattern for signaling weak PRNG - Displayed values leak seed material — the MAC shown in the logo gives us half the seed
- Timing is controllable — when the server computes
time.time() - boot_timeat the moment we send input, we control the elapsed time - Calibration matters — the ~2 second offset between TCP connect and Python's
boot_time(socat fork + interpreter startup) had to be measured empirically - Reconnection strategy — since MAC is random per connection, reconnecting until finding a favorable MAC is more efficient than waiting for a single connection's shiny window
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR