PicoCTF 2025 Reverse Engineering—Chronohack Writeup
Welcome back to my writeup, today I’ll walk you through the detailed, step-by-step process I used to tackle the Chronohack challenge from picoCTF 2025. Let's dive in!!

In this challenge, we are given a Python file named token_generator.py
.
import random
import time
def get_random(length):
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
random.seed(int(time.time() * 1000)) # seeding with current time
s = ""
for i in range(length):
s += random.choice(alphabet)
return s
def flag():
with open('/flag.txt', 'r') as picoCTF:
content = picoCTF.read()
print(content)
def main():
print("Welcome to the token generation challenge!")
print("Can you guess the token?")
token_length = 20 # the token length
token = get_random(token_length)
try:
n=0
while n < 50:
user_guess = input("\nEnter your guess for the token (or exit):").strip()
n+=1
if user_guess == "exit":
print("Exiting the program...")
break
if user_guess == token:
print("Congratulations! You found the correct token.")
flag()
break
else:
print("Sorry, your token does not match. Try again!")
if n == 50:
print("\nYou exhausted your attempts, Bye!")
except KeyboardInterrupt:
print("\nKeyboard interrupt detected. Exiting the program...")
if __name__ == "__main__":
main()
Examining the token_generator.py
file, we notice around line 6 that the Python Pseudo Random Number Generator (PRNG) is seeded using the current system time:
random.seed(int(time.time() * 1000)) # seeded with current time
Here, time.time()
returns the current time in seconds since the Unix epoch as a floating-point number. Multiplying it by 1000 converts it to milliseconds, and casting it to an integer gives us a millisecond-precision seed value. This means that the randomness of the token heavily depends on the exact moment the generator runs.
The core idea of the attack is to replicate the token locally by running the same token generation code using an estimated seed close to the server's actual time. If the seed is accurate or very close, both the local and remote token generators will produce the same output. In practice, however, minor discrepancies in system clocks and server-side delays (such as instance spawn time or network latency) can cause mismatches. To account for this, we can brute-force a small range of timestamps around our estimated seed—adjusting slightly forward and backward—to increase the chances of syncing up with the server’s random output and correctly guessing the token.
For this challenge, I opted to use the picoCTF webshell instead of my local Linux environment due to network latency issues. The webshell offers a much faster and more responsive connection, which is crucial for solving this challenge since precise timing and minimal delay are key to successfully predicting the token.
This is our exploit
#!/usr/bin/env python3
from pwn import *
import random
import time
# Function to generate a pseudo-random string based on a given seed
def create_random_string(size, seed_value):
characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
random.seed(seed_value) # Seed the PRNG with the specified value
result = ""
for _ in range(size):
result += random.choice(characters) # Pick a random character for each position
return result
flag_discovered = False # Flag to determine when the correct token is found
# Loop through a range of offset values to account for time desync/latency
for offset in range(-50, 1000, 40):
print(f"--- Offset Value: {offset} ---")
begin_time = time.time() # Record the approximate current time as base
server_conn = remote("verbal-sleep.picoctf.net", 58616) # Connect to challenge server
# Try up to 50 token guesses for each offset
for attempt in range(50):
print(f"[*] Attempt #{attempt + offset}")
seed = int(begin_time * 1000) + attempt + offset # Construct seed with offset variation
random_token = create_random_string(20, seed).encode("utf-8") # Generate token guess
response = server_conn.recvuntil(b'(or exit):') # Wait for the input prompt from server
print(f"[*] Sending token: {random_token.decode()}")
server_conn.sendline(random_token) # Submit the generated token
server_response = server_conn.readline() # Read the response
# Check if the response is success (i.e., not starting with "Sorry")
if not server_response.startswith(b'Sorry'):
print("=" * 100)
print(f"[+] Success Response: {server_response.decode()}")
print(f"[+] Flag: {server_conn.recvline().decode()}") # Read and display the flag
flag_discovered = True # Set flag to terminate outer loop
break
print(f"[*] Response: {server_response.decode()}")
server_conn.close()
if flag_discovered:
break
Run and we should get the flag!!!

Some of you might be wondering, "Wait a second, why does the script make more than 50 attempts? Isn’t the challenge supposed to limit us to 50 guesses max?"
That’s a great question, and the answer lies in how the challenge enforces the attempt limit and how the script works around it.
Here’s the relevant part of the challenge code
n = 0
while n < 50:
user_guess = input("\nEnter your guess for the token (or exit):").strip()
n += 1
In this block, the variable n
counts how many guesses you’ve made. Once n
reaches 50, the challenge exits. That means you only get 50 guesses per program execution — not per user or IP, just per run of the script on the server.
THE LIMIT IS PER-SESSION
What this means is that every time you connect to the server, the challenge script starts from scratch, n
is set back to 0
, a new random token is generated, and you get another set of 50 guesses.
Now let's look at our exploit logic
for offset in range(-50, 1000, 40):
server_conn = remote("verbal-sleep.picoctf.net", 58616)
for attempt in range(50):
...
server_conn.close()
Every time the outer loop (for offset in ...
) runs:
A new connection is made to the server.
The challenge script restarts.
The attempt counter
n
is reset to 0 on the server side.You get a fresh 50 tries.
So even though you're brute-forcing potentially hundreds of guesses, you're never making more than 50 guesses per connection, and that's perfectly within the challenge rules.^^
Last updated