← Back to Writeups
HTBN/AMisc

ShinyHunter

XESXOR8/23/20267 min read
#misc#htb#n/a

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
PortServiceVersionNotes
<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

  1. Lcg_prediction
  2. Prng_seed_recovery
  3. Reconnection_bruteforce
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  1. Random MAC generation: get_mac() generates a random MAC address displayed in the boot logo
  2. Battery died: system_time is reset to 0, eliminating real-world clock entropy
  3. 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)
  1. Trainer IDs from seed:
   def generate_ids(seed):
       random.seed(seed)
       tid = random.randint(0, 65535)
       sid = random.randint(0, 65535)
       return tid, sid
  1. Shiny check: shiny_value = ((tid ^ sid) ^ (pid & 0xFFFF) ^ (pid >> 16)) — shiny if < 8
  2. LCG: lcg(seed, a=1664525, c=1013904223, m=2**32) — standard Numerical Recipes LCG
  3. 3 starters generated with seed+0, seed+1, seed+2

Vulnerability: Fully Deterministic PRNG

The PRNG seed is completely predictable:

ComponentStatusWhy
MAC addressKnownDisplayed in the ASCII art logo output
system_timeZerobattery_died = True resets it to 0
time_passedControllableWe control when we send input (name), which triggers seed computation
formatted_timePredictableint(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

  1. Extract MAC from the server's ASCII art logo
  2. Brute-force shiny times — for each possible formatted_time (16–78), compute seed → tid/sid → check all 3 starters for shininess
  3. Calibrate timing offset — server's boot_time is set ~2 seconds after TCP connection (socat fork + Python startup), so server_formatted_time ≈ our_elapsed - 2
  4. Reconnect until favorable MAC — most MACs won't have a shiny in the feasible time window; keep reconnecting until finding one
  5. 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

  1. "Battery died" = entropy elimination — resetting system_time to 0 removes real-world clock randomness, a common CTF pattern for signaling weak PRNG
  2. Displayed values leak seed material — the MAC shown in the logo gives us half the seed
  3. Timing is controllable — when the server computes time.time() - boot_time at the moment we send input, we control the elapsed time
  4. Calibration matters — the ~2 second offset between TCP connect and Python's boot_time (socat fork + interpreter startup) had to be measured empirically
  5. 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
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR