~/learn / cryptography / symmetric-encryption

Symmetric Encryption

How block and stream ciphers really work, what modes of operation do, a worked CBC bit-flip, how padding oracles and nonce reuse break confidentiality, and why authenticated encryption is the only safe default.

intermediateupdated

Symmetric encryption uses the same secret key to encrypt and decrypt. It is fast, often ten to a thousand times faster than public key operations, which is why it protects essentially all bulk data: TLS traffic after the handshake, full-disk encryption, encrypted backups, and individual database fields. The algorithm is almost never the weak point. The way it is wired together is.

Inside a block cipher

A block cipher like AES is a keyed permutation on a fixed-size block (128 bits for AES). Give it a key and a 16-byte input and it produces a 16-byte output that looks random, and the same key reverses it exactly. Internally AES runs 10 to 14 rounds, each applying substitution (a lookup table called the S-box, which provides confusion, breaking the link between key bits and output bits) and permutation (shifting and mixing bytes across the block, which provides diffusion, spreading each input bit’s influence across the whole block). After a few rounds, flipping one input bit changes about half the output bits unpredictably. That avalanche is what makes the cipher strong, and you do not need the math to use it correctly.

The catch is that a block cipher only encrypts exactly one block. Real messages are longer, so a mode of operation defines how to chain blocks. This is where most mistakes live.

A stream cipher like ChaCha20 takes a different route: it generates a long pseudorandom keystream from the key and a nonce, then XORs it with the plaintext. Several block cipher modes (CTR, GCM) turn AES into a stream cipher by encrypting a counter to produce the keystream.

Modes of operation

Mode How it works Verdict
ECB Each block encrypted independently Leaks structure. Never use it.
CBC Each block XORed with the previous ciphertext before encrypting Needs an unpredictable IV, has no integrity, and enabled the padding oracle class. Legacy only.
CTR Encrypts a counter to make a keystream Fast and parallel, but nonce reuse is catastrophic and it has no integrity.
GCM CTR plus a built-in authentication tag Authenticated. The common default. Nonce reuse is still catastrophic.
XTS Tweakable, for data at rest The standard for disk encryption, where there is no room to store a nonce or tag per sector.

Why ECB leaks

In ECB, identical plaintext blocks produce identical ciphertext blocks, so structure survives encryption. The famous demonstration is the “ECB penguin”: encrypt a bitmap block by block and the penguin’s outline is still clearly visible, because every identical region of pixels maps to the same ciphertext. If a token or cookie shows repeating 16-byte chunks, suspect ECB immediately.

CBC and the bit-flip

CBC decryption computes plaintext_block = decrypt(ciphertext_block) XOR previous_ciphertext_block. The previous ciphertext block is XORed directly into the result, which means an attacker who can change the previous ciphertext block controls the plaintext of the next one, bit for bit, without any key. Suppose an encrypted cookie decrypts to ...;role=user;... and you know the byte offset. Flip the matching bits in the preceding ciphertext block and that byte of plaintext changes to whatever you want (say role=admn adjusted to land on a valid value). The block you tamper with turns to garbage, but if that garbage lands somewhere the application ignores, the controlled block wins. This is why CBC without a MAC is malleable, and why integrity is not optional.

Padding oracles

CBC needs the plaintext padded to a block boundary (PKCS#7). On decryption, the server checks the padding is valid. If it reveals, through an error message, a status code, or even response timing, whether padding was valid separately from whether the content was valid, it becomes an oracle. By submitting crafted ciphertexts and watching the “valid padding” signal, an attacker recovers the plaintext one byte at a time, with no key, in a few hundred guesses per byte. Padding oracle attacks broke real systems for years (and POODLE broke SSL 3.0 the same way). The lesson is not “patch the error message.” It is “use authenticated encryption so a tampered ciphertext is rejected before any padding is ever examined.”

Authenticated encryption is the default

Authenticated encryption with associated data (AEAD) provides confidentiality and integrity together. Change a single byte of the ciphertext, the tag, or the associated data and decryption fails outright rather than returning altered plaintext. That one property removes the entire malleability and padding-oracle family above.

Use AES-256-GCM or ChaCha20-Poly1305. Both are standard in TLS 1.3; ChaCha20 wins on devices without AES hardware acceleration. The associated data field lets you bind context (a user id, a record id, a version number) to the ciphertext so a valid ciphertext cannot be lifted out of one record and replayed into another.

# Python, using the cryptography library
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

key = AESGCM.generate_key(bit_length=256)
aead = AESGCM(key)
nonce = os.urandom(12)                      # unique for every message under this key
ciphertext = aead.encrypt(nonce, b"account balance: 1200", b"user:42")
plaintext = aead.decrypt(nonce, ciphertext, b"user:42")  # raises if anything was altered

Nonces: the number used once

For CTR, GCM, and ChaCha20, encrypting two messages with the same key and nonce produces two ciphertexts that are the plaintext XORed with the same keystream. XOR the two ciphertexts together and the keystream cancels, leaving the XOR of the two plaintexts, which is frequently enough to recover both (this is the “two-time pad” and it is how WEP fell). With GCM it is worse: nonce reuse also leaks the authentication subkey, letting an attacker forge valid tags thereafter (the “forbidden attack”). A fixed nonce constant in code, or a counter that resets to zero on restart, causes this.

Random 96-bit GCM nonces are safe up to a large but finite message count per key (collisions become a concern well before 2^32 messages). For very high volume, use a deterministic counter you are certain never repeats, or an extended-nonce construction like XChaCha20-Poly1305 whose 192-bit nonce makes random collisions a non-issue.

The subtle one: key commitment

A property most engineers have never heard of, and a genuinely clever modern gotcha. Standard AES-GCM is not key-committing: given a ciphertext, an attacker who controls the key can construct a situation where the same ciphertext decrypts successfully under two different keys to two different meaningful plaintexts. This has broken real systems that used AEAD as if a successful decryption proved which key was used (key-rotation and multi-recipient designs especially). If your design relies on “it decrypted, therefore it was my key,” use a committing AEAD or add a hash of the key, and know that this is an active area where even careful teams get surprised.

Key management is the actual hard part

Strong algorithms do nothing if the key sits next to the data or in the repository. In code review and assessments, look for:

  • Keys hardcoded in source, mobile apps, or front-end JavaScript (all of which can be unpacked).
  • Keys in .env files committed to version control, or in CI logs.
  • One key shared across every customer or every environment, so a single leak exposes everyone and everything historical.
  • No rotation plan, which means a leaked key has unbounded blast radius in time.

Production systems keep keys in a KMS or HSM and use envelope encryption: each piece of data is encrypted with a fresh data key, and the data key is itself encrypted by a master key that never leaves the KMS. Rotating the master key then re-wraps data keys without touching the bulk data.

Field notes: attacking block cipher modes

You rarely break AES. You break the mode and the handling around it, and these attacks need no key:

  • Padding oracle (CBC). If the server reveals, by error message, status code, or timing, whether a decrypted ciphertext had valid PKCS#7 padding, you can decrypt the whole message and forge ciphertexts without the key, one byte at a time. padbuster automates it: padbuster <url> <ciphertext> <blocksize> -cookies "auth=<ciphertext>". The lesson is that unauthenticated CBC leaks through its own error handling.
  • CBC bit-flipping. CBC decryption XORs each plaintext block with the previous ciphertext block, so flipping a bit in ciphertext block N flips the same bit in plaintext block N+1 (while scrambling block N). If the first block is an IV you can edit, you rewrite the first plaintext block arbitrarily: turn role=user into role=root in an encrypted-but-unsigned cookie. cyberchef or a few lines of Python do the XOR math.
  • ECB detection and cut-and-paste. ECB encrypts identical plaintext blocks to identical ciphertext blocks, so a repeated 16-byte pattern in the input shows up as repeated ciphertext (the “ECB penguin”). That determinism also lets you shuffle and splice blocks between messages to assemble a ciphertext the server never issued.
  • GCM nonce reuse. Reusing a nonce under one key with GCM is catastrophic: two messages leak their XOR and, worse, the attacker recovers the authentication subkey and can then forge valid tags for arbitrary messages (the “forbidden attack”). An all-zero or counter IV that wraps is the usual cause.

Field notes: recognizing and testing crypto in an app

Capture a token or “encrypted” blob and read it like a tester:

  • Base64 that decodes to a length that is a multiple of 16 suggests a 128-bit block cipher (AES). A multiple of 8 suggests DES/3DES/Blowfish.
  • Flip one character and resubmit. If only one block’s worth of plaintext breaks, it is likely ECB or CBC; a single-bit change breaking everything suggests a stream cipher or proper AEAD.
  • Register two users with near-identical data and compare ciphertexts block by block to spot ECB.
  • If you can edit the blob and the server trusts the result without an integrity error, there is no MAC, which opens bit-flipping and padding-oracle attacks.

All of this is for data from systems you own or are authorized to test. The defensive takeaway is unchanged: authenticated encryption (AES-GCM, XChaCha20-Poly1305) with unique nonces removes the whole category, because tampered ciphertext fails the tag check before any of these oracles can speak.

Testing notes

  • Repeating ciphertext blocks in a token or cookie suggest ECB.
  • Different responses or timings for bad padding versus bad content suggest a padding oracle.
  • A token that decrypts but is never authenticated is malleable: one flipped bit changes one field, per the CBC example above.
  • Ciphertext whose length exactly matches the plaintext with no extra tag bytes usually means no integrity protection at all.

For the classic ciphers and encodings that masquerade as encryption, the cipher workbench breaks them in seconds, which is itself a lesson in why you never build your own.