Description Can you guess the exact token and unlock the hidden flag? Our school relies on tokens to authenticate students. Unfortunately, someone leaked an important file for token generation. Guess the token to get the flag. Additional details will be available after launching your challenge instance.
Security bugs are often not about complex cryptography, but about wrong assumptions. One of the most common — and dangerous — assumptions is that the current time is random enough for secrets.
In this write-up, we'll analyze the Chronohack challenge from picoCTF and show how a seemingly large token space can be cracked through clever timing and knowledge of Python's pseudorandom number generator.
Understanding the Challenge
When launched, Chronohack presents a remote service asking you to guess a token:
- Token length: 20 characters
- Alphabet:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz - You get up to 50 guesses per connection
- If you guess correctly, the server prints the flag
The service leaks the token generation code, and that's where the vulnerability lies.
The Token Generator
The server generates the token using this logic:
random.seed(int(time.time() * 1000))
token = ''.join(random.choice(ALPHABET) for _ in range(20))So:
- It uses Python's
randommodule - It seeds with the current time in milliseconds
- The entire token is a deterministic function of that seed
Breaking the Generator
1) Python's random is Predictable
The Python standard library's random the module uses a deterministic Mersenne Twister PRNG.
If you know the seed, you can reproduce the same sequence of random choices.
That means perfectly duplicating the server's token generation is possible -if you can guess the seed.
2) The Seed Is Time-Based
The server seeds the RNG with:
int(time.time() * 1000)This gives the current Unix time in milliseconds.
While we don't know the exact server timestamp, we do know it is close to our own clock plus network delay. This drastically limits the brute-force window: instead of 62²⁰possibilities, we need only to search for a few hundred milliseconds.
3) Multiple Attempts Per Connection
Critically, the server keeps the token fixed for up to 50 guesses. This allows us to repeatedly try predicted tokens on a single connection without reconnecting each time.
If the token were regenerated every guess, this attack wouldn't work.
Exploit Strategy
The strategy is to:
- Open a connection and note the local time in milliseconds
- Account for network latency by trying a range of nearby seed values
- For each seed and each guess index (0–49), generate a candidate token
- Submit these tokens until one matches
Instead of trying enormous token combinations, we try seeds like:
possible_seed ≈ int(local_time_ms) + offset + guess_indexWhere offset compensates for differences between local and server clocks.
The Working Exploit
Here's the complete exploitation script that demonstrates this in practice:
HOST = "verbal-sleep.picoctf.net"
PORT = 64083
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def gen(seed):
random.seed(seed)
return "".join(random.choice(ALPHABET) for _ in range(20))
for adjust in range(-50, 1000, 40):
with socket.create_connection((HOST, PORT)) as s:
start = int(time.time() * 1000)
s.recv(1024)
for i in range(50):
seed = start + i + adjust
token = gen(seed)
s.sendall(token.encode() + b"\n")
resp = s.recv(1024)
if b"Sorry" not in resp:
print("FLAG FOUND")
print(resp.decode())
print(s.recv(1024).decode())
exit()Why This Works
Time Is Predictable
Time is not random — it's ever increasing. Since the server seeds with a current timestamp, an attacker can approximate the seed to within a few milliseconds.
random Is Not Cryptographically Secure
Python's random module is not intended for security. It's deterministic, hence predictable.
The Token Is Reused
Had the server changed the token after each failed guess, this technique would fail.
How to Fix This
If you're implementing real token generation, never use:
random.seed(time.time())Instead, use a cryptographically secure random source, such as:
import secrets
token = secrets.token_urlsafe(32)Or:
token = secrets.choice(ALPHABET) for _ in range(20)Python's secrets the module is designed for exactly this use case.
Conclusion
The Chronohack challenge beautifully illustrates how a tiny mistake — using predictable time-based seeding — can reduce an intractable problem to a small brute-force search.
From an apparent 62²⁰ search space, the flaw turns the problem into guessing a 200–1000 ms range of timestamps.