Skip to main content
All articles

ECB byte-at-a-time: recovering a secret you are never shown

If an application encrypts your input concatenated with a secret, and does it in ECB mode, you can extract that secret one character at a time. A complete walkthrough with runnable code. The clearest demonstration of why ECB is not encryption.

7 min read1 view

Most cryptographic attacks are hard to internalise because the mathematics sits between you and the intuition. This one does not. You can implement it in thirty lines, watch a secret appear one character at a time, and afterwards you will never look at an ECB-mode configuration flag the same way.

It is worth an hour even if you never meet it in production, because it teaches the single most useful idea in applied cryptography: a cipher that is deterministic leaks equality, and equality is enough.

What ECB does

Electronic Codebook is the simplest way to use a block cipher: split the plaintext into blocks, encrypt each one independently.

That independence is the problem. The same plaintext block always produces the same ciphertext block. Under the same key, E("YELLOW SUBMARINE") is a fixed value, forever.

The classic demonstration is the "ECB penguin": encrypt a bitmap in ECB mode and the outline of the image is still visible, because identical regions of pixels encrypt to identical blocks. The data is encrypted and the picture is still there.

For structured data the leak is worse than aesthetic. If a token contains repeated fields, you can see the repetition. If two users have the same value in a field, their ciphertexts share a block. And if you can influence the plaintext, you can do considerably better than observe.

The setup

Suppose a service encrypts your input with a secret appended, under a fixed key:

AES_ECB(attacker_input || SECRET, key)

This looks contrived. It is not. It is the shape of any "encrypted cookie" that concatenates a user-controlled field with a server-side value, any signed-URL scheme that appends a token, any cache key built by concatenation and then encrypted.

You never see SECRET. You cannot see the key. You can submit any input you like and observe the ciphertext.

That is enough to recover SECRET completely.

The mechanism

Blocks are 16 bytes. Send exactly 15 bytes of A:

AAAAAAAAAAAAAAA?   ← block 1: your 15 A's plus the FIRST byte of the secret
...                ← block 2 onwards: the rest of the secret

Block 1 is fifteen bytes you chose and one byte you want. Record its ciphertext. Call it the target.

Now build a dictionary. Send AAAAAAAAAAAAAAA followed by each candidate byte, AAAAAAAAAAAAAAAa, AAAAAAAAAAAAAAAb, and so on through all 256 possibilities, and record the first ciphertext block of each.

One of those will equal the target. Because ECB is deterministic, matching ciphertext means matching plaintext. You have the first byte of the secret.

For the second byte, send 14 As. Block 1 is now your 14 As, the known first secret byte, and the unknown second. Build the dictionary with 14 As plus your known byte plus each candidate. Match. Repeat.

Once you exhaust the first block, shift to aligning on block boundaries further along. Worst case 256 requests per byte, and you have the whole secret.

Runnable

from Crypto.Cipher import AES          # pip install pycryptodome
from Crypto.Util.Padding import pad
import os

KEY = os.urandom(16)
SECRET = b"the_flag_is_deterministic_encryption_is_not_encryption"

def oracle(attacker_input: bytes) -> bytes:
    """The service. You may call it. You may not look inside it."""
    return AES.new(KEY, AES.MODE_ECB).encrypt(pad(attacker_input + SECRET, 16))

BLOCK = 16

# 1. Detect ECB at all: identical plaintext blocks -> identical ciphertext blocks
probe = oracle(b"A" * 64)
blocks = [probe[i:i + BLOCK] for i in range(0, len(probe), BLOCK)]
assert len(blocks) != len(set(blocks)), "not ECB"

# 2. Recover, one byte at a time
recovered = b""
secret_len = len(oracle(b"")) - BLOCK          # generous upper bound

for i in range(secret_len):
    block_index = i // BLOCK
    pad_len = BLOCK - 1 - (i % BLOCK)
    prefix = b"A" * pad_len
    lo, hi = block_index * BLOCK, (block_index + 1) * BLOCK

    target = oracle(prefix)[lo:hi]

    for candidate in range(256):
        guess = prefix + recovered + bytes([candidate])
        if oracle(guess)[lo:hi] == target:
            recovered += bytes([candidate])
            print(recovered.decode(errors="replace"))
            break
    else:
        break                                   # ran into padding; done

print("recovered:", recovered)

Run it. Watching the secret assemble itself character by character in the terminal is the part that makes it stick, and it is the reason this exercise (Cryptopals challenge 12, and its harder sibling challenge 14 with an unknown random prefix) is the standard way people learn that "encrypted" and "secure" are unrelated claims.

Where this actually appears

The pure textbook case is rare. The underlying property, deterministic encryption leaks equality, is not, and it appears in systems nobody thinks of as cryptographic:

  • Encrypted database columns for searchability. If SSN is deterministically encrypted so it can be indexed, then equal ciphertexts mean equal values. Frequency analysis across a column recovers a great deal, particularly for low-cardinality fields (postcodes, dates of birth, diagnosis codes, "gender"). This is the standard objection to deterministic and order-preserving encryption schemes, and it is the reason "we encrypted the sensitive columns" is not a complete answer in a data-protection review.
  • Cookies and tokens built by concatenating user data with a secret and encrypting the result.
  • Any protocol where an attacker can vary input and observe the ciphertext. The general precondition.
  • Legacy systems and hardware modules where ECB was the default, or the only mode implemented.

Finding it

grep -rn "MODE_ECB\|AES/ECB\|ECBMode\|aes-128-ecb\|aes-256-ecb" .
grep -rn "Cipher.getInstance(\"AES\")" --include="*.java" .   # defaults to ECB in some providers

That Java line deserves attention. Cipher.getInstance("AES") without a mode has historically defaulted to AES/ECB/PKCS5Padding in the SunJCE provider. Developers write it believing they asked for "AES", and the mode was chosen for them, badly. Always specify the full transformation string.

Then detect it empirically, which is often faster than reading code: submit a long run of a repeated character and look for repeated 16-byte blocks in the output. The assertion at the top of the script above is a complete ECB detector in three lines.

Two more things to check in the same pass:

  • A fixed or missing IV in CBC mode. An all-zero IV, or an IV derived from the plaintext, reintroduces determinism for the first block and much of the same leakage.
  • Encryption without a MAC, which is the sibling failure and, in practice, the more damaging one.

Fixing it

Use an AEAD mode. AES-GCM or ChaCha20-Poly1305, with a unique nonce per message. This gives randomised ciphertext. The same plaintext encrypts differently every time, so there is no equality to leak, and integrity, so tampering fails cleanly. It solves this attack and the padding-oracle class in one decision.

Better still: use a library that does not offer you the choice. libsodium's crypto_secretbox, Python cryptography's Fernet, or Tink. Every API that asks you to pick a mode is an API that will eventually be asked by someone who does not know the answer.

If you need searchable encryption, do not reach for deterministic encryption. Use a keyed HMAC as a blind index for exact-match lookup, understand that it still leaks equality (that is what makes it searchable), and keep it out of low-cardinality fields where frequency analysis wins. If you need range queries on encrypted data, that is a research-grade problem, get advice before designing it.

Never concatenate a secret with attacker input and encrypt the result. If you need to bind a secret to a message, use HMAC. If you need to hide a value, do not put it in a token at all, keep it server-side and hand out a random identifier.

Take this away

ECB is not a weaker form of encryption. It is a construction that preserves the structure of the plaintext, which means it does not accomplish the thing encryption is for.

The general rule worth carrying: if the same input produces the same output, an attacker who controls the input has an oracle. That sentence covers ECB, deterministic column encryption, unsalted password hashes, and a surprising amount of everything else.


Further reading

Was this useful?

Share

Tags

  • cryptography
  • secure code review
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Source Code Review

Reading the code for the flaws that black-box testing structurally cannot reach.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.