Introduction

Every time you connect to a website, download an app update, or send an encrypted message, AES is doing the heavy lifting somewhere in the chain. AES โ€” the Advanced Encryption Standard โ€” is a block cipher: it takes a 128-bit chunk of data and a secret key, scrambles them together, and produces 128 bits of ciphertext that look like random noise to anyone without the key.

But real data is never exactly 128 bits. It could be a photo, a database, a stream of video. So how do you use a block cipher to encrypt something larger? You need a mode of operation โ€” a recipe for combining successive blocks.

Here is the uncomfortable truth: the cipher is almost never what breaks. AES has withstood decades of cryptanalysis. The modes, on the other hand, have a long history of catastrophic failures. ECB leaks the structure of your data in plain sight. CBC enabled the POODLE and BEAST attacks. CTR is brilliant but reusing a nonce is fatal. GCM is today's gold standard โ€” and it too can be misused.

The mode is the part nobody teaches you until something breaks.

The ECB Penguin

The most famous demonstration in applied cryptography is the ECB penguin: encrypt the Linux mascot Tux with AES in ECB mode and the penguin is still clearly visible in the ciphertext โ€” because identical plaintext blocks produce identical ciphertext blocks, and the image has large uniform regions.

Draw a shape below (or use the preset) and encrypt it in ECB mode. Notice how the structure survives encryption. Then switch to CTR mode and see the difference.

<div class="controls">
  <div class="btn-group">
    <button id="btn-tux" type="button" class="active">Load Tux shape</button>
    <button id="btn-clear" type="button" class="ghost">Clear canvas</button>
  </div>
  <div class="mode-row">
    <span class="label">Encryption mode:</span>
    <label class="radio"><input type="radio" name="mode" value="ecb" checked> ECB</label>
    <label class="radio"><input type="radio" name="mode" value="ctr"> CTR</label>
  </div>
  <button id="btn-encrypt" type="button">Encrypt</button>
  <button id="btn-decrypt" type="button" class="ghost" disabled>Decrypt</button>
</div>
<div class="canvases">
  <div class="canvas-wrap">
    <div class="canvas-label">Plaintext (draw here)</div>
    <canvas id="plain" width="160" height="160"></canvas>
  </div>
  <div class="arrow">โ†’</div>
  <div class="canvas-wrap">
    <div class="canvas-label">Ciphertext</div>
    <canvas id="cipher" width="160" height="160"></canvas>
  </div>
</div>
<div id="status" class="status"></div>
<div class="legend">
  Block size: 4ร—4 pixels &nbsp;|&nbsp; Grid: 40 blocks (10ร—10 logical blocks shown on 160ร—160 px)
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .75rem; }
.btn-group { display: flex; gap: .4rem; }
.mode-row { display: flex; align-items: center; gap: .5rem; font-size: .88rem; }
.label { font-weight: 600; color: #444; }
.radio { display: flex; align-items: center; gap: .25rem; cursor: pointer; font-size: .88rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1.5px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #e63946; border-color: #c92f3c; }
button:disabled { opacity: .45; cursor: default; }
.canvases { display: flex; align-items: center; gap: .75rem; margin: .5rem 0; }
.canvas-wrap { display: flex; flex-direction: column; align-items: center; gap: .3rem; }
.canvas-label { font-size: .78rem; font-weight: 700; color: #555; letter-spacing: .04em; text-transform: uppercase; }
canvas { border: 1.5px solid #b0bcc8; border-radius: 4px; cursor: crosshair; image-rendering: pixelated; display: block; }
.arrow { font-size: 1.6rem; color: #888; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.3em; margin: .4rem 0; color: #1a6e35; }
.status.warn { color: #c92f3c; }
.legend { font-size: .76rem; color: #888; margin-top: .3rem; }
// ECB vs CTR visual demo โ€” encrypt a 10ร—10 grid of 4-pixel "blocks" on a 160ร—160 canvas.
// ECB: identical pixel blocks โ†’ identical encrypted blocks (pattern visible).
// CTR: block XOR'd with keystream derived from counter โ†’ output looks random.
// This is a SIMPLIFIED toy cipher (not AES) for visual illustration โ€” the security
// properties demonstrated (ECB pattern leak, CTR randomization) are identical to AES.

const BLOCK = 4;          // pixels per block side
const GRID  = 10;         // blocks per row/col (10*4 = 160px per axis)
const TOTAL = GRID * GRID; // 100 blocks

const plainCanvas  = document.getElementById('plain');
const cipherCanvas = document.getElementById('cipher');
const pCtx  = plainCanvas.getContext('2d');
const cCtx  = cipherCanvas.getContext('2d');
const status = document.getElementById('status');

// โ”€โ”€ Toy cipher: 3-byte XOR+rotate permutation (illustrative only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Key is fixed; we only care about the STRUCTURAL property of each mode.
const KEY = [0x5A, 0xA3, 0x7C]; // arbitrary 3-byte "key"

function encryptBlock(bytes) {
  // Encrypt a single 3-byte (RGB) block โ€” used for ECB.
  // Pure XOR: self-inverse (decrypt === encrypt), and deterministic
  // regardless of block position (that IS the ECB property: same block,
  // same key โ†’ same ciphertext no matter where it appears).
  return bytes.map((b, i) => b ^ KEY[i % 3] ^ 0xC9);
}

function ctrKeystream(blockIndex) {
  // CTR keystream for one block: deterministic function of counter + key.
  // Simulates "Encrypt(Key, Counter)" per AES-CTR spec.
  const ctr = blockIndex ^ 0xABCD;
  return KEY.map((k, i) => {
    let v = k ^ ((ctr >> (i * 4)) & 0xff) ^ (i * 0x5C);
    v = ((v << 3) | (v >> 5)) & 0xff; // rotate left 3
    return v ^ 0xE7;
  });
}

// โ”€โ”€ Plain pixel storage: 100 blocks ร— 3 channels (RGB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Each block stores the AVERAGE colour of its 4ร—4 area (simplified pixel block).
const plain = new Uint8Array(TOTAL * 3); // RGB per block

function getMode() {
  return document.querySelector('input[name="mode"]:checked').value;
}

// โ”€โ”€ Tux shape preset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Encoded as a 10ร—10 grid of colours: 0=white bg, 1=black, 2=orange (beak/feet)
const TUX = [
  [0,0,0,1,1,1,1,0,0,0],
  [0,0,1,1,1,1,1,1,0,0],
  [0,1,1,0,1,1,0,1,1,0],
  [0,1,1,1,1,1,1,1,1,0],
  [0,0,1,2,1,1,2,1,0,0],
  [0,0,0,1,1,1,1,0,0,0],
  [0,1,1,1,1,1,1,1,1,0],
  [0,1,1,1,1,1,1,1,1,0],
  [0,0,1,1,0,0,1,1,0,0],
  [0,0,1,0,0,0,0,1,0,0],
];
const TUX_COLORS = [
  [240,240,240], // white bg
  [20, 20, 20],  // black
  [220,140, 20], // orange
];

function loadTux() {
  for (let by = 0; by < GRID; by++) {
    for (let bx = 0; bx < GRID; bx++) {
      const idx = (by * GRID + bx) * 3;
      const [r,g,b] = TUX_COLORS[TUX[by][bx]];
      plain[idx] = r; plain[idx+1] = g; plain[idx+2] = b;
    }
  }
  renderPlain();
  clearCipher();
  status.textContent = '';
  status.className = 'status';
  document.getElementById('btn-decrypt').disabled = true;
}

function renderPlain() {
  pCtx.clearRect(0, 0, 160, 160);
  for (let b = 0; b < TOTAL; b++) {
    const bx = b % GRID, by = Math.floor(b / GRID);
    const r = plain[b*3], g = plain[b*3+1], bl = plain[b*3+2];
    pCtx.fillStyle = `rgb(${r},${g},${bl})`;
    pCtx.fillRect(bx * BLOCK, by * BLOCK, BLOCK, BLOCK);
  }
}

function clearCipher() {
  cCtx.fillStyle = '#e8eef3';
  cCtx.fillRect(0, 0, 160, 160);
}

function renderCipherData(data) {
  cCtx.clearRect(0, 0, 160, 160);
  for (let b = 0; b < TOTAL; b++) {
    const bx = b % GRID, by = Math.floor(b / GRID);
    const r = data[b*3], g = data[b*3+1], bl = data[b*3+2];
    cCtx.fillStyle = `rgb(${r},${g},${bl})`;
    cCtx.fillRect(bx * BLOCK, by * BLOCK, BLOCK, BLOCK);
  }
}

// โ”€โ”€ Drawing on plain canvas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let drawing = false;
let drawColor = [20, 20, 20];

function canvasXY(canvas, e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  const clientX = e.touches ? e.touches[0].clientX : e.clientX;
  const clientY = e.touches ? e.touches[0].clientY : e.clientY;
  return [
    Math.floor((clientX - rect.left) * scaleX / BLOCK),
    Math.floor((clientY - rect.top) * scaleY / BLOCK),
  ];
}

function paintBlock(bx, by, color) {
  if (bx < 0 || bx >= GRID || by < 0 || by >= GRID) return;
  const idx = (by * GRID + bx) * 3;
  plain[idx] = color[0]; plain[idx+1] = color[1]; plain[idx+2] = color[2];
  pCtx.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
  pCtx.fillRect(bx * BLOCK, by * BLOCK, BLOCK, BLOCK);
}

plainCanvas.addEventListener('mousedown', e => {
  drawing = true;
  drawColor = e.button === 2 ? [240,240,240] : [20,20,20];
  const [bx,by] = canvasXY(plainCanvas, e);
  paintBlock(bx, by, drawColor);
  clearCipher();
  document.getElementById('btn-decrypt').disabled = true;
});
plainCanvas.addEventListener('mousemove', e => {
  if (!drawing) return;
  const [bx,by] = canvasXY(plainCanvas, e);
  paintBlock(bx, by, drawColor);
});
window.addEventListener('mouseup', () => { drawing = false; });
plainCanvas.addEventListener('contextmenu', e => e.preventDefault());

// โ”€โ”€ Encrypt / Decrypt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const cipher = new Uint8Array(TOTAL * 3);

document.getElementById('btn-encrypt').addEventListener('click', () => {
  const mode = getMode();
  for (let b = 0; b < TOTAL; b++) {
    const rgb = [plain[b*3], plain[b*3+1], plain[b*3+2]];
    let enc;
    if (mode === 'ecb') {
      enc = encryptBlock(rgb); // same key for every block โ†’ identical blocks stay identical!
    } else {
      // CTR: XOR plaintext with keystream[counter]
      const ks = ctrKeystream(b);
      enc = rgb.map((v, i) => v ^ ks[i % 3]);
    }
    cipher[b*3] = enc[0]; cipher[b*3+1] = enc[1]; cipher[b*3+2] = enc[2];
  }
  renderCipherData(cipher);
  const modeLabel = mode === 'ecb' ? 'ECB' : 'CTR';
  status.textContent = mode === 'ecb'
    ? 'ECB: identical blocks โ†’ identical ciphertext. The shape is still visible.'
    : 'CTR: every block gets a unique keystream. The output looks random.';
  status.className = mode === 'ecb' ? 'status warn' : 'status';
  document.getElementById('btn-decrypt').disabled = false;
});

document.getElementById('btn-decrypt').addEventListener('click', () => {
  const mode = getMode();
  for (let b = 0; b < TOTAL; b++) {
    const rgb = [cipher[b*3], cipher[b*3+1], cipher[b*3+2]];
    let dec;
    if (mode === 'ecb') {
      // ECB decrypt: same operation is its own inverse (pure XOR)
      dec = encryptBlock(rgb);
    } else {
      const ks = ctrKeystream(b);
      dec = rgb.map((v, i) => v ^ ks[i % 3]); // XOR is its own inverse
    }
    plain[b*3] = dec[0]; plain[b*3+1] = dec[1]; plain[b*3+2] = dec[2];
  }
  renderPlain();
  clearCipher();
  status.textContent = 'Decrypted โ€” original plaintext recovered.';
  status.className = 'status';
  document.getElementById('btn-decrypt').disabled = true;
});

document.getElementById('btn-tux').addEventListener('click', loadTux);
document.getElementById('btn-clear').addEventListener('click', () => {
  plain.fill(240);
  renderPlain();
  clearCipher();
  status.textContent = '';
  status.className = 'status';
  document.getElementById('btn-decrypt').disabled = true;
});

// โ”€โ”€ Init โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
loadTux();

// โ”€โ”€ Verification assertions (run in Node.js via: node verify.js) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Assert ECB: same input block โ†’ same output regardless of position.
// Assert CTR: different blocks โ†’ different keystreams.
// Assert round-trip: decrypt(encrypt(x)) === x.
if (typeof process !== 'undefined' && process.versions && process.versions.node) {
  let pass = true;

  // ECB: same plaintext block โ†’ same ciphertext (determinism = the ECB weakness)
  const ecbA = encryptBlock([128, 64, 32]);
  const ecbB = encryptBlock([128, 64, 32]);
  if (JSON.stringify(ecbA) !== JSON.stringify(ecbB)) { console.error('FAIL: ECB determinism'); pass = false; }

  // CTR: two different counters โ†’ different keystreams
  const ks0 = ctrKeystream(0);
  const ks1 = ctrKeystream(1);
  if (JSON.stringify(ks0) === JSON.stringify(ks1)) { console.error('FAIL: CTR keystreams collide at 0 and 1'); pass = false; }

  // CTR round-trip: decrypt(encrypt(x)) === x for 10 random-ish blocks
  for (let b = 0; b < 10; b++) {
    const orig = [(b * 37 + 11) & 0xff, (b * 53 + 199) & 0xff, (b * 71 + 83) & 0xff];
    const ks = ctrKeystream(b);
    const enc = orig.map((v, i) => v ^ ks[i % 3]);
    const dec = enc.map((v, i) => v ^ ks[i % 3]);
    if (JSON.stringify(dec) !== JSON.stringify(orig)) { console.error(`FAIL: CTR round-trip at block ${b}`); pass = false; }
  }

  // ECB round-trip (pure XOR is self-inverse)
  const origEcb = [100, 200, 150];
  const encEcb = encryptBlock(origEcb);
  const decEcb = encryptBlock(encEcb);
  if (JSON.stringify(decEcb) !== JSON.stringify(origEcb)) { console.error('FAIL: ECB round-trip'); pass = false; }

  // ECB: different plaintexts โ†’ different ciphertexts
  const p1 = encryptBlock([0, 0, 0]);
  const p2 = encryptBlock([255, 255, 255]);
  if (JSON.stringify(p1) === JSON.stringify(p2)) { console.error('FAIL: ECB collision'); pass = false; }

  if (pass) console.log('All assertions passed: ECB determinism, CTR keystream uniqueness, CTR/ECB round-trips.');
  process.exit(pass ? 0 : 1);
}
Result
Console

ECB (Electronic Codebook): each 4ร—4 block of pixels is encrypted independently. Identical blocks โ†’ identical ciphertext. The pattern leaks.

CTR (Counter): a counter value is encrypted and XORed with each block of plaintext. Every block gets a unique keystream โ€” the output looks uniformly random regardless of the input. This is how a block cipher becomes a stream cipher.

The lesson: a secure cipher in a broken mode is a broken cipher.

The Real Complexity

Each mode makes a different security promise โ€” and breaks in a different way when misused.

ECB โ€” Electronic Codebook (never use it for real data): Encrypt each block independently. Identical plaintext blocks produce identical ciphertext blocks. The mode is not semantically secure: an attacker who can see which blocks are the same learns structure about the plaintext without breaking AES at all. The ECB penguin is the canonical demonstration. ECB is only appropriate for encrypting a single block.

CBC โ€” Cipher Block Chaining (widely used, widely broken): Each plaintext block is XORed with the previous ciphertext block before encryption. This erases the block-repetition problem, but introduces padding. Because block ciphers require fixed-size blocks, you must pad the last block. That padding, combined with decryption APIs that reveal whether padding is correct, enables the padding oracle attack: an attacker who can submit ciphertexts and observe whether decryption succeeds can decrypt any CBC ciphertext one byte at a time without knowing the key. POODLE (SSL 3.0, 2014) and BEAST (TLS 1.0, 2011) are real-world SSL/TLS exploits built on exactly this weakness.

CTR โ€” Counter Mode (excellent when used correctly): Encrypt a counter that increments with each block and XOR the result with the plaintext. No padding needed; the cipher is used only for encryption (not decryption); and blocks can be processed in parallel. The one critical requirement: never reuse a (key, nonce) pair. If two messages are encrypted with the same nonce, XORing the two ciphertexts cancels the keystream and reveals the XOR of the two plaintexts โ€” which is usually enough to recover both. Project Nayuki and others have published practical attacks on nonce-reusing CTR. See also lattice-based cryptography for attacks on systems that share similar structural weaknesses.

GCM โ€” Galois/Counter Mode (the current standard): CTR encryption plus a polynomial authentication tag over the ciphertext. GCM is authenticated encryption with associated data (AEAD): it simultaneously guarantees confidentiality and that the ciphertext has not been tampered with. It is the mode mandated in TLS 1.3, SSH, and modern HTTPS. The weakness: if the nonce is ever reused, the authentication key is revealed and an attacker can forge arbitrary authenticated ciphertexts. Nonce misuse resistance is an active research area (e.g., AES-GCM-SIV). GCM's provable security rests on the hardness of the discrete logarithm problem over a binary field.

The progression ECB โ†’ CBC โ†’ CTR โ†’ GCM is also the history of cryptographers learning that confidentiality and integrity must be provided together. Encrypting without authenticating (CBC without a MAC) has been broken over and over.

Where It Matters

Block cipher modes are the working layer of almost every security protocol in use today:

  • TLS 1.3 and HTTPS: the only symmetric cipher suites allowed are AEAD modes โ€” AES-128-GCM, AES-256-GCM, and ChaCha20-Poly1305. CBC was removed entirely because padding oracle attacks made it impossible to implement safely. Every time you see the padlock in your browser, AES-GCM is running.
  • Disk and file encryption: BitLocker (Windows) and FileVault (macOS) use XTS-AES, a tweaked mode designed so that identical plaintext blocks at different disk positions encrypt differently. Full-disk encryption with ECB would leak which sectors have identical content.
  • Secure messaging: Signal and WhatsApp use the Double Ratchet algorithm, which derives fresh keys for every message and uses AES-CBC or ChaCha20 (a stream cipher). The ratchet ensures nonce reuse is structurally impossible.
  • Database-level encryption: transparent database encryption frequently used ECB or CBC in early implementations. A 2015 paper showed that CBC-encrypted database columns could be partially sorted and searched by an attacker, leaking query patterns.
  • Breaking real systems: the Wired Equivalent Privacy protocol (WEP, 2001โ€“2004) used RC4 in a mode that reused key streams โ€” effectively the CTR nonce-reuse catastrophe. WPA2's TKIP had the same issue. Both were broken in minutes with commodity hardware. POODLE (Google Security Team, 2014) exploited CBC padding in SSL 3.0 to decrypt HTTPS cookies.

The practical lesson mirrors what we find in factoring and RSA: the hard mathematical core is rarely what breaks. The failure is in how the primitive is composed and deployed.

Conclusion

AES has never been broken. But ECB made a penguin visible in ciphertext. CBC gave attackers the POODLE. CTR handed them XOR-of-plaintexts the moment a nonce was recycled. The cipher was fine; the mode was not.

The arc from ECB to GCM is the story of the cryptography community slowly learning that encryption without authentication is not encryption โ€” it is a locked door with a letterbox big enough to reach through. TLS 1.3 settled the argument by banning every non-AEAD mode outright.

The next time you read that a system "uses AES-256", ask which mode. The key size is the least important detail in the sentence.

Share this article

Pick a channel โ€” or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/block-cipher-modes/Content licensed under CC BY-NC 4.0.