In the previous lesson we encrypted exactly 16 bytes with AES and hit a wall: a real record measures kilobytes, and a machine that only processes 16-byte blocks needs instructions on how to chain them together. Those instructions are the mode of operation, and they're the difference between secure encryption and a data leak with AES perfectly intact underneath. In this lesson you're going to see — with code, on a MediNube record — why the ECB mode is a structural disaster (the famous "penguin"), how CBC (with its IV and its padding) and CTR (turning AES into a stream cipher) fix it, and a catastrophe that will reappear throughout the course: reusing a nonce. By the end you'll have one non-negotiable rule and one piece still missing: none of these modes detect tampering. That will be solved in lesson 02-03.
Contents
- Why a block cipher needs a mode
- ECB: the mode you must not use (and the penguin)
- CBC: chaining, IV, and PKCS7 padding
- CTR: the counter that turns AES into a stream cipher
- The catastrophe of a reused nonce
- Comparison table and the IV/nonce golden rule
Why a block cipher needs a mode
AES is a function that transforms 16 bytes into 16 bytes with a key. Nothing more. To encrypt a message of any other size, two questions must be answered:
- How do I split the message and chain the blocks together? If the message is 1600 bytes long, that's 100 blocks: do we encrypt them independently? Do we link them? That decision is the mode of operation.
- What do I do with the last chunk if it doesn't reach 16 bytes? A 22-byte message has one full block and 6 leftover bytes. Filling that gap is padding, and not every mode needs it.
flowchart LR
M[Message of N bytes] --> S[Split into 16-byte blocks]
S --> B1[Block 1]
S --> B2[Block 2]
S --> B3[... Block k]
B1 & B2 & B3 --> MODE{Mode of operation:\nhow they combine}
MODE --> C[Ciphertext]
The choice of mode doesn't change AES: it changes how the calls to AES are orchestrated. And that's where security is won or lost. Let's see it.
ECB: the mode you must not use (and the penguin)
ECB (Electronic Codebook) is the most naive mode: it encrypts each 16-byte block independently, with the same key and no relationship between them.
flowchart LR
subgraph ECB
P1[Block 1] --> E1[AES] --> Cc1[Ciphertext 1]
P2[Block 2] --> E2[AES] --> Cc2[Ciphertext 2]
P3[Block 3] --> E3[AES] --> Cc3[Ciphertext 3]
end
The flaw is structural and devastating: since every block is encrypted the same way, identical plaintext blocks produce identical ciphertext blocks. The encryption turns into a lookup table that preserves the patterns of the original. The classic illustration is the "Linux penguin" (Tux): if you encrypt the penguin image with ECB, you still see the penguin, because the large same-color areas produce the same encrypted blocks. AES is intact; the mode gives it away.
Let's reproduce the effect on MediNube data. Records have repetitive structure (fields that repeat across patients), and ECB leaks it:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = os.urandom(32)
# A "record" with repetitive structure: the same 16-byte block
# appears three times (imagine a template field repeated in the record).
repeated_block = b"CLINIC: SOL-----" # exactly 16 bytes
data = repeated_block * 3 + b"PATIENT:ANA-----" # 4 blocks, blocks 1-2-3 identical
encryptor = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ciphertext = encryptor.update(data) + encryptor.finalize()
# Split the ciphertext into 16-byte blocks and look for repeats.
blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
for i, b in enumerate(blocks):
print(i, b.hex())
repeated = len(blocks) - len(set(blocks))
print(f"\nRepeated ciphertext blocks: {repeated}") # -> 2Output: the first three ciphertext blocks are byte-for-byte identical, and the counter detects 2 repeats. Without knowing the key or decrypting anything, an attacker already knows "there are three identical things at the start" — the record's structure has leaked. With real data this reveals templates, repeated values (same diagnosis, same blood type across patients), lengths... extremely valuable information. The rule is categorical:
Never use ECB to encrypt data. It's a mode useful only as an internal piece of more complex constructions, never directly.
How do you fix it? By making identical blocks encrypt differently. There are two strategies, giving rise to CBC and CTR.
CBC: chaining, IV, and PKCS7 padding
CBC (Cipher Block Chaining) breaks the repetition by chaining the blocks: before encrypting each block, it's XORed with the ciphertext of the previous block. So the result of each block depends on all the previous ones; two identical plaintext blocks produce different ciphertexts because they arrive with different "context."
flowchart LR
IV[Random IV] -->|XOR| X1
P1[Block 1] --> X1((XOR)) --> E1[AES] --> C1[Ciphertext 1]
C1 -->|XOR| X2
P2[Block 2] --> X2((XOR)) --> E2[AES] --> C2[Ciphertext 2]
C2 -->|XOR| X3
P3[Block 3] --> X3((XOR)) --> E3[AES] --> C3[Ciphertext 3]
But the first block has no "previous one." That's where the IV (Initialization Vector) comes in: a 16-byte block that acts as the "ciphertext of block zero." Its requirements:
- Random and unpredictable for every message: it comes from the CSPRNG (
os.urandom(16)). If the IV were fixed or predictable, two messages that start the same would give themselves away again. - Public: it's stored/transmitted alongside the ciphertext, in the clear. It's not a secret; its value lies in being unrepeatable and unpredictable.
And since CBC encrypts block by block, the message must be an exact multiple of 16 bytes. It almost never is, so the last block gets padded. The standard is PKCS7: it pads with bytes whose value is the number of padding bytes added. If 5 bytes are missing to complete the block, it adds 05 05 05 05 05; if the message was already a multiple of 16, it adds a whole extra block of 10 10 ... 10 (16 in hex is 0x10), so that when decrypting, the code always knows exactly how much to strip off, with no ambiguity.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
key = os.urandom(32)
iv = os.urandom(16) # random 16-byte IV, one per message
message = "ANA PEREZ MEDICAL NOTE".encode("utf-8") # 22 bytes, NOT a multiple of 16
# 1. Pad up to a multiple of the block size (128 bits = 16 bytes).
padder = padding.PKCS7(128).padder()
padded = padder.update(message) + padder.finalize()
print(len(padded)) # 32: padded from 22 to 32 (2 blocks)
# 2. Encrypt with CBC, passing the IV.
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
# 3. To decrypt you must keep the IV (it's stored alongside the ciphertext).
decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
padded2 = decryptor.update(ciphertext) + decryptor.finalize()
# 4. Strip the padding.
unpadder = padding.PKCS7(128).unpadder()
recovered = unpadder.update(padded2) + unpadder.finalize()
print(recovered.decode("utf-8")) # ANA PEREZ MEDICAL NOTEKey points in the code:
- The IV is generated with
os.urandom(16)on every encryption and stored alongside the result. Without the correct IV, you can't decrypt. padding.PKCS7(128)— the128is the number of bits in AES's block (16 bytes). Thepadderadds the padding; theunpadderremoves it and validates that it's consistent.- CBC solves ECB's problem: identical blocks produce different ciphertexts, and the same message with a different IV produces a completely different ciphertext.
Now, CBC has a historical fragility that's the reason we prefer AEAD today (lesson 02-03): the process of "stripping and validating the padding" can turn into an oracle for the attacker. If the system reacts in a distinguishable way to valid padding versus invalid padding (an error, a different timing), an attacker can, by sending manipulated ciphertexts and observing those reactions, decrypt the data without the key. This is the padding oracle attack. We won't develop the attack here — just remember the moral: raw CBC is dangerous to handle correctly, and the modern solution isn't to patch it, but to use authenticated encryption.
CTR: the counter that turns AES into a stream cipher
CTR (Counter) uses a completely different idea: instead of encrypting the message, it encrypts a counter to generate a keystream, and then XORs that keystream with the message. In other words, it turns AES (a block cipher) into a stream cipher, like the ones we saw in 02-01.
flowchart LR
N0[nonce+counter 0] --> AE0[AES] --> K0[keystream 0]
N1[nonce+counter 1] --> AE1[AES] --> K1[keystream 1]
K0 -->|XOR| P0[Block 0] --> C0[Ciphertext 0]
K1 -->|XOR| P1[Block 1] --> C1[Ciphertext 1]
How it works: an input block is built by joining a nonce (a value unique per message) with a counter that starts at 0 and keeps incrementing (0, 1, 2, ...). Each of those nonce||counter blocks is encrypted with AES, and the result is the keystream that gets combined with the message via XOR. Notable advantages:
- No padding needed: since it's byte-by-byte XOR, the ciphertext is exactly the same length as the message. Goodbye to CBC's padding oracle.
- Parallelizable: each keystream block is computed independently (you only need to know its position), so you can encrypt/decrypt in parallel or randomly access the middle of a file.
- Encrypting and decrypting are the same operation: XOR with the same keystream (decrypting = XORing again).
In pyca/cryptography, CTR receives the full 16-byte initial block (nonce + counter):
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = os.urandom(32)
nonce = os.urandom(16) # initial block (nonce||counter), unique per message
message = "ANA PEREZ MEDICAL NOTE".encode("utf-8")
encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
ciphertext = encryptor.update(message) + encryptor.finalize()
print(len(ciphertext)) # 22: the SAME length as the message, no padding
decryptor = Cipher(algorithms.AES(key), modes.CTR(nonce)).decryptor()
recovered = decryptor.update(ciphertext) + decryptor.finalize()
print(recovered.decode("utf-8")) # ANA PEREZ MEDICAL NOTECTR is elegant and fast, but it carries a deadly danger that it shares with every stream cipher (ChaCha20 included). Let's see it with code, because it's one of the most expensive lessons to ignore.
The catastrophe of a reused nonce
In any stream cipher, the ciphertext is ciphertext = message XOR keystream, and the keystream depends only on the key and the nonce. So if we encrypt two messages with the same key and the same nonce, both use the same keystream. And there an attacker does magic without knowing the key:
The keystream cancels out and the attacker obtains the XOR of the two plaintext messages, without the key at all. That leaks a great deal, and if the attacker knows (or guesses) part of one message, they recover the corresponding part of the other. Demonstration on two MediNube records mistakenly encrypted with the same nonce:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = os.urandom(32)
nonce = os.urandom(16) # ⚠️ the SAME nonce for two messages: the fatal mistake
m1 = b"PATIENT: Ana Perez BLOOD TYPE: O+"
m2 = b"PATIENT: Juan Prats BLOOD TYPE: AB"
def encrypt_msg(m):
c = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
return c.update(m) + c.finalize()
c1, c2 = encrypt_msg(m1), encrypt_msg(m2)
# The attacker ONLY sees c1 and c2. They compute their XOR:
xor_ciphertexts = bytes(a ^ b for a, b in zip(c1, c2))
xor_plaintexts = bytes(a ^ b for a, b in zip(m1, m2))
print(xor_ciphertexts == xor_plaintexts) # True: C1 XOR C2 == M1 XOR M2, the keystream vanished
# And if the attacker knows/guesses m1 (e.g. because they know the template),
# they solve for m2 completely, without the key:
m2_recovered = bytes(a ^ b for a, b in zip(xor_ciphertexts, m1))
print(m2_recovered) # b'PATIENT: Juan Prats BLOOD TYPE: AB'With just the two ciphertexts and a reasonable guess about one of the messages (the record template is known: Kerckhoffs's rule, the enemy knows the system), the attacker reconstructs the other one completely. The AES-256 key served no purpose whatsoever. This is one of the most frequent and catastrophic ways cryptography breaks due to misuse, and it's not exclusive to CTR: it happens exactly the same to ChaCha20 and to AES-GCM (02-03). Hence the rule that closes this lesson.
Comparison table and the IV/nonce golden rule
| ECB | CBC | CTR | |
|---|---|---|---|
| Idea | Each block independent | Chains with the previous ciphertext | Encrypts a counter, XORs with the message |
| IV / nonce? | None | Random, unpredictable IV (16 B) | Unique nonce+counter (16 B) |
| Padding? | Yes | Yes (PKCS7) | No |
| Ciphertext length | Multiple of 16 | Multiple of 16 | Same as the message |
| Parallelizable | Yes | Encryption no, decryption yes | Yes (both encrypting and decrypting) |
| Characteristic failure | Leaks patterns (penguin) | Padding oracle | Reused nonce |
| Protects integrity | No | No | No |
| Recommendation | Never | Only with added integrity; today, avoid | Only with added integrity (it's the basis of GCM) |
Two conclusions that govern everything that follows:
Golden rule: never reuse the key + IV/nonce pair. With the same key, every message needs its own unique IV/nonce (and, in CBC, unpredictable). Repeating it breaks confidentiality outright, with no need to break the algorithm.
And the big loose end, visible in the table's last row: none of these modes protects integrity. Nothing stops an attacker from modifying the ciphertext so that decrypting produces altered data without anyone noticing. With CTR, it's chillingly easy on top of that (flipping one ciphertext bit changes exactly that bit of the message). Solving this — encrypting and detecting tampering in a single operation — is the leap to AEAD we take in the next lesson, and it's the construction with which we finally encrypt MediNube's records properly.
Common Mistakes and Tips
- Using ECB "because it's the simplest." It's trap number one. If your code calls
modes.ECB()to encrypt real data, that's a security bug. In this course ECB only appeared as a didactic piece for a single loose block. - Reusing the IV/nonce. The costliest mistake of the module. A badly managed global counter, an IV "fixed to keep it simple," a nonce derived from a low-resolution timestamp... they all end up in reuse. Generate the IV/nonce with the CSPRNG on every encryption and store it alongside the data.
- Believing the IV is secret. It isn't: it's public. Its virtue is being unrepeatable (and, in CBC, unpredictable), not hidden.
- Trusting raw CBC. The padding oracle is real and subtle. The professional answer isn't to code the padding validation more carefully, but to use AEAD (02-03).
- Thinking encrypting = protecting. Encrypting gives confidentiality, not integrity. Encryption without authentication is tamperable. That's the bridge to the next lesson.
- Forgetting to store the IV/nonce. Without it, you can't decrypt. It's part of the encrypted data and must be stored with it (we'll formalize this in the
v1format in 02-03).
Exercises
-
ECB detector. Write a function
looks_like_ecb(ciphertext: bytes) -> boolthat, by splitting the ciphertext into 16-byte blocks, returnsTrueif any block repeats. Test it with the ciphertext from the lesson's ECB example and with one produced by CTR on the same data. What do you observe? -
The IV matters. Encrypt the same message twice in CBC, first with the same IV and then with two different IVs. Compare the ciphertexts in both cases and explain the result.
-
Bit-flipping in CTR (an appetizer for 02-03). Encrypt the message
b"BALANCE: 000010 EUR"in CTR. Without knowing the key, manipulate the ciphertext byte corresponding to a digit of the amount (XORing it with a value) so that, when decrypted, the amount changes. What does this demonstrate about integrity?
Solutions
def looks_like_ecb(ciphertext: bytes) -> bool:
blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
return len(blocks) != len(set(blocks))With the ECB ciphertext of repetitive data it returns True (there are repeated blocks → detectable leak). With CTR on the same data it returns False: even though the message's blocks repeat, each one is encrypted with a different position of the keystream, so the ciphertext blocks are all distinct. It's, in miniature, the test analysts use to detect ECB ciphertexts.
-
With the same IV, the two ciphertexts come out identical (same message + same IV + same key = same result; that alone already leaks that the messages are equal). With different IVs, the two ciphertexts are completely different despite encrypting the same thing. That's exactly the IV's job: guaranteeing that encrypting the same data twice doesn't produce the same output.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key, nonce = os.urandom(32), os.urandom(16)
m = b"BALANCE: 000010 EUR"
c = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
ciphertext = bytearray(c.update(m) + c.finalize())
# Position of the last '1' in the amount (index 13). In CTR: ciphertext = m XOR keystream,
# so ciphertext[i] XOR X decrypts to m[i] XOR X. We swap '1'(0x31) for '9'(0x39):
ciphertext[13] ^= (ord('1') ^ ord('9'))
d = Cipher(algorithms.AES(key), modes.CTR(nonce)).decryptor()
print((d.update(bytes(ciphertext)) + d.finalize())) # b'BALANCE: 000090 EUR'Without the key, we've turned a balance of 10 into 90 with surgical precision. This demonstrates that CTR (and every stream cipher) is malleable: confidentiality doesn't imply integrity. Detecting and rejecting this manipulation is exactly what the authenticated encryption of the next lesson adds.
Conclusion
You now know why AES needs a mode of operation and what each one does: ECB leaks patterns and is never used (you saw it with your own eyes on a repetitive record); CBC chains blocks with a random, unpredictable IV and pads with PKCS7, but its padding oracle makes it dangerous to handle; CTR turns AES into a stream cipher, without padding and parallelizable, but shares with ChaCha20 the catastrophe of a reused nonce, which we demonstrated leaks the messages completely. You're taking away the rule that isn't up for debate — never repeat the key + IV/nonce pair — and a huge gap that none of these modes cover: integrity. The bit-flipping in the last exercise makes it crystal clear — a ciphertext can be altered without the key and without anyone detecting it. The solution isn't to stack patches on top of CBC or CTR, but to make the leap to authenticated encryption. In the next lesson, 02-03: Authenticated Encryption (AEAD), we merge confidentiality and integrity into a single operation with AES-GCM and ChaCha20-Poly1305 — and, at last, pay off the course's debt by encrypting MediNube's records at rest with the versioned v1 format. See you there.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
