Skip to main content
Cryptography · lesson 3 of 8
Cryptography intermediate Lesson 3 of 8

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

cryptography aes ecb aes-gcm nonce owasp

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

  1. 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.

  1. Try a nonce-misuse-resistant mode. Repeat the nonce-reuse experiment with AESGCMSIV. Encrypt p1, p2, and p1 again 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.

  1. Generate nonces from a counter. Write a Sealer class 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.

  1. Check CBC’s patterns. Encrypt the ecb_leak.py record 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.

Frequently Asked Questions

Why is ECB mode insecure?
ECB encrypts every 16-byte block independently with the same key, so identical plaintext blocks always produce identical ciphertext blocks. Anyone who sees the ciphertext can tell which blocks repeat and where, which leaks the structure of the data without breaking AES itself. Use an authenticated mode such as AES-GCM or ChaCha20-Poly1305 instead.
What happens if you reuse a nonce with AES-GCM?
GCM encrypts by XORing the plaintext with a keystream derived from the key and nonce, so two messages under the same key and nonce share a keystream. XORing the two ciphertexts cancels it and leaves the XOR of the two plaintexts, so knowing or guessing one message reveals the other. Nonce reuse also leaks the internal authentication key, which lets an attacker forge tags.
How should I generate nonces for AES-GCM?
Either draw 12 random bytes with os.urandom for every message, or use a counter that never repeats for a given key. Random 96-bit nonces are safe up to about 2 to the 32 messages per key, after which you should rotate the key. Have your encrypt function generate the nonce itself so callers can't pass in a reused one.
What is AES-GCM-SIV and when should I use it?
AES-GCM-SIV is a nonce-misuse-resistant AEAD mode. If a nonce is accidentally repeated, it only reveals whether the same message was encrypted twice, instead of leaking the XOR of two different plaintexts. Use it where guaranteeing unique nonces is hard, such as many machines sharing one key, but still aim for unique nonces.
Is OWASP Cryptographic Failures about broken algorithms?
Mostly not. OWASP A04:2025 Cryptographic Failures covers misuse of strong primitives as much as weak ones: ECB mode, reused or predictable nonces and IVs, missing authentication, hard-coded keys and fast password hashes. AES itself is fine in every one of those cases; the way it is used is the bug.