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
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand the key derivationObservationI 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 likeHint: 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
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 2
Read the timestamp from the hintObservationI 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 theHint: The encryption was done around <TS> UTCline. 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:bashhead -1 message.txt # -> Hint: The encryption was done around 1770242610 UTCbash# Pull just the integer:bashgrep -oE '[0-9]{10}' message.txt | head -1Expected 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 logon 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.Step 3
Brute-force the timestampObservationI 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 withpicoCTF{. ECB makes this clean: same key always yields the same ciphertext, no IV to track.pythonpython3 - <<'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 EOFWhat 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.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.
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
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.