Description
Recover the AES key from the provided files. The key generation has a weakness that makes it recoverable.
Setup
Download the provided files (encrypted data, possibly key generation code).
wget https://mercury.picoctf.net/static/.../clouds.tar.gz && tar xvf clouds.tar.gzSolution
Walk me through it- Step 1Extract the tarball and identify the ciphertextUnpack the tarball. Typically you'll get a key generation script (.py) plus an encrypted file. Skim the script for how the key is built and check the file timestamps as a seed-window hint.bash
tar -xzf clouds.tar.gzbashls -la clouds/bashstat clouds.tar.gz # mtime hints at the seed windowbashless clouds/*.py # how was the key generated?Learn more
Why the timestamp matters. If the script seeds Python's
randommodule withtime.time()at key-generation time, you only need to scan timestamps within the window when the challenge files were created.staton the tarball gives you the upload time; the picoCTF release calendar gives you the season. Together that's usually a few-million-second window, not a year. - Step 2Analyze the key generationSkim the script for the weakness: time-seeded RNG (predictable seed), short effective key length, reused IV, or a padding oracle. The fix you'll exploit follows directly from which one you spot.bash
less clouds/*.pybashgrep -nE 'random|seed|time' clouds/*.pyLearn more
Common AES key generation weaknesses:
- Time-seeded PRNG: If the key was generated using
random.seed(int(time.time())), the seed is the Unix timestamp at generation time. If you know approximately when the challenge was created, you can try all timestamps in a small window. - Short key derived from password: If the key is MD5 or SHA1 of a password, dictionary attacks may work.
- ECB mode: AES in ECB mode encrypts identical blocks identically. Pattern analysis reveals information about the plaintext without needing the key.
- Padding oracle: If the server reports decryption errors differently for padding errors vs. authentication errors, a padding oracle attack can decrypt the ciphertext byte by byte.
- Time-seeded PRNG: If the key was generated using
- Step 3Exploit the identified weaknessBased on the weakness found, craft the appropriate attack. For a time-based PRNG, try timestamps in a range. For a padding oracle, use the pycryptodome PaddingOracle helper.python
python3 - <<'EOF' # Example: time-seeded PRNG key recovery import time import random from Crypto.Cipher import AES ciphertext = bytes.fromhex('PASTE_CIPHERTEXT_HEX') # Try timestamps from a reasonable window around challenge creation start_time = int(time.time()) - 86400 * 365 # 1 year ago end_time = int(time.time()) for seed in range(start_time, end_time): random.seed(seed) key = bytes([random.randint(0, 255) for _ in range(16)]) try: cipher = AES.new(key, AES.MODE_CBC, iv=ciphertext[:16]) pt = cipher.decrypt(ciphertext[16:]) if b'picoCTF' in pt: print(f"Seed: {seed}, Flag: {pt.decode(errors='replace')}") break except Exception: pass EOFLearn more
Why time-seeded PRNGs are a death sentence. Python's
randommodule uses the Mersenne Twister, a fast non-cryptographic PRNG. Once seeded, every output is deterministic. If the seed is the Unix timestamp at key-generation time (a common bug), the keyspace collapses from2^128(a real AES-128 key) to roughly2^25(the number of seconds in a year). That is 33 million possibilities - a few minutes of wall-clock work on a laptop.Worked sketch. If the challenge tarball was uploaded at
2021-03-15 12:00:00 UTC(Unix epoch1615809600), and the key was generated withrandom.seed(int(time.time()))sometime in the preceding year, you only need to scan ~31.5 million seeds. For each seed:for seed in range(start_epoch, end_epoch): random.seed(seed) key = bytes(random.randint(0, 255) for _ in range(16)) iv = ciphertext[:16] pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext[16:]) if b'picoCTF' in pt: print(seed, pt) breakMersenne Twister state recovery (when seed brute-force isn't the right tool). If the script doesn't expose
time.time()as a seed but instead leaks PRNG outputs - e.g. it prints a "debug" random number alongside the ciphertext, or usesrandomfor both a public nonce and the key - you can collect 624 consecutive 32-bit outputs and reconstruct the entire internal state.randcrackdoes this in a few lines:from randcrack import RandCrack rc = RandCrack() for n in observed_outputs[:624]: # 624 32-bit ints from random.getrandbits(32) rc.submit(n) # Now rc.predict_*() returns the same values random would next_key_byte = rc.predict_getrandbits(8)Use the seed-bruteforce path when only the seed is broken. Use randcrack when the script leaks enough PRNG outputs to seed the state machine directly. For this challenge, check the script first: if it prints anything random-looking before the ciphertext, randcrack is the cleaner attack.
The real fix.
os.urandom(16)reads from the kernel CSPRNG (/dev/urandom, ChaCha20 or Fortuna under the hood) - not seeded by anything attacker-observable.secrets.token_bytes(16)(Python 3.6+) is a thin wrapper aroundos.urandomwith a name that makes its purpose obvious.- Never use
random.seed(time.time()),random.seed(getpid()), or any seed derived from process state for security-sensitive randomness.
Real-world lesson. In 2008, Debian shipped an OpenSSL patch that accidentally reduced the seed entropy to 15 bits. SSH keys generated on those systems for two years were brute-forceable in seconds. The same lesson applies here: AES is unbreakable, but only if the key is. For more on AES modes and the surrounding key-handling pitfalls, see AES for CTF; for the Python plumbing, see Python for CTF.
Flag
picoCTF{...}
AES security depends entirely on key generation quality - a time-seeded PRNG or other weak key generation method makes even AES trivially breakable by reconstructing the PRNG state.