Day 25/90: The ECB Penguin in Our Own Code

90 Day Python Security Scripting Challenge

A pull request crossed my desk titled "AES encryption added." It encrypted small JSON blobs before they hit a shared cache. Tests passed, it merged. Two days later I noticed two different cache records whose ciphertext started with the same bytes, because their plaintext started the same way. That is the signature of AES in ECB mode, and the key was a hardcoded constant. Using AES is not the same as encrypting safely. Here is the deep version.

Why ECB leaks structure

AES is a block cipher that transforms 16 bytes at a time. A mode of operation decides how a longer message is chained. ECB is the laziest mode: encrypt each 16-byte block independently with the same key. Identical plaintext blocks therefore become identical ciphertext blocks, so repetition in your data leaks straight into the ciphertext.

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes def count_repeated_blocks(ciphertext: bytes) -> int: blocks = [ciphertext[i:i + 16] for i in range(0, len(ciphertext), 16)] return len(blocks) - len(set(blocks)) key = bytes(32) # all-zero key, FOR DEMONSTRATION ONLY -- never do this for real plaintext = b"SECRET-PREFIX!!!" * 4 # four identical 16-byte blocks enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() ct = enc.update(plaintext) + enc.finalize() print("repeated ciphertext blocks:", count_repeated_blocks(ct)) # 3 -> leak 

The function counts how many ciphertext blocks are duplicates. With ECB and repeating plaintext, the count is greater than zero, which means the structure of the plaintext is observable in the output. That is the "ECB penguin" effect: encrypt a bitmap and the image stays recognizable because flat color regions are runs of identical blocks.

The fix: AESGCM with a per-message nonce and a tag

Making the key longer does not help. ECB needs per-message randomization (a nonce) and integrity (authentication). AEAD gives you both. AESGCM derives a unique keystream from a fresh random nonce, so identical plaintexts encrypt differently, and it appends a 16-byte tag so tampering is caught.

import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.exceptions import InvalidTag class CacheCrypter: def __init__(self, key: bytes): self._gcm = AESGCM(key) def seal(self, plaintext: bytes, context: bytes) -> bytes: nonce = os.urandom(12) return nonce + self._gcm.encrypt(nonce, plaintext, context) def open(self, blob: bytes, context: bytes) -> bytes: nonce, body = blob[:12], blob[12:] try: return self._gcm.decrypt(nonce, body, context) except InvalidTag: raise ValueError("authentication failed; blob rejected") crypter = CacheCrypter(AESGCM.generate_key(bit_length=256)) secret = b'{"token":"abc","scope":"read"}' one = crypter.seal(secret, context=b"cache:user:7") two = crypter.seal(secret, context=b"cache:user:7") print("identical plaintext -> different ciphertext:", one[12:] != two[12:]) print("round trip:", crypter.open(one, b"cache:user:7") == secret) corrupt = bytearray(one) corrupt[20] ^= 0x01 try: crypter.open(bytes(corrupt), b"cache:user:7") except ValueError as exc: print("tamper rejected:", exc) 

Two seals of the same secret now produce different blobs because each carries its own nonce. The corrupted blob fails authentication and seal/open never returns trusted-but-wrong bytes. The context argument is associated data: it is authenticated but not encrypted, so binding a blob to cache:user:7 means a blob copied to a different user's context fails to decrypt.

Nonce reuse is the one rule you cannot break

GCM's security collapses if you reuse a nonce with the same key. A repeated nonce can leak the authentication key and enable forgery, which is far worse than ECB's block leakage. So generate the nonce randomly per message and never from a resettable counter.

import os def unique_nonces(count: int) -> bool: seen = set() for _ in range(count): nonce = os.urandom(12) if nonce in seen: return False seen.add(nonce) return True print("all nonces unique across the batch:", unique_nonces(100000)) 

Across a large batch of random 12-byte nonces, collisions are vanishingly unlikely, which is exactly the property GCM relies on.

Do not hardcode keys; derive them

The other half of the bug was the key in source control. When a password is the source of truth, stretch it with a memory-hard KDF instead of hashing once.

import os from cryptography.hazmat.primitives.kdf.scrypt import Scrypt def make_key(password: str, salt: bytes | None = None) -> tuple[bytes, bytes]: salt = salt or os.urandom(16) kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1) return kdf.derive(password.encode("utf-8")), salt key, salt = make_key("a long passphrase the user actually typed") print("key bytes:", len(key), "salt bytes:", len(salt)) 

Scrypt is memory-hard, which raises the cost of GPU brute forcing. Store the random salt beside the ciphertext, keep the password out of the repo, and never reuse a salt.

Key Takeaways

ECB encrypts each block independently, so identical plaintext leaks as identical ciphertext. Use AESGCM with a fresh random nonce per message and rely on the auth tag to catch tampering. Never reuse a nonce under one key. Derive keys from passwords with Scrypt, not a single hash, and store the salt with the ciphertext.

Day 25/90 of the 90 Day Python Security Scripting Challenge

Keep all security scripts in a dedicated venv with pinned dependencies. Run bandit against your own code regularly -- it flags weak modes and hardcoded keys, and security tools should be held to the same standard as the systems they protect.