Why ECB Mode Leaks Patterns: A Visual Guide to Cipher Modes

Part 2 of 3 in Cryptography Basics: Ciphers, Modes, and Tampering

The last post broke the Caesar cipher because its keyspace has only 26 entries. AES has a keyspace of 2^256, brute force is not an option. Yet you can still encrypt an image with AES and get an output where you can see the original picture through the ciphertext. The permutation is not the problem this time. The problem is what you do with it across an entire message: the mode of operation.

Permutation and mode: two separate ideas

Any block cipher splits cleanly into two separate concerns: a permutation and a mode of operation. A permutation is a fixed-size, key-dependent scrambling function: feed it a block and a key, get back a block of the same size, and knowing the key lets you undo it, nobody else can. A mode of operation is the separate rule for stitching that fixed-size operation together to cover a message of arbitrary length.

AES-256 is a strong permutation: given a 128-bit block and a 256-bit key, it produces a 128-bit output that’s determined by the key, gives a different result for every different key, and looks indistinguishable from random noise. But a permutation only ever handles one fixed-size block. Anything longer than 16 bytes needs a mode to glue blocks together, and reusing one permutation naively, the same operation on every block with no variation, is exactly the mistake the Caesar cipher makes at the level of single letters: run the exact same substitution on every letter of the message, and every repeated letter produces the exact same output letter, which is enough by itself to leak structure about the plaintext even without recovering the key. ECB mode is that identical mistake, played out one 16-byte AES block at a time instead of one letter at a time.

ECB: same input, same output, every time

Electronic Codebook (ECB) mode is the simplest possible way to extend a block cipher to long messages: split the plaintext into 16-byte blocks and run AES on each one independently, same key, no interaction between blocks.

C_1 = AES(K, P_1)
C_2 = AES(K, P_2)
...

If P_1 and P_2 happen to be identical 16-byte blocks (two identical stretches of pixels, two identical stretches of any plaintext), C_1 and C_2 come out identical too: it’s the same permutation applied to the same input, so it produces the same output every time.

CBC: breaking the repetition with chaining

Cipher Block Chaining (CBC) mode fixes exactly this by XORing each plaintext block with the previous ciphertext block before encrypting it, seeded with a random initialization vector (IV) for the first block:

C_1 = AES(K, P_1 XOR IV)
C_2 = AES(K, P_2 XOR C_1)
C_3 = AES(K, P_3 XOR C_2)
...

Now two identical plaintext blocks P_i = P_j almost certainly get XORed against different preceding ciphertext (C_{i-1} != C_{j-1}), so they no longer produce identical output. That’s exactly the property a mode of operation needs to supply: different effective input for every position, so that duplicate blocks in the plaintext stop being visible as duplicate blocks in the ciphertext.

The code: encrypt a real image both ways

To make the leak visible instead of theoretical, encrypt a BMP’s raw pixel bytes directly (skip past the header, which has to stay readable so the file still opens as an image), run AES-256 over them in ECB and in CBC with the same key, then drop the ciphertext back behind the original header:

def make_plaintext_image(path: str) -> None:
    """Flat-color stripes plus repeated ring shapes: lots of duplicate
    16-byte chunks in the raw pixel stream, exactly the repeated-input
    condition that exposes ECB."""
    img = Image.new("RGB", (W, H), "white")
    draw = ImageDraw.Draw(img)
    stripe_colors = [
        (230, 57, 70), (29, 53, 87), (69, 123, 157),
        (241, 250, 238), (168, 218, 220),
    ]
    stripe_h = H // len(stripe_colors)
    for i, c in enumerate(stripe_colors):
        draw.rectangle([0, i * stripe_h, W, (i + 1) * stripe_h], fill=c)
    for cx in range(48, W, 96):
        for cy in range(48, H, 96):
            draw.ellipse([cx - 40, cy - 40, cx + 40, cy + 40], fill=(255, 255, 255))
            draw.ellipse([cx - 20, cy - 20, cx + 20, cy + 20], fill=(0, 0, 0))
    img.save(path)


def split_bmp(path: str):
    """Return (header_bytes, pixel_bytes) for an uncompressed BMP."""
    data = open(path, "rb").read()
    pixel_offset = int.from_bytes(data[10:14], "little")
    return data[:pixel_offset], data[pixel_offset:]
# AES-256 key, same key for both modes (fair comparison). Fixed rather
# than os.urandom() purely so this post's captured output is reproducible;
# a real system must use a fresh random key.
key = bytes.fromhex(
    "1f2e3d4c5b6a798897a6b5c4d3e2f1001f2e3d4c5b6a798897a6b5c4d3e2f100"
)

# ECB: same permutation, block by block, no chaining.
ecb = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ecb_body = ecb.update(body) + ecb.finalize()

# CBC: each block XORed with previous ciphertext block before encrypting.
# IV fixed for the same reproducibility reason as the key above.
iv = bytes.fromhex("00112233445566778899aabbccddeeff")
cbc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
cbc_body = cbc.update(body) + cbc.finalize()

count_duplicate_blocks() walks the byte stream in 16-byte steps and counts how many blocks are exact repeats of an earlier block, in plaintext, in the ECB ciphertext, and in the CBC ciphertext. Running the whole thing end to end:

=== ECB vs CBC on the same image, same AES-256 key ===
image: 384x384, pixel bytes: 442368, blocks encrypted: 27648
duplicate 16-byte blocks in PLAINTEXT: 27459
example: plaintext block at offset 3456 == block at offset 3504
  plaintext[3456:3472] = dcdaa8dcdaa8dcdaa8dcdaa8dcdaa8dc
  plaintext[3504:3520] = dcdaa8dcdaa8dcdaa8dcdaa8dcdaa8dc

[ECB] duplicate 16-byte blocks in CIPHERTEXT: 27459
[ECB] ciphertext[3456:3472] = 89ea87c66a68644f0ee4525b6d021ff7
[ECB] ciphertext[3504:3520] = 89ea87c66a68644f0ee4525b6d021ff7  <-- identical to the block above

[CBC] duplicate 16-byte blocks in CIPHERTEXT: 0
[CBC] ciphertext[3456:3472] = 8a254fc84e6a03921a3dad1100bcecb6
[CBC] ciphertext[3504:3520] = dcc17547e8f1828167c80f9483e80503  <-- different now

Same plaintext, same key. ECB keeps the duplicate blocks visible in
ciphertext; CBC's chaining wipes them out.

27,459 out of 27,648 plaintext blocks are duplicates of an earlier block (the flat color stripes are almost all repetition). ECB ciphertext has exactly the same duplicate count: 27,459, unchanged. Same key, same AES permutation, but CBC’s ciphertext has zero duplicate blocks. Nothing about AES itself changed between the two runs, only the mode. Here it is running live:

Seeing it: the actual pixels

Numbers are convincing, but structure like this is easiest to trust once you can see it directly. Here is the same image, unencrypted, then run through AES-256-ECB, then through AES-256-CBC with the same key:

Side-by-side comparison of a plaintext image with flat color stripes and ring shapes, the same image encrypted with AES-256 in ECB mode showing the shapes and stripes still clearly visible through noisy coloring, and the same image encrypted with AES-256 in CBC mode showing uniform random-looking noise with no visible structure

The middle panel is ciphertext. Every ring, every stripe boundary is still exactly where it was in the plaintext, because every repeated 16-byte block of “white circle” or “red stripe” encrypted to the same repeated ciphertext block, textured with noise but geometrically identical to the source. The right panel is also ciphertext, from the same key, same AES calls, just a different mode, and it is indistinguishable from static.

One more artifact of the same effect, for free: the PNG file sizes.

-rw-r--r-- 1 dat dat 443379 Jul 26 06:36 cbc.png
-rw-r--r-- 1 dat dat  18071 Jul 26 06:36 ecb.png
-rw-r--r-- 1 dat dat   5392 Jul 26 06:36 plaintext.png

PNG compression finds and removes exactly the kind of redundancy ECB leaves behind, so the ECB ciphertext compresses down to a fraction of its raw size, about 24.5x smaller than the CBC ciphertext of the identical byte length. A compressor and a cryptanalyst are exploiting the same weakness: repeated blocks are structure, and structure is information an attacker (or a PNG encoder) shouldn’t be able to extract from a secure ciphertext.

Why this matters beyond a picture of circles

This is not a contrived demo, it is the well-known “ECB penguin” problem, and it generalizes past images to anything with repeated structure: database records with the same value in one column, encrypted network packets with identical headers, backups of mostly-empty disk sectors. There’s a formal name for the security property ECB is missing: indistinguishability under chosen-plaintext attack (IND-CPA), which requires that encrypting the same plaintext twice never produce the same ciphertext twice, because if it did, an attacker could tell which plaintexts repeat just by watching the ciphertext, without ever recovering the key.

ECB fails this outright: encrypt the same 16-byte block twice under the same key and you get the same ciphertext block twice, deterministically, every time. CBC does better because of the IV: feeding a fresh, unpredictable value into the very first block (and, through the chaining, into every block after it) means the ciphertext comes out different on every encryption, even for identical plaintext, under the identical key. That is not the same as saying CBC solves every problem AES-mode design has (padding oracle attacks specifically target CBC’s padding, a separate weakness from anything covered here), but it does solve the one this post is about: a secure permutation, reused block after block with no chaining, is not a secure cipher. The mode of operation is not an implementation detail, it’s half of what “secure encryption” means.