Chronohack picoCTF 2025 Solution

Published: April 2, 2025

Description

You intercepted ABC School's token generator. It seeds Python's Mersenne Twister with int(time.time()*1000) and gives you 50 guesses. Synchronize on the server time, brute-force the nearby millisecond range, and submit the matching token.

Study token_generator.py to learn the alphabet, seed, and token length (20 characters).

Connect to nc verbal-sleep.picoctf.net <PORT_FROM_INSTANCE> and synchronize with the server time when sending guesses.

bash
pip install pwntools
python
python3 script.py # see snippet below

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Replicate get_random
    Observation
    I noticed the server's token_generator.py seeds Python's built-in random module with int(time.time()*1000) and builds tokens via random.choice(alphabet), which suggested that replicating the exact same function locally with a matching seed would reproduce the server's output identically.
    Reimplement the provided get_random() locally but accept a custom time argument. Seed with int(t*1000) to mimic the challenge. MT19937 is deterministic: same seed produces the exact same sequence, so replicating get_random with the right seed produces an identical token.
    Learn more

    Python's built-in random module uses the Mersenne Twister algorithm (MT19937), a pseudo-random number generator (PRNG) designed for statistical quality, not cryptographic security. Its critical property here is that it is deterministic: given the same seed, it will always produce the exact same sequence of numbers. This means anyone who knows the seed can reproduce all past and future outputs.

    When the seed is derived from time.time() - the current Unix timestamp - the seed space is severely limited. Using millisecond precision (int(time.time() * 1000)) gives only about 1,000 possible seeds per second. An attacker who can estimate when the token was generated (for example, by measuring round-trip time to the server) can enumerate all plausible seeds in a fraction of a second.

    The correct approach for token generation is to use cryptographically secure random number generators: secrets.token_hex() or os.urandom() in Python, which draw from the operating system's entropy pool (e.g., /dev/urandom on Linux). These are non-deterministic and cannot be predicted even with knowledge of the current time.

  2. Step 2
    Brute-force nearby timestamps
    Observation
    I noticed the server seeds with millisecond precision (int(time.time()*1000)) and allows 50 guesses, which suggested that enumerating a small window of candidate milliseconds around the client's recorded connection time would cover the realistic network latency gap and yield the correct seed within the allowed attempts.
    Record t0 = int(time.time() * 1000) at connect time and assume the server seeded within ±25 ms of t0 (network latency under 50 ms). Iterate that centered window. For each candidate ms, reseed locally, generate the 20-char token, and submit. Typical RTT is 10-100 ms, so ±25 ms covers most internet latencies; if no hit, network jitter or server clock skew shifted the range, so re-run.
    python
    from pwn import *
    import random, time
    
    def get_random(length, t_ms):
        alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
        random.seed(t_ms)
        return "".join(random.choice(alphabet) for _ in range(length))
    
    p = remote("verbal-sleep.picoctf.net", 64704)
    base_ms = int(time.time() * 1000)
    
    for offset in range(-25, 25):  # 50 guesses centered on now
        candidate_ms = base_ms + offset
        guess = get_random(20, candidate_ms)
        p.recvuntil(b": ")            # prompt ends with ": " (defensive)
        p.sendline(guess.encode())
        line = p.recvline()
        if b"Congratulations" in line or b"picoCTF{" in line:
            print(line.decode(errors="replace"))
            print(p.recvall(timeout=2).decode(errors="replace"))
            break
    else:
        print("[!] No hit. Re-run; network jitter may have shifted the window.")

    Expected output

    picoCTF{UseSecure#$_Random@j3n3r@T0rsd...}
    What didn't work first

    Tried: Use random.randint or random.random directly instead of replicating the server's exact get_random function with random.choice over the alphabet.

    The token is built by calling random.choice(alphabet) 20 times after seeding, which advances the MT19937 state in a specific sequence. Replacing it with random.randint or sampling differently calls a different number of internal state transitions, producing a completely different output even with the correct seed. The exploit only works if the local replica calls the same sequence of random operations as the server.

    Tried: Set the search window to ±5 ms instead of ±25 ms to reduce guesses and avoid hitting the 50-guess limit.

    A ±5 ms window covers only 10 candidate seeds, which is too narrow for real internet latency between client and server. Typical RTT on the open internet is 10-100 ms, meaning the server's seed timestamp can easily differ from the client's recorded time by 15-30 ms. Narrowing the window causes frequent misses and wasted attempts; ±25 ms with 50 total guesses is calibrated to cover realistic latency while staying within the allowed guess budget.

    Learn more

    Pwntools is a Python library built for CTF exploit development and binary exploitation. Its remote() function creates a socket connection and exposes convenient methods like recvuntil() (block until a specific byte sequence is received), sendline() (send data followed by a newline), and interactive() (hand the connection to the user for manual interaction). This allows complex multi-round protocol interactions to be scripted precisely.

    The attack works because network latency introduces only a small, bounded clock difference between client and server. By recording the client's timestamp at connection time and scanning a small window of milliseconds around it, the attacker covers the range of seeds the server could have used. With 50 guesses allowed and a roughly 50 ms window to search, the probability of success is very high.

    This class of vulnerability is called a time-based seed attack or temporal PRNG attack. Real-world examples include PHP's early mt_rand(time()) usage for session tokens, early versions of WordPress generating password reset tokens from the current time, and various Java applications using new Random(System.currentTimeMillis()). All were vulnerable to the same prediction technique demonstrated here. The pwntools for CTF guide covers the recvuntil/sendline patterns used in this exploit, and Python for CTF covers the random/secrets module distinctions.

Flag

Reveal flag

picoCTF{UseSecure#$_Random@j3n3r@T0rsd...}

Network jitter means you may need to rerun the script, but scanning a 50 ms window is enough.

Key takeaway

General-purpose PRNGs like Python's Mersenne Twister are deterministic: knowing the seed means knowing every value the generator will ever produce. Seeding from the current timestamp collapses the seed space to whatever window of time an attacker can plausibly guess, which for networked services is often just a few thousand milliseconds. Any security-sensitive value (session tokens, password reset codes, API keys) must come from a cryptographically secure source that draws from the OS entropy pool, making prediction infeasible regardless of timing knowledge.

How to prevent this

Seeding a PRNG with the current time gives you ~1 second of entropy. The flag literally says use a secure RNG.

  • Use a CSPRNG: secrets.token_bytes() in Python, crypto.randomBytes() in Node, /dev/urandom in shell, rand::thread_rng() in Rust. Never random.seed(time()) for anything security-relevant.
  • For tokens, IDs, and keys, generate at least 128 bits of entropy. secrets.token_urlsafe(32) gives 256 bits and is shareable in URLs without escaping.
  • Audit your code for Math.random(), random.random(), rand(), and any time-based seeding. Linters (Bandit B311, ESLint security plugin) flag these automatically.

Related reading

Want more picoCTF 2025 writeups?

Useful tools for Reverse Engineering

What to try next