Timestamped Secrets picoCTF 2026 Solution

Published: March 20, 2026

Description

Someone encrypted a message using AES in ECB mode but they weren't very careful with their key. Turns out it's derived from something as simple as the current time! Download the encrypted message: message.txt and the encryption script: encryption.py .

Download message.txt and encryption.py.

Read encryption.py to understand exactly how the key is derived from the timestamp.

bash
cat encryption.py
bash
cat message.txt

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Understand the key derivation
    Observation
    I noticed the challenge description stated the AES key was derived from the current time, which suggested that the key space was not 2^256 but only the small set of plausible timestamps, making brute force feasible before writing a single line of attack code.
    The AES key is SHA-256(unix_timestamp), where the timestamp is the integer number of seconds since the Unix epoch at encryption time. Crucially, message.txt hands you that timestamp directly: it opens with a line like Hint: The encryption was done around 1770242610 UTC, followed by the hex ciphertext. So you barely have to brute-force at all.
    Learn more

    Using the current timestamp as an encryption key is a classic example of low-entropy key generation. A Unix timestamp is a 32-bit integer representing seconds since January 1, 1970. At the time of the challenge, there have been roughly 1.7 billion seconds since the epoch - but if the encryption happened within a few hours of the challenge release, the search space is only ~10,000 timestamps. Wrapping the timestamp in SHA-256 does not help because SHA-256 is deterministic: the same timestamp always produces the same key.

    Cryptographic keys must be generated from a cryptographically secure random number generator (CSPRNG). Python's secrets module and os.urandom() provide CSPRNG output suitable for key generation. Using timestamps, sequential numbers, or other predictable values as keys reduces the effective key space from 2^128 (for a 128-bit AES key) to the much smaller space of plausible timestamp values - trivially attackable by brute force.

    This vulnerability class appears in real-world software. PHP's old rand() function was seeded with the current time in some configurations, making session tokens predictable. Older versions of OpenSSL had bugs that reduced the entropy of generated keys. The Debian OpenSSL debacle (2008) accidentally removed entropy sources from the random number generator, making all SSL keys generated on Debian from 2006-2008 predictable and compromised.

  2. Step 2
    Read the timestamp from the hint
    Observation
    I noticed message.txt contains a line beginning with 'Hint: The encryption was done around <TS> UTC', which suggested the script had printed its own key seed into the ciphertext file, reducing the search to reading that integer rather than guessing a wide time window.
    Parse the integer out of the Hint: The encryption was done around <TS> UTC line. That single value is almost certainly the exact key seed. Because the hint says 'around', the real encryption second may be off by a little, so sweep a small window (a few hundred seconds either side) to be safe instead of trusting one exact value.
    bash
    # The timestamp is printed in message.txt itself:
    bash
    head -1 message.txt        # -> Hint: The encryption was done around 1770242610 UTC
    bash
    # Pull just the integer:
    bash
    grep -oE '[0-9]{10}' message.txt | head -1

    Expected output

    1770242610
    What didn't work first

    Tried: Treat the hinted timestamp as the exact seed and decrypt with only that single value.

    The hint says 'around', so the script may have called int(time.time()) one or two seconds before or after the printed value. A single-guess attempt decrypts to garbled bytes and you assume the approach is wrong. Sweeping a window of even +/- 300 seconds (601 candidates) catches the true seed without meaningful extra cost.

    Tried: Parse the ciphertext from message.txt using the same grep that extracts the timestamp.

    The regex '[0-9]{10}' matches the 10-digit timestamp but the ciphertext is a much longer hex string. Running head -1 then reusing the same grep pattern on the full file will pull the timestamp integer again instead of the ciphertext hex block. The ciphertext line is labelled 'Ciphertext (hex):' - read the second token on that line rather than the first 10-digit match.

    Learn more

    Why barely any brute force is needed. The encryptor printed its own int(time.time()) into the message, so you are not guessing a wall-clock value at all - you are reading it. The only reason to loop is the word "around": if the script computed the timestamp a moment before or after the second it printed, a single exact guess could miss. A ±300-second sweep (601 candidates) covers that comfortably and still finishes instantly.

    Search-space math, for when the hint is absent. If a variant of this challenge omitted the hint, you would anchor on metadata (file mtime via stat, git log on the file, or the competition start date) and sweep a wider range. Unix timestamps are integer seconds, so 24 hours = 86,400 candidates and 30 days = 2,592,000. PyCryptodome does ~100,000-500,000 AES decryptions per second per core, so even a 30-day window finishes in tens of seconds. Here, though, the printed hint makes all of that unnecessary.

    More broadly, timestamp-based attacks apply anywhere security depends on a wall-clock value the attacker can guess. Session tokens seeded with time(), predictable captcha IDs, shuffling algorithms in online gambling, weak TOTP implementations - all variants of the same temporal-enumeration weakness.

  3. Step 3
    Brute-force the timestamp
    Observation
    I noticed the hint says 'around' rather than giving an exact second, and encryption.py uses SHA-256(str(ts).encode())[:16] as the AES-128 key, which suggested sweeping a small window centered on the hinted timestamp and checking each decryption candidate for the known 'picoCTF{' prefix.
    Try every timestamp in the window, derive the AES key as SHA-256(timestamp), decrypt, and check whether the plaintext starts with picoCTF{. ECB makes this clean: same key always yields the same ciphertext, no IV to track.
    python
    python3 - <<'EOF'
    import hashlib, re
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import unpad
    
    data = open("message.txt").read()
    
    # message.txt carries BOTH values:
    #   Hint: The encryption was done around 1770242610 UTC
    #   Ciphertext (hex): 71cd3848...
    ts0 = int(re.search(r"(\d{10})", data).group(1))                     # the hinted timestamp
    ct  = bytes.fromhex(re.search(r"([0-9a-fA-F]{32,})", data).group(1))  # the hex ciphertext
    
    # "around" => sweep a small window centered on the hinted second.
    for ts in range(ts0 - 300, ts0 + 301):
        key = hashlib.sha256(str(ts).encode()).digest()[:16]   # match encryption.py (AES-128: first 16 bytes)
        pt  = unpad(AES.new(key, AES.MODE_ECB).decrypt(ct), 16)
        if pt.startswith(b"picoCTF{"):
            print(ts, pt)
            break
    EOF
    What didn't work first

    Tried: Use the full 32-byte SHA-256 digest as the AES key instead of the first 16 bytes.

    AES-128 requires exactly a 16-byte key. Passing a 32-byte digest to AES.new() uses AES-256, which produces different ciphertext for every candidate timestamp. No timestamp in the sweep will match and the loop exits without printing anything. Check encryption.py: if it slices digest()[:16], your brute-force must do the same.

    Tried: Encode the timestamp as bytes directly (str(ts).encode() produces the ASCII digits) but apply sha256 to the raw integer bytes via ts.to_bytes(4, 'big') instead.

    encryption.py calls hashlib.sha256(str(ts).encode()), which hashes the ASCII string '1770242610', not the 4-byte big-endian integer. Hashing the raw integer bytes produces a completely different digest and every decryption candidate yields garbage. The key derivation must match encryption.py byte-for-byte - always read the source before coding the brute-force loop.

    Learn more

    Why ECB makes this clean. Each 16-byte plaintext block is encrypted independently under the same key, with no IV and no chaining. So "guess the key, decrypt, check the first 8 bytes" works on the very first ciphertext block - no setup, no synchronization. ECB is cryptographically weak in general (patterns in plaintext leak directly into ciphertext - the famous "ECB Penguin"), but here it's a feature: it makes the brute-force loop trivial.

    The picoCTF{ prefix check is rock-solid. The flag starts with 8 distinctive bytes: p i c o C T F {. The probability that a random key's decryption happens to produce those exact 8 bytes is about 2^-64 (~1 in 18 quintillion). Even across an entire 30-day window of 2.6 million candidates, the false-positive count is effectively zero. So you can skip padding-validation tricks entirely and just check the prefix.

    How to extract the ciphertext. Inspect message.txt first. Hex-encoded ciphertext looks like [0-9a-f]+ with even length. Base64 looks like [A-Za-z0-9+/]+={0,2} with length divisible by 4. Raw binary is anything else (often an unprintable mess - file message.txt reports "data" in that case). Match the loader to the format before looping; otherwise every iteration silently fails on bad ciphertext shape.

    See the AES for CTF guide for ECB pitfalls and other AES bug patterns, and the Python for CTF guide for PyCryptodome usage.

Interactive tools
  • AES DecryptorDecrypt AES-CBC, AES-GCM, AES-CTR, and AES-ECB ciphertexts with a known key and IV. Hex / base64 / UTF-8 inputs, AES-128/192/256, PKCS#7 padding.

Flag

Reveal flag

picoCTF{t1m3st4mp_k3y_...}

message.txt prints the encryption timestamp in its hint line, so the AES key is SHA-256(timestamp) with the seed handed to you. Sweep a small +/- window around the hinted second to absorb the 'around' wording, then AES-ECB decrypt.

Key takeaway

Cryptographic security depends on key entropy, not algorithm strength. When a key is derived from a predictable value like a timestamp, the attacker only needs to search the space of plausible timestamps rather than the full 2^128 or 2^256 key space, collapsing the problem to a brute force that finishes in seconds. Hashing a low-entropy seed with SHA-256 or any other function does not add entropy because the mapping from seed to hash is deterministic and public. Real-world variants of this class appear in session token generation, PRNG seeds, and legacy cryptographic libraries that used wall-clock time for initialization.

How to prevent this

An AES key derived from time is not a key, it is a brute-force target with ~17 bits of entropy per day.

  • Generate keys with a CSPRNG: os.urandom(32) for AES-256. Store them in a secrets manager, not derived from anything observable.
  • If the design genuinely needs a key derived from a value, use a proper KDF (HKDF, Argon2id, scrypt) with high entropy input plus a per-record salt. HKDF(secret_key, salt=random_bytes(16), info="context") is standard.
  • Audit any encryption code for time(), date stamps, or sequence numbers being fed into key derivation. These are markers of low-entropy keys; replace them with random secrets pulled from a vault.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Cryptography

What to try next