Day 25/90: The ECB Penguin in Our Own Code -- Symmetric Encryption Done Right
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 p
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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.