Browse CTFs New CTF Sign in

Timestamp-Seeded PRNG Exploitation: Predicting and Reproducing Time-Based Random Output

encoding_crypto_classical Difficulty 1–5 30 min certifiable

Theory

Cryptanalysis Methodology

Pseudo-random number generators (PRNGs) require an initial seed to determine their entire output sequence. When that seed is derived from the system clock — a common shortcut in scripts and CTF challenge generators — the seed space collapses from 2^128 (for a cryptographically secure generator) to a window of a few seconds or minutes, typically 10^9 to 10^11 possible values for Unix timestamps in seconds or milliseconds. An attacker who knows roughly when the PRNG was seeded can enumerate every plausible seed and regenerate the keystream.

Why developers use timestamp seeds. random.seed(int(time.time())) is the path of least resistance for scripts that need "different" output each run without thinking carefully about security. CTF challenges deliberately use this pattern to test whether competitors understand the distinction between a PRNG and a CSPRNG.

Attack prerequisites. To exploit a timestamp seed, the analyst needs: 1. Evidence that the ciphertext was generated with a timestamp-seeded PRNG (source code leak, decompiled generator, challenge description). 2. An approximate timestamp range for when the ciphertext was generated. Sources: file modification time, network packet capture timestamp, challenge upload time, certificate notBefore, or any timestamp embedded in the ciphertext file itself. 3. A way to verify a candidate decryption — most commonly, the known plaintext structure of the flag format (CTF{...}).

Seed window sizing. If the timestamp is known to second precision and the generation window is ±5 minutes, the search space is 601 seeds. At millisecond precision with ±1 minute window, the space is 120,000 seeds. Both are trivially enumerable on modern hardware in under a second.

Technical Deep-Dive

import random, time, itertools

def encrypt_with_seed(plaintext: bytes, seed: int) -> bytes:
    """Simulate the challenge generator: XOR with PRNG bytes derived from seed."""
    rng = random.Random(seed)
    keystream = bytes(rng.randint(0, 255) for _ in range(len(plaintext)))
    return bytes(p ^ k for p, k in zip(plaintext, keystream))

def decrypt_with_seed(ciphertext: bytes, seed: int) -> bytes:
    """Decryption is identical to encryption for XOR."""
    return encrypt_with_seed(ciphertext, seed)

def brute_timestamp_seed(
    ciphertext: bytes,
    approx_ts: int,
    window_seconds: int = 300,
    flag_prefix: bytes = b'CTF{',
) -> list[tuple[int, bytes]]:
    """
    Try all integer seeds in [approx_ts - window, approx_ts + window].
    Return list of (seed, plaintext) for candidates matching flag_prefix.
    """
    hits = []
    for seed in range(approx_ts - window_seconds, approx_ts + window_seconds + 1):
        pt = decrypt_with_seed(ciphertext, seed)
        if pt.startswith(flag_prefix):
            hits.append((seed, pt))
    return hits

# --- Millisecond-precision brute force ---
def brute_ms_seed(
    ciphertext: bytes,
    approx_ms: int,
    window_ms: int = 60_000,
    flag_prefix: bytes = b'CTF{',
) -> list[tuple[int, bytes]]:
    hits = []
    for seed in range(approx_ms - window_ms, approx_ms + window_ms + 1):
        pt = decrypt_with_seed(ciphertext, seed)
        if pt.startswith(flag_prefix):
            hits.append((seed, pt))
    return hits

# --- Usage ---
# import os
# approx_ts = int(os.path.getmtime("ciphertext.bin"))
# hits = brute_timestamp_seed(open("ciphertext.bin","rb").read(), approx_ts)
# for seed, pt in hits:
#     print(f"Seed {seed}: {pt}")
# If the challenge uses numpy or os.urandom-seeded Mersenne Twister,
# the same brute-force principle applies but the PRNG class differs:
import numpy as np

def numpy_keystream(seed: int, length: int) -> bytes:
    rng = np.random.default_rng(seed)   # PCG64 in numpy >= 1.17
    # For legacy numpy.random.seed (MT19937):
    np.random.seed(seed)
    return bytes(np.random.randint(0, 256, size=length, dtype=np.uint8))
# Get file modification time as Unix timestamp
stat -c %Y ciphertext.bin

# Brute-force in bash using Python one-liner
python3 -c "
import random, sys
ct = open('ciphertext.bin','rb').read()
ts = int(sys.argv[1])
for seed in range(ts - 60, ts + 60):
    random.seed(seed)
    pt = bytes(random.randint(0,255)^c for c in ct)
    if pt[:4] == b'CTF{':
        print(seed, pt)
" "$(stat -c %Y ciphertext.bin)"

Common Cryptanalysis Errors

1. Using the wrong time precision. Some generators use int(time.time()) (second precision) while others use int(time.time() * 1000) (millisecond). If second-precision brute force fails, try millisecond-precision with the same approximate timestamp.

2. Assuming the seed equals the current time. The seed may be int(time.time()) + some_constant or int(time.time()) ^ magic. Read the source or decompile the generator to confirm the exact seeding expression before brute-forcing.

3. Timezone confusion. time.time() returns UTC epoch seconds regardless of locale. File modification timestamps reported by stat may be in local time depending on the tool. Always work in UTC when computing the search window.

4. Off-by-one in seed enumeration. The search range must be inclusive on both ends: range(ts - window, ts + window + 1). Python's range is exclusive at the upper bound; forgetting the +1 drops the last candidate.

5. Not verifying sufficiency of the flag prefix. A 4-byte prefix match (CTF{) can produce false positives at the rate of roughly 1 in 2^32 per candidate — negligible for a 600-candidate search, but meaningful for a 10^9-candidate search. For large windows, also verify the closing } or require more known plaintext bytes.

6. Forgetting Python's random is not thread-safe. In parallel brute-force scripts, each thread must instantiate its own random.Random(seed) object rather than calling the module-level random.seed(), which modifies shared state.

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.