Cryptography · lesson 3 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
ECB Leaks Patterns, and a Reused Nonce Leaks Plaintext
See two classic AES mistakes on your own data: ECB repeating identical blocks, and a reused GCM nonce making c1 XOR c2 equal p1 XOR p2. Then fix both.
By SkillByExample · Published
AES itself isn’t the weak point in real-world encryption failures. The mode it runs in, and the nonce
you give it, are. This lesson makes two classic mistakes deliberately and shows exactly what each one leaks.
It’s a misuse demonstration: every key, nonce and message is generated by the script itself, and each
mistake is followed by the correct construction. Start a container with
docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash, then run
pip install cryptography==50.0.2. The examples were run with Python 3.12.15 and cryptography 50.0.2.
ECB: equal blocks in, equal blocks out
ECB (electronic codebook) mode encrypts each 16-byte block on its own, with the same key. The record below is five 16-byte fields, and four of them are identical.
# ecb_leak.py
# Misuse demo on this script's own data and key. Never use ECB.
import os
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = os.urandom(32)
record = b"status=APPROVED;" * 3 + b"status=REJECTED;" + b"status=APPROVED;"
padder = padding.PKCS7(128).padder()
padded = padder.update(record) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ct = enc.update(padded) + enc.finalize()
labels: dict[bytes, str] = {}
for i in range(0, len(ct), 16):
block = ct[i:i + 16]
label = labels.setdefault(block, "ABCDEFGH"[len(labels)])
print(f"block {i // 16}: {block.hex()} {label}")
$ python ecb_leak.py
block 0: 89be2120aaf423a6366a44414d414fff A
block 1: 89be2120aaf423a6366a44414d414fff A
block 2: 89be2120aaf423a6366a44414d414fff A
block 3: 7e4ce6c60d60c094390b1fe40da67a39 B
block 4: 89be2120aaf423a6366a44414d414fff A
block 5: 99f3af040d48e084a8ce89e5d360bb99 C
AES did its job: nobody can read APPROVED from 89be2120…. But the pattern A A A B A is fully visible.
Without the key, an observer learns that four fields are equal and the fourth is different, and once they
learn what any one block means, they know it everywhere it appears. Block 5 is the padding: the record is
exactly 80 bytes, so PKCS7 adds a whole block of 0x10 bytes. This is the well-known “ECB penguin”: encrypt
a bitmap image in ECB and the outline is still visible.
The same record under AES-GCM
Here’s the same record encrypted with AES-GCM and a random nonce, with the tag dropped so only the ciphertext blocks are compared.
# gcm_blocks.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
record = b"status=APPROVED;" * 3 + b"status=REJECTED;" + b"status=APPROVED;"
ct = AESGCM(key).encrypt(os.urandom(12), record, None)[:-16] # drop the tag
labels: dict[bytes, str] = {}
for i in range(0, len(ct), 16):
block = ct[i:i + 16]
label = labels.setdefault(block, "ABCDEFGH"[len(labels)])
print(f"block {i // 16}: {block.hex()} {label}")
$ python gcm_blocks.py
block 0: 26c216969e290e4d9ad762325a1336f1 A
block 1: d8ade6c19228a0f303a07b4ef80439b5 B
block 2: 68209f9ca3de24aa672ae51145ff26a7 C
block 3: 2d10ba6feaa2caea1944b76e548488a8 D
block 4: 3e94f2db69ecdbcb87c34985910f9631 E
Every block is different, so the pattern is gone. GCM runs AES in counter mode: it encrypts a counter that starts from the nonce and goes up by one per block, and XORs the result with the plaintext. Each block’s keystream is different, so equal plaintext blocks no longer give equal ciphertext. There’s no padding block either, because counter mode doesn’t need one. All of that safety depends on one rule: the nonce must never repeat for the same key.
Reusing a nonce: the keystream cancels out
Here’s what breaking that rule does. Two different messages are encrypted with the same key and the same nonce.
# nonce_reuse.py
# Misuse demo on this script's own messages and key: one nonce used twice.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12) # the bug: reused below
p1 = b"transfer 100 to alice"
p2 = b"transfer 999 to bobby"
c1 = AESGCM(key).encrypt(nonce, p1, None)[:-16]
c2 = AESGCM(key).encrypt(nonce, p2, None)[:-16]
print("c1 XOR c2:", xor(c1, c2).hex())
print("p1 XOR p2:", xor(p1, p2).hex())
print("equal: ", xor(c1, c2) == xor(p1, p2))
$ python nonce_reuse.py
c1 XOR c2: 0000000000000000000809090000000003030b011c
p1 XOR p2: 0000000000000000000809090000000003030b011c
equal: True
With the same key and nonce, both messages are XORed with the same keystream K. So
c1 XOR c2 = (p1 XOR K) XOR (p2 XOR K) = p1 XOR p2: the key drops out completely. The zero bytes show
exactly where the two messages agree (transfer , to) and where they differ (the amount and the name),
without the key. If one of the messages is known or guessable, as protocol messages often are, XORing it
back reveals the other. Nonce reuse in GCM also leaks the internal authentication key, which lets tags be
forged. A single reused nonce breaks both confidentiality and integrity for that key.
The fix: a fresh nonce for every message
Generate the nonce inside the encrypt function, so no caller can reuse one.
# fresh_nonce.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
key = AESGCM.generate_key(bit_length=256)
p1 = b"transfer 100 to alice"
p2 = b"transfer 999 to bobby"
def seal(p: bytes) -> bytes:
nonce = os.urandom(12) # the fix: fresh for every message
return nonce + AESGCM(key).encrypt(nonce, p, None)
b1, b2 = seal(p1), seal(p2)
c1, c2 = b1[12:-16], b2[12:-16]
print("nonces differ:", b1[:12] != b2[:12])
print("c1 XOR c2: ", xor(c1, c2).hex())
print("p1 XOR p2: ", xor(p1, p2).hex())
print("equal: ", xor(c1, c2) == xor(p1, p2))
$ python fresh_nonce.py
nonces differ: True
c1 XOR c2: b3c123457e052e038e7852191dd35a09b27d824c04
p1 XOR p2: 0000000000000000000809090000000003030b011c
equal: False
With different nonces, the two keystreams are unrelated, so c1 XOR c2 looks random and reveals nothing
about the plaintexts. A random 96-bit nonce is safe for about 2³² messages per key. Beyond that, the chance
of two random nonces colliding is no longer negligible, so rotate the key well before then (lesson 8).
The rules to take away:
- Never use ECB for data, in any library or language.
- Use an AEAD mode, and let a function you control generate the nonce:
os.urandom(12)or a never-repeating counter. - Never derive the nonce from something that can repeat: a timestamp, a user ID or a fixed constant.
Practice
- Write an ECB detector. Write a
repeated_blocks(ct)function that counts how many 16-byte blocks are duplicates. Run it on an ECB ciphertext of block-aligned repetitive data and on a GCM ciphertext of the same data.
Solution
# detect_ecb.py
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def repeated_blocks(ct: bytes) -> int:
blocks = [ct[i:i + 16] for i in range(0, len(ct), 16)]
return len(blocks) - len(set(blocks))
key = os.urandom(32)
data = b"status=APPROVED;" * 8 # 128 bytes, block aligned
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ecb = enc.update(data) + enc.finalize()
gcm = AESGCM(key).encrypt(os.urandom(12), data, None)
print("ECB repeated blocks:", repeated_blocks(ecb))
print("GCM repeated blocks:", repeated_blocks(gcm))
$ python detect_ecb.py
ECB repeated blocks: 7
GCM repeated blocks: 0
Eight identical blocks under ECB give one unique block and seven repeats. A check like this is useful when auditing your own stored data or an internal service’s output: repeated 16-byte blocks in ciphertext are a strong sign of ECB.
- Try a nonce-misuse-resistant mode. Repeat the nonce-reuse experiment with
AESGCMSIV. Encryptp1,p2, andp1again under one nonce. Does the XOR relation still hold? What does leak?
Solution
# gcm_siv.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
def xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
key = AESGCMSIV.generate_key(bit_length=256)
nonce = os.urandom(12) # reused on purpose
p1, p2 = b"transfer 100 to alice", b"transfer 999 to bobby"
c1 = AESGCMSIV(key).encrypt(nonce, p1, None)
c2 = AESGCMSIV(key).encrypt(nonce, p2, None)
c3 = AESGCMSIV(key).encrypt(nonce, p1, None)
print("XOR relation holds:", xor(c1[:-16], c2[:-16]) == xor(p1, p2))
print("same message, same output:", c1 == c3)
print(AESGCMSIV(key).decrypt(nonce, c2, None))
$ python gcm_siv.py
XOR relation holds: False
same message, same output: True
b'transfer 999 to bobby'
GCM-SIV derives the keystream from the message itself, so different messages get different keystreams even under a repeated nonce. The only leak is equality: encrypting the same message twice with the same nonce gives the same ciphertext. That makes it a safety net for systems where nonce uniqueness is hard to guarantee. It doesn’t replace unique nonces.
- Generate nonces from a counter. Write a
Sealerclass that builds each nonce from a 4-byte random prefix and an 8-byte counter, and refuses to continue after 2³² messages. Print the first three nonces.
Solution
# counter_nonce.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class Sealer:
"""One key, one sender: a 4-byte random prefix plus an 8-byte counter."""
def __init__(self, key: bytes):
self.aead, self.prefix, self.count = AESGCM(key), os.urandom(4), 0
def seal(self, plaintext: bytes) -> bytes:
if self.count >= 2**32: # stay well under GCM's limits
raise RuntimeError("rotate the key")
nonce = self.prefix + self.count.to_bytes(8, "big")
self.count += 1
return nonce + self.aead.encrypt(nonce, plaintext, None)
s = Sealer(AESGCM.generate_key(bit_length=256))
blobs = [s.seal(b"msg %d" % i) for i in range(5)]
for b in blobs[:3]:
print(b[:12].hex())
print("unique nonces:", len({b[:12] for b in blobs}), "of", len(blobs))
$ python counter_nonce.py
3a6ac0100000000000000000
3a6ac0100000000000000001
3a6ac0100000000000000002
unique nonces: 5 of 5
A counter can’t collide with itself, which a random nonce can, but it only works while the counter is never reset. If the process restarts and the counter starts again from 0 with the same key, nonces repeat. That’s why counters are kept in memory with a key that lives no longer than the process, as TLS does, or the counter is persisted.
- Check CBC’s patterns. Encrypt the
ecb_leak.pyrecord with AES-CBC and a random 16-byte IV and print the block labels. Is the pattern gone? Is CBC therefore a good choice?
Solution
# cbc_blocks.py
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key, iv = os.urandom(32), os.urandom(16)
record = b"status=APPROVED;" * 3 + b"status=REJECTED;" + b"status=APPROVED;"
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
ct = enc.update(record) + enc.finalize() # already a multiple of 16
labels: dict[bytes, str] = {}
for i in range(0, len(ct), 16):
block = ct[i:i + 16]
label = labels.setdefault(block, "ABCDEFGH"[len(labels)])
print(f"block {i // 16}: {block.hex()} {label}")
$ python cbc_blocks.py
block 0: 06de811b0e29cbae63f722bb9ff6418e A
block 1: c86c4e26b4b17366412fab89cc019c42 B
block 2: 54461af166174a73d253609a4722cc75 C
block 3: 52e8de28e9d0c3a9aeb39f29fec9e016 D
block 4: b58f457be707b3b349a470f232ff3003 E
CBC XORs each plaintext block with the previous ciphertext block before encrypting it, so the pattern is gone. But CBC has no authentication, which leaves it open to tampering and padding-oracle attacks, and a predictable IV weakens it further. It hides patterns, and it’s still not the right default. Use AES-GCM, ChaCha20-Poly1305 or Fernet.