Block Ciphers Decoded: DES, AES, GOST, and the Modes That Make or Break Them
Before You Read
You should already be comfortable with: XOR, hex/byte representation, and basic programming (the demos are Python). No prior cryptography background needed, key schedules and finite-field math get explained inline as they come up.
By the end you’ll understand: what a block cipher actually promises security-wise, how DES/GOST (Feistel) and AES (substitution-permutation) differ structurally, why ECB mode is unsafe even with a “strong” cipher underneath, how CBC and CTR modes fix that in different ways, why doubling a cipher’s key length by chaining two encryptions doesn’t double its security (meet-in-the-middle), and how a padding-validity error message alone can leak an entire plaintext (padding oracle attacks).
What a Block Cipher Actually Is
A block cipher is a pair of functions: encryption E(K,P)=C and decryption D(K,C)=P, both keyed by the same secret K, both operating on a fixed-size chunk of bits at a time. D has to exactly undo E for every key and every block, that’s the only hard functional requirement. The security requirement is stricter and more interesting: a good block cipher should be indistinguishable from a random permutation. Hand an attacker oracle access to E (and maybe D) under a hidden key, and they shouldn’t be able to tell its outputs apart from a uniformly random shuffle of the block space, shouldn’t be able to recover K, and shouldn’t be able to predict how one block transforms based on having seen others.
Block size looks like an arbitrary implementation detail but it’s actually a real tradeoff. Make it too small and the entire input space becomes small enough to enumerate: an attacker just runs the cipher once for every possible input, builds a full lookup table of input to output, and never needs the key again. That’s called a codebook attack, and it doesn’t even require breaking the math, just brute enumeration. Make the block too large, though, and every short message wastes space padding out to a full block, plus the internal diffusion (spreading one input bit’s influence across the whole block) gets harder to achieve efficiently. Real designs land at 64 bits (DES, GOST) or 128 bits (AES): big enough that full enumeration stops being realistic, small enough to fit cleanly into CPU registers and hardware datapaths.
To see why block size matters so much, it helps to do the arithmetic rather than just take it on faith. Take a hypothetical toy cipher with a 24-bit block. Its entire input space is 2^24, about 16.8 million possible blocks. Building a complete lookup table means running the cipher 16.8 million times, once per possible input. At a rough billion-operations-per-second pace, that’s under 20 milliseconds, and storing all those 24-bit-input to 24-bit-output pairs takes on the order of 100MB, trivial on a laptop. Now compare a 64-bit block: the input space balloons to 2^64, about 1.8x10^19 entries. Running the cipher once per possible block at that same billion-op/sec pace takes on the order of 585 years. The point isn’t the specific number, it’s that the attack goes from “runs during a coffee break” to “outlives the attacker who started it” just by growing the block size from 24 to 64 bits.
Two Blueprints: Feistel Networks and Substitution-Permutation Networks
Nobody scrambles a block in one giant computation. Real ciphers iterate a modest, individually unremarkable transformation many times, a “round”, each instance keyed by its own subkey pulled from the master key via a key-schedule algorithm. If every round reused the identical subkey, the whole cipher would collapse into the same simple round function composed with itself over and over, and that kind of repetition is exploitable: an attacker who finds two plaintexts where one is exactly what the round function would produce from the other ends up staring at ciphertexts related by a single, easy-to-study round step. Cracking one round’s behavior is far easier than cracking the whole cipher at once, which is exactly why key schedules go out of their way to make each round’s subkey look unrelated to its neighbors. Skip that precaution and you get the slide attack, a technique built entirely around exploiting self-similar round structure.
There are two dominant blueprints for what happens inside each round. A Feistel network splits the block into two halves, call them L and R, and each round updates L by XOR-ing in a function F applied to R, then swaps the halves for the next round. The trick is that F itself never has to be invertible: the swap-and-XOR structure guarantees the whole round can always be undone regardless of what F computes internally, which frees designers to make F as messy and nonlinear as they like. DES runs 16 such rounds with 48-bit round keys. GOST 28147-89 runs 32 rounds off a 256-bit key split into eight 32-bit subkeys, applied in a mirrored schedule: the eight subkeys in sequence, repeated twice more, then reversed once at the very end. That symmetry looked harmless for decades, until researchers found reflection-style key-recovery shortcuts that exploit exactly that mirrored structure, despite GOST’s large nominal keyspace.
An SPN takes a different approach: instead of only touching half the block per round, it transforms the entire block through two layers every round. A substitution layer runs each byte (or small group of bits) through a fixed lookup table, an S-box, giving nonlinear “confusion”. A permutation or linear layer then spreads each bit’s influence across the whole block, giving “diffusion”. AES is the standard modern example of this style, and it’s worth tracing through in detail next.
Before that, a small hand-worked trace makes the Feistel mechanic concrete. Imagine a toy 8-bit block split into two 4-bit halves, L0=0011 and R0=1010, with a trivial round function F(x) = x XOR 0001 (just flip the low bit).
Round 1: L1 = R0 = 1010, R1 = L0 XOR F(R0) = 0011 XOR (1010 XOR 0001) = 0011 XOR 1011 = 1000. Round 2: L2 = R1 = 1000, R2 = L1 XOR F(R1) = 1010 XOR (1000 XOR 0001) = 1010 XOR 1001 = 0011.
After two rounds the halves have thoroughly mixed even though F itself does almost nothing interesting, that’s the whole point: repetition and the swap structure do the heavy lifting, not any single round’s complexity.
flowchart TB
subgraph Round1["Round 1"]
L0["L0 = 0011"] --> XOR1["XOR"]
R0["R0 = 1010"] --> F1["F(R0)<br/>= R0 xor 0001"]
F1 --> XOR1
XOR1 --> R1["R1 = 1000"]
R0 --> L1["L1 = R0 = 1010"]
end
subgraph Round2["Round 2"]
L1 --> XOR2["XOR"]
R1 --> F2["F(R1)<br/>= R1 xor 0001"]
F2 --> XOR2
XOR2 --> R2["R2 = 0011"]
R1 --> L2["L2 = R1 = 1000"]
end
Inside AES: A Round-by-Round Tour
AES treats a 128-bit block as a 4x4 grid of 16 bytes, called the “state”, filled column by column. It runs 10 rounds for a 128-bit key (12 rounds for 192-bit keys, 14 for 256-bit), and every round except the last applies four operations in sequence.
SubBytes replaces each of the 16 bytes independently through a fixed 256-entry S-box, this is the nonlinear confusion step, identical to the substitution idea from the previous section but applied byte-wise across the whole state at once. ShiftRows then cyclically shifts row i of the grid left by i positions (row 0 untouched, row 1 shifted by 1, and so on), which starts spreading bytes across different columns. MixColumns follows, a fixed matrix multiplication performed over the finite field GF(2^8) that mixes the four bytes within each column together, this is where diffusion really kicks in since a change to one byte now affects three others in its column. Finally AddRoundKey XORs the entire state against that round’s derived subkey.
The very last round deliberately skips MixColumns, and that’s not an oversight or a shortcut for speed alone. MixColumns is a linear operation, which means whatever effect it would have had can be folded mathematically into an adjustment of the surrounding round keys instead. Dropping it from the final round costs exactly zero security while removing one full matrix-multiply pass from the critical path. All the round keys themselves come from a process called KeyExpansion, which derives 11 to 15 round keys from the single master key using the same S-box plus a series of XORs. Notably, this expansion is invertible: anyone who manages to recover even a single round key, say via a side-channel leak on one round, can run the expansion backward and reconstruct the entire master key.
The best way to see these four steps actually do something is to trace one round by hand, in code, with no crypto library involved, just the public S-box table and the fixed GF(2^8) multiply. Here’s a fixed 16-byte plaintext and a fixed 16-byte key driving a single hand-rolled AES round:
Plaintext: b'CRYPTOISFUN_1234'
Key (hex): 2b7e151628aed2a6abf7158809cf4f3c
state (initial, loaded from plaintext):
43 54 46 31
52 4f 55 32
59 49 4e 33
50 53 5f 34
after SubBytes:
1a 20 5a c7
00 84 fc 23
cb 3b 2f c3
53 ed cf 18
after ShiftRows:
1a 20 5a c7
84 fc 23 00
2f c3 cb 3b
18 53 ed cf
after MixColumns:
94 cf f7 61
60 ce b7 45
e8 b4 d8 fb
b5 f9 c7 ec
after AddRoundKey (end of round):
bf e7 5c 68
1e 60 40 8a
fd 66 cd b4
a3 5f 4f d0
Watch how much a single byte’s influence spreads: by the time MixColumns has run, every byte in a column depends on all four original bytes of that column, and once ShiftRows has run once, that mixing starts crossing columns too, over 10 rounds every output byte ends up depending on every input byte and every key byte.
flowchart LR
S["State<br/>(4x4 byte grid)"] --> SB["SubBytes<br/>(S-box lookup,<br/>per byte)"]
SB --> SR["ShiftRows<br/>(row i shifts<br/>left by i)"]
SR --> MC["MixColumns<br/>(GF(2^8) matrix<br/>multiply per column)"]
MC --> ARK["AddRoundKey<br/>(XOR with<br/>round subkey)"]
ARK --> Next["Next round<br/>(final round<br/>skips MixColumns)"]
ECB Mode: Why Encrypting Each Block Alone Fails
A block cipher only ever encrypts one fixed-size block. To encrypt an entire message, you need a mode of operation that says how to chain many block encryptions together. The simplest possible mode, Electronic Codebook (ECB), just runs E(K,·) independently on every plaintext block: C1=E(K,P1), C2=E(K,P2), and so on, with zero linkage enforced between blocks.
That independence has one unavoidable consequence: the same input bytes under the same key always produce the exact same output bytes, full stop, no matter where those matching bytes happen to sit. Same file, different file, sent a week apart, doesn’t matter. Semantic security is supposed to guarantee that ciphertext leaks nothing beyond its length, and ECB’s determinism blows straight through that guarantee. Any large flat region or repeated pattern in the source data, a blank sky in a bitmap, padding bytes, a repeated header, shows up as visibly repeated ciphertext blocks, even though the cipher itself hasn’t been broken at all. AES-128 under ECB is still AES-128, the weakness lives entirely in the mode, not the cipher.
The cleanest way to see this is with an image, since flat color regions map directly onto repeated plaintext blocks. I built a synthetic 256x256 bitmap from scratch: a white canvas, a black filled circle in the center, and a blue bar across the top and a red bar across the bottom, saved as an uncompressed BMP so the raw pixel bytes slice cleanly into 16-byte blocks. I read the pixel data (skipping the BMP header), padded it to a multiple of 16 bytes, and encrypted it once under AES-ECB and once under AES-CTR using the same random key, then spliced each ciphertext back behind the original header to produce two new viewable bitmap files:

The middle image is the ECB version. The circle’s silhouette and the two solid bars are still plainly visible, rendered now as repeating blocks of a different but still-flat color instead of the original ones. The rightmost image is the CTR version of the exact same pixels under the exact same key: uniform noise, no shape recoverable at all. Nothing about AES itself changed between those two images, only the mode wrapped around it.
flowchart TB
subgraph ECB["ECB mode"]
P1["P1"] --> E1["E(K, .)"] --> C1["C1"]
P2["P2 (same as P1)"] --> E2["E(K, .)"] --> C2["C2 (identical to C1)"]
end
subgraph CTR_ref["Any chaining mode"]
Q1["P1"] --> M1["mixed with<br/>prior state"] --> D1["C1"]
Q2["P2 (same as P1)"] --> M2["mixed with<br/>different prior state"] --> D2["C2 (different from C1)"]
end
CBC Mode: Chaining Blocks Together
Cipher Block Chaining (CBC) fixes ECB’s independence problem directly: Ci = E(K, Pi XOR C(i-1)), each ciphertext block feeds into the encryption of the next one. Since there’s no C0 to chain the very first block against, CBC introduces a random initial value, the IV, that stands in for it. Because every block’s input now depends on everything that came before it in the message, identical plaintext blocks stop producing identical ciphertext blocks, and encrypting the same message twice with two different random IVs produces two completely unrelated-looking ciphertexts. The IV itself doesn’t need to be secret, it travels alongside the ciphertext in the clear, its job is uniqueness and unpredictability, not secrecy.
That uniqueness requirement is exactly where things go wrong if it’s violated. If the same IV is ever reused with the same key for two messages that happen to share an identical prefix, the first ciphertext block of both messages will match too, silently confirming to anyone watching that the two plaintexts start out identically, even without revealing what that shared prefix actually says. Picture two transaction requests: “TRANSFER=100USD_TO_ACCT_A” and “TRANSFER=100USD_TO_ACCT_B”. Reuse the IV for both and an eavesdropper immediately learns these are near-identical transfer requests differing only somewhere after the shared prefix, without ever decrypting a single byte.
Real messages are rarely an exact multiple of the block size, so CBC is almost always paired with PKCS#7 padding: append N bytes, each holding the value N, where N is whatever’s needed to reach the next block boundary. If a message already lands exactly on a block boundary, it still gets a whole extra block of 0x10 bytes appended, padding is never optional or skipped. On decryption, the padding gets checked for well-formedness and then stripped. Mishandling exactly that check, leaking whether it passed or failed, is precisely what powers the padding oracle attack covered later in this post.
First, fresh IVs on identical plaintext. Encrypt the same two-block message twice under the same key but a different random IV each time, and measure how different the resulting ciphertexts actually are, not just eyeball two hex dumps but compute the bitwise Hamming distance as a percentage of differing bits:
=== Fresh IV each run (same plaintext) ===
Plaintext: b'THIS IS A SECRET MESSAGE FOR YOU'
IV1: 2181d67978dd818d258541370787ccf2 -> CT1: 270c82eb4c1ac6b0f2f26e0742a356cd87582fe8da1193da673a6f36c4a455e5
IV2: 790cd9455564969fa44cbf28f9cf72d4 -> CT2: a7e3b7b22a6b61edf11e991e543725c32f8d72b03ef56e5c3e40a719d34c16aa
Differing bits: 132/256 = 51.6%
Roughly half the bits differ, statistically what you’d expect from two unrelated-looking outputs. Now the failure case, same IV reused across two plaintexts that share a prefix:
=== Same IV reused (shared-prefix plaintexts) ===
IV (reused): e6a32c3c5a65aa4a837cc8f9b0413f2c
P1: b'TRANSFER=100USD_TO_ACCT_A_PADXXX'
P2: b'TRANSFER=100USD_TO_ACCT_B_PADXXX'
CT1 block0: 1f3abd8174c1417e1eef6c2719df9679 block1: a5e25cc35a5331a0aa04a50dd48b0ec5
CT2 block0: 1f3abd8174c1417e1eef6c2719df9679 block1: 5b4409bf6fdad0db7dab0e2efb51658f
Block0 identical? True
Block1 identical? False
Block 0 comes out byte-for-byte identical in both ciphertexts, exactly because both plaintexts’ first 16 bytes are identical and both were XORed against the same IV before encryption. Block 1 differs because that’s where the account letter and the difference actually live. The leak here isn’t the account digits themselves, it’s the confirmation that these two requests match up to that point, which is often plenty for a curious eavesdropper.
CTR Mode: Turning a Block Cipher into a Stream
Counter (CTR) mode takes a completely different approach from CBC: it never runs the block cipher on the plaintext at all. Instead, it encrypts a counter value joined with a nonce to produce a block of pseudorandom keystream, then XORs that keystream against the plaintext to get ciphertext. Decryption is identical, XOR the same keystream again and the plaintext falls back out. Only the encryption direction E is ever used, D is never needed by either side.
The nonce has to be unique per message under a given key (reusing a nonce across different keys is harmless, since the keystream still differs), and the counter must never repeat within a single message. Violate that and two ciphertext blocks end up sharing the exact same keystream block, at which point XOR-ing those two ciphertexts together makes the keystream cancel out entirely, leaving just the XOR of the two underlying plaintext blocks exposed. That’s the identical failure mode that makes reusing a one-time pad catastrophic, CTR mode is, structurally, a one-time pad built out of a block cipher’s output.
Because keystream generation only depends on key, nonce, and counter, never on the plaintext itself, every keystream block can be produced independently, out of order, on separate cores, even precomputed ahead of time, then simply XORed in whenever the matching plaintext chunk becomes available. Combined with needing no padding at all (any length just XORs against a keystream trimmed to size), that’s what makes CTR both fast and comparatively hard to misuse, as long as nonce/counter uniqueness is never violated.
That independence also buys something CBC structurally can’t offer: random access. Take a 40-byte log line, not a multiple of 16 bytes, encrypt it under CTR, then decrypt only the second block (bytes 16 through 31) by directly recomputing the counter value for block index 1, without touching block 0 at all:
Plaintext: b'user=alice action=login status=ok ts=169' len: 40
Ciphertext (hex): 949c554c2819bdf810046d53d70f9e5ed7e324008f2502c8e2c7c8992ca652e7263bb28bb374ba04
Full decrypt matches original: True
Recovering block index 1 only (bytes 16-31):
Recovered bytes: b'n=login status=o'
Expected slice: b'n=login status=o'
Match: True
Recomputing the counter for block 1 and decrypting just that slice recovers exactly the same bytes as a full decrypt would give for that range, no block 0 required. Try that with CBC and you’d need the preceding ciphertext block just to undo the XOR chain, CTR has no such dependency.
flowchart LR
N["Nonce || Counter=1"] --> E["E(K, .)"] --> KS["Keystream block"]
KS --> X["XOR"]
CT1["Ciphertext block 1"] --> X
X --> PT1["Plaintext block 1<br/>(recovered directly,<br/>no other blocks touched)"]
The Meet-in-the-Middle Attack: Why Double Encryption Doesn’t Double Security
Suppose a cipher’s key feels too short, so someone encrypts twice with two independent n-bit keys: C = E(K2, E(K1, P)). Naive intuition says this should need roughly 2^(2n) brute-force guesses to break, doubling the effective key length for the cost of one extra encryption per block. The meet-in-the-middle (MitM) attack shows that intuition is wrong.
The trick is to stop treating the two encryptions as one inseparable blob and attack them from both ends at once. Build a table of E(K1, P) for every one of the 2^n possible K1 values, indexed by output value. Then, separately, compute D(K2, C) for every one of the 2^n possible K2 values, and check each result against that table. Any match gives a candidate (K1, K2) pair, since it means E(K1,P) landed on the same intermediate value that D(K2,C) reached going backward, confirm the candidate against a second known plaintext/ciphertext pair to rule out coincidental collisions. Total cost: roughly 2x2^n operations (2^n encryptions to build the table, 2^n decryptions to probe it) plus O(2^n) storage, instead of 2^(2n) brute-force guesses. For 56-bit DES keys that’s about 2^57 operations instead of 2^112, meaning double DES ends up barely stronger than single DES despite looking twice as long on paper. It’s exactly why 3DES chains three keys in an encrypt-decrypt-encrypt pattern instead of stopping at two: MitM still applies to the three-key version too, bringing its nominal 168 key bits down to about 112-bit effective security, but going to three keys was the cheapest way to claw back meaningful extra security while reusing existing DES hardware.
To make the magnitude concrete without waiting on real 56-bit DES, I built a toy version with an artificially shrunk keyspace: AES-128 used as the building block, but the effective key restricted to 16 bits by repeating a 2-byte seed to fill out the full 16-byte AES key. That makes C = E(K2, E(K1, P)) have only 2^16 possible values each for K1 and K2, small enough to brute force live on a laptop for demonstration.
Real K1: 0x2bd2 Real K2: 0x3bf0
P: b'MITMDEMO_BLOCK16' -> C: e0c2acd9cbecc9ca2e859feed271f739
=== Naive brute force baseline ===
Measured local encrypt rate: 187980 ops/sec
Naive (K1,K2) pairs to try: 65536 x 65536 = 4294967296 (2^32)
Estimated naive brute-force time at this rate: 22848 sec (~6.3 hours)
=== Meet-in-the-middle attack ===
Recovered K1: 0x2bd2 (real: 0x2bd2)
Recovered K2: 0x3bf0 (real: 0x3bf0)
Match: True
Operation count: 65536 (build table) + 15345 (probe) = 80881 ops (~2x2^16)
Wall time: 0.37 sec
The measured local encrypt rate turns the naive 2^32 pair search into a concrete “would take hours” estimate, while the actual MitM run recovers both correct keys, verified against a second known plaintext/ciphertext pair, in about a third of a second. Same toy cipher, same effective keyspace, roughly a 53000x reduction in work:

flowchart TB
subgraph Forward["Build table: try every K1"]
P0["Known P"] --> EK1["E(K1, P)<br/>for all 2^n K1"] --> Tbl["Table:<br/>output -> K1"]
end
subgraph Backward["Probe: try every K2"]
C0["Known C"] --> DK2["D(K2, C)<br/>for all 2^n K2"] --> Look["Look up in table"]
end
Tbl --> Look
Look --> Match["Match found -><br/>candidate (K1, K2),<br/>verify on 2nd pair"]
Padding Oracle Attacks: Turning Error Messages into a Decryption Tool
A padding oracle is any system that, after decrypting attacker-supplied CBC ciphertext, leaks whether the result ended in syntactically valid PKCS#7 padding, whether through a distinct error code, a different exception type, or even just a measurable timing difference, all while the actual plaintext content stays hidden. That single valid/invalid bit turns out to be enough to recover the entire plaintext, without the attacker ever learning the key.
Here’s the mechanism. CBC decryption works by running the block cipher’s raw decryption on a ciphertext block, then XORing that raw output against the preceding ciphertext block to produce the actual plaintext. An attacker who controls that preceding block, by forging their own bytes and pairing them with a real target ciphertext block, can manipulate the last byte of the decrypted result freely. Try all 256 values for the last byte of the forged preceding block, and the padding oracle flips from “invalid” to “valid” at exactly one point: whichever forged byte makes the last decrypted byte equal 0x01, a valid one-byte pad. Since that decrypted byte is (raw cipher output byte) XOR (forged byte), and it now equals a known value (1), the raw cipher output byte at that position falls right out. Repeat this working backward, one byte at a time, from the last position to the first, at each step locking in the already-recovered bytes of the forged block so they force the correct target pad length (1, then 2, then 3, and so on), and the entire hidden intermediate decryption value falls out byte by byte. XOR that recovered intermediate block against the real preceding ciphertext block afterward and the actual plaintext appears.
Query cost here scales with message length, one block takes at most 256x16 oracle queries, not with key size, which is exactly what makes this attack so dangerous regardless of whether the underlying cipher is DES, AES, or anything else considered cryptographically strong. This isn’t a theoretical curiosity either: a padding oracle vulnerability in ASP.NET’s ViewState and cookie encryption, disclosed in 2010, let attackers decrypt and even forge encrypted cookies on live production sites just by watching how the server responded to tampered ciphertext.
To make this concrete, I built a small oracle function that simulates exactly this kind of leaky server endpoint: it CBC-decrypts a forged (preceding_block, target_ciphertext_block) pair under a fixed random key, using the forged block as the IV for that single decryption, and returns only True or False depending on whether PKCS#7 padding validated:
Secret plaintext: b'PAY BOB $9000'
Padded to 16 bytes: b'PAY BOB $9000\x03\x03\x03'
Real IV: b42941fa77bd5226928761cd3f7e63c3
Real ciphertext block: 6d670359cc24cc2d52b58a57244bb014
Then the byte-at-a-time recovery loop runs, working from the last byte of the block back to the first, locking in already-recovered bytes each time so the forced pad length increases by one:
byte 15: recovered intermediate=0xc0 (target pad value 1, winning guess byte=0xc1)
byte 14: recovered intermediate=0x60 (target pad value 2, winning guess byte=0x62)
byte 13: recovered intermediate=0x7d (target pad value 3, winning guess byte=0x7e)
byte 12: recovered intermediate=0x0f (target pad value 4, winning guess byte=0x0b)
byte 11: recovered intermediate=0xfd (target pad value 5, winning guess byte=0xf8)
byte 10: recovered intermediate=0x51 (target pad value 6, winning guess byte=0x57)
byte 9: recovered intermediate=0xbe (target pad value 7, winning guess byte=0xb9)
byte 8: recovered intermediate=0xb6 (target pad value 8, winning guess byte=0xbe)
byte 7: recovered intermediate=0x06 (target pad value 9, winning guess byte=0x0f)
byte 6: recovered intermediate=0x10 (target pad value 10, winning guess byte=0x1a)
byte 5: recovered intermediate=0xf2 (target pad value 11, winning guess byte=0xf9)
byte 4: recovered intermediate=0x35 (target pad value 12, winning guess byte=0x39)
byte 3: recovered intermediate=0xda (target pad value 13, winning guess byte=0xd7)
byte 2: recovered intermediate=0x18 (target pad value 14, winning guess byte=0x16)
byte 1: recovered intermediate=0x68 (target pad value 15, winning guess byte=0x67)
byte 0: recovered intermediate=0xe4 (target pad value 16, winning guess byte=0xf4)
XOR that fully recovered intermediate block against the genuine original preceding ciphertext block (the real IV, in this single-block example) and the real plaintext drops out:
Recovered padded plaintext: b'PAY BOB $9000\x03\x03\x03'
Original padded plaintext: b'PAY BOB $9000\x03\x03\x03'
Match: True
Total oracle queries used: 2086
An exact match, recovered without ever touching the encryption key, using nothing but a yes/no padding-valid signal and 2086 oracle queries for a single 16-byte block. Scale that to a multi-block message and the query count grows linearly with the number of blocks, still nowhere near what it would cost to brute-force the key directly.
flowchart TB
A["Forge preceding block,<br/>try all 256 values<br/>for one byte"] --> B{"Oracle says<br/>padding valid?"}
B -- "No" --> A
B -- "Yes" --> C["Decrypted byte at<br/>that position = target<br/>pad value -> solve for<br/>intermediate byte"]
C --> D{"All 16 bytes<br/>of block done?"}
D -- "No, move to<br/>next byte inward" --> A
D -- "Yes" --> E["XOR intermediate block<br/>against real preceding<br/>ciphertext -> plaintext"]
Choosing (and Not Misusing) a Mode of Operation
Never use ECB for anything beyond a single, unrelated block. Its block-by-block independence leaks plaintext structure no matter how strong the underlying cipher is, AES-256 under ECB leaks exactly as visibly as AES-128 under ECB. CBC is safe only with a fresh, unpredictable IV per message and a padding-validation path that doesn’t leak timing or error differences to an attacker, or better yet, a padding scheme immune to oracle attacks in the first place. CBC also can’t be encrypted in parallel across blocks, though decryption can, since decrypting each block only needs the two adjacent ciphertext blocks, not the running chain. CTR avoids padding entirely, parallelizes fully in both directions, and is generally the easiest classic mode to use correctly, provided nonce/counter uniqueness is never violated even once.
None of ECB, CBC, or CTR on their own protect message integrity. An attacker who can’t read the plaintext can still flip bits in the ciphertext and predictably corrupt or manipulate the decrypted plaintext, with none of these three modes raising any alarm. That’s precisely why modern systems increasingly reach for authenticated encryption modes, AES-GCM being the most common, that bundle in tamper detection alongside confidentiality. That combination deserves its own dedicated post.
Key Takeaways
- A block cipher’s security goal is indistinguishability from a random permutation, and block size is a real tradeoff between codebook-attack resistance (bigger is safer) and wasted padding space (smaller is more efficient), which is why real designs settle at 64 or 128 bits.
- DES and GOST use Feistel networks (split, swap, XOR a non-invertible round function), AES uses a substitution-permutation network (SubBytes, ShiftRows, MixColumns, AddRoundKey each round), and both rely on round keys that deliberately look unrelated to each other.
- ECB mode encrypts each block independently, so identical plaintext blocks always produce identical ciphertext blocks, visibly leaking structure like flat image regions even when the cipher itself is unbroken.
- CBC chains ciphertext blocks together via XOR and needs a fresh unpredictable IV per message plus PKCS#7 padding; CTR turns the block cipher into a keystream generator XORed against plaintext, needs no padding, and supports random access and parallel encryption, but both modes require strict nonce/IV uniqueness to stay safe.
- Meet-in-the-middle attacks show that naively chaining two independent-key encryptions only costs an attacker roughly 2x2^n operations instead of 2^(2n), which is why 3DES needed three keys, not two, to meaningfully raise effective security.
- A padding oracle, any system that leaks valid/invalid PKCS#7 padding after CBC decryption, lets an attacker recover full plaintext byte by byte using only that one bit of feedback per query, with query cost scaling in message length rather than key size.