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.
cat encryption.pycat message.txtSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the key derivation
ObservationThe description says the AES key comes from the current time, so the key space is not 2^256 but the handful of plausible timestamps. Brute force is on the table before you write anything.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 2Read the timestamp from the hint
Observationmessage.txt carries a hint line naming roughly when the encryption ran, which means the script printed its own key seed into the ciphertext file. The search collapses to reading that integer.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 word around means the script may have read the clock a second or two either side of the printed value. One guess decrypts to garbage and looks like a failed approach. A window of 300 seconds each way is only 601 candidates and costs nothing.
Tried: Parse the ciphertext from message.txt using the same grep that extracts the timestamp.
A ten-digit pattern matches the timestamp, and the ciphertext is a much longer hex string, so reusing that pattern pulls the timestamp back out instead. The ciphertext line is labeled; take the token after the label rather than the first digit run in the file.
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 3Brute-force the timestamp
ObservationThe hint says around, not exactly, and the key is the first 16 bytes of the SHA-256 of the timestamp string. Sweep a small window around the hinted value and check each candidate against the known flag 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) raw = AES.new(key, AES.MODE_ECB).decrypt(ct) if not raw.startswith(b"picoCTF{"): continue # check the prefix BEFORE unpad: a wrong key raises on bad padding print(ts, unpad(raw, 16)) 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 wants exactly 16 bytes. Hand it the full 32-byte digest and you are running AES-256, which produces different output for every candidate, so the sweep finishes silently having matched nothing. encryption.py slices the digest, and your loop has to as well.
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 hashes the timestamp as an ASCII string, not as raw integer bytes. Hash the bytes instead and every digest differs, so every candidate decrypts to garbage. The derivation has to match the source byte for byte, which is why you read it first.
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.
- Hash IdentifierIdentify unknown hash types by length and prefix. Covers MD5, SHA-1, SHA-256, SHA-512, bcrypt, NTLM, and more.
Flag
Reveal flag
picoCTF{sa3S_sEc9t_...}
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.