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 .
Setup
Download message.txt and encryption.py.
Read encryption.py to understand exactly how the key is derived from the timestamp.
cat encryption.pycat message.txtSolution
Walk me through it- Step 1Understand the key derivationThe AES key is SHA-256(unix_timestamp), where the timestamp is the integer number of seconds since the Unix epoch at encryption time. The ciphertext in message.txt has a timestamp embedded or is from a known time window.
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
secretsmodule andos.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. - Step 2Determine the encryption time windowFind a tight window around when message.txt was encrypted. File mtime, git history, or the challenge release date are all good anchors. A 24-hour window is only 86,400 candidates - small enough that brute force takes seconds.bash
# Filesystem timestamps - mtime is the encryption time if the file wasn't touched after:bashstat message.txtbash# If the challenge ships in a git repo, check when the file was added:bashgit log --all --pretty=format:'%ai %h %s' -- message.txtbash# Otherwise anchor on the public picoCTF 2026 start date.Learn more
Search-space math. Unix timestamps are integer seconds. So:
- 1 hour = 3,600 candidates
- 24 hours = 86,400 candidates
- 1 week = 604,800 candidates
- 30 days = 2,592,000 candidates
Python with PyCryptodome does roughly 100,000-500,000 AES decryptions per second on a single core, so even a 30-day window finishes in tens of seconds. A 24-hour window is essentially instant.
How to anchor the window. The strongest signal is filesystem mtime:
stat message.txtreports the last-modified time, which usually matches when the file was encrypted. If the challenge ships from a git repo,git log --all --pretty=format:%ai -- message.txtshows every commit that touched the file, with timestamps. Failing that, anchor on the public competition start date and pad ±24-48 hours.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. - Step 3Brute-force the timestampTry 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.pythonpython3 - <<'EOF' import hashlib from Crypto.Cipher import AES # Read the ciphertext from message.txt. Encryption scripts typically dump it as: # - hex string -> bytes.fromhex(...) # - base64 -> base64.b64decode(...) # - raw bytes -> open("message.txt", "rb").read() # Inspect message.txt first; pick the matching loader. import base64 raw = open("message.txt").read().strip() try: ct = bytes.fromhex(raw) # hex form except ValueError: try: ct = base64.b64decode(raw) # base64 form except Exception: ct = open("message.txt", "rb").read() # raw bytes # Window from `stat message.txt` / git log / challenge release date: START_TS = 1714521600 # adjust END_TS = 1714608000 # adjust (24h later) for ts in range(START_TS, END_TS + 1): key = hashlib.sha256(str(ts).encode()).digest() # match encryption.py try: pt = AES.new(key, AES.MODE_ECB).decrypt(ct) except Exception: continue if pt.startswith(b"picoCTF{"): print(ts, pt) break EOFLearn 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.txtfirst. 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.txtreports "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.
Flag
picoCTF{t1m3st4mp_k3y_...}
Deriving an AES key from the current Unix timestamp creates only ~86400 candidates per day - trivially brute-forceable.
How to prevent this
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.