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
SSNis 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
- Cryptopals Set 2, challenges 11, 12 and 14 are exactly this attack
- Filippo Valsorda: cryptography engineering advice on choosing libraries over primitives
- OWASP Cryptographic Storage Cheat Sheet
Was this useful?
Comments
Loading comments…