AES is fine. The mode around it is the challenge
ECB detection and cut-and-paste, CBC bit flipping, the padding oracle, and what happens when a CTR or GCM nonce repeats. Five attacks that never touch the block cipher itself, because the mode is where CTF authors put the bug.
Nobody in a CTF is going to break AES. The block cipher is a 128-bit permutation and it does its job. What breaks is everything wrapped around it: how the message gets chopped into blocks, what gets XORed into what, how the last partial block is padded, and whether a nonce was reused. Those are the *mode*, and the mode is the entire attack surface.
So the first question on any AES challenge is never "how do I break AES". It is which mode is this, and does the mode leak? The answer is usually visible in the ciphertext before you read a line of source.
Reading the mode off the ciphertext
| Observation | What it means |
|---|---|
| Length is an exact multiple of 16 | A block mode with padding: ECB or CBC. Also true of raw CBC-MAC output. |
| Length is arbitrary, not a multiple of 16 | A stream mode: CTR, OFB, CFB, or GCM. There is no padding to attack, but there is a keystream to reuse. |
| Two 16-byte blocks in one ciphertext are identical | ECB, essentially always. No other mode does this. |
| Ciphertext is 16 bytes longer than plaintext, and the first block changes every time | CBC with a prepended random IV. |
| Ciphertext is 28 bytes longer than plaintext | GCM: a 12-byte nonce and a 16-byte tag. |
| Same plaintext encrypts to the same ciphertext twice | Deterministic. Either ECB, or a fixed IV/nonce - and a fixed nonce is fatal in the stream modes. |
ECB: the mode that is a lookup table
ECB encrypts each 16-byte block independently with the same key. That makes it a deterministic, keyed substitution over 16-byte symbols - and every weakness of a substitution cipher comes back, just at a larger alphabet. Identical plaintext blocks give identical ciphertext blocks, and blocks can be reordered, duplicated, or deleted without the decryptor noticing.
Byte-at-a-time decryption
The classic ECB challenge gives you an oracle that encrypts yourInput || secret under a fixed key. You recover secret one byte at a time, and you never learn the key.
The idea: pad your input so the first unknown byte of the secret sits in the last position of a block. Record that block. Then encrypt every candidate for that position and match. Because ECB is deterministic, one of the 256 candidates produces the identical block.
def recover(oracle, block=16):
known = b""
while True:
# Push the next unknown byte into the final slot of block index i.
pad = b"A" * ((-len(known) - 1) % block)
i = (len(pad) + len(known)) // block
target = oracle(pad)[i * block:(i + 1) * block]
for guess in range(256):
probe = pad + known + bytes([guess])
if oracle(probe)[i * block:(i + 1) * block] == target:
known += bytes([guess])
break
else:
# No match: we ran off the end of the secret into the padding,
# which changed underneath us. Everything before this is correct.
return known[:-1]Cut and paste
When the plaintext is structured - a cookie like user=guest&role=user - and you control part of it, you are not decrypting anything. You are rearranging blocks. Align the field boundary you want to a block boundary, get the server to encrypt a block containing admin plus valid padding, and splice that block into position.
email=AAAAAAAAAA admin\x0b*11 &role=user...
|--- block 0 ---|--- block 1 ---|--- block 2 ---|
^ ask the oracle to encrypt this, keep block 1
email=AAAAAAAAAAAAA&role= user...
|--- block 0 ---|--- block 1 ---|
^ splice the saved block hereCBC bit flipping: controlled corruption
CBC decryption computes P[i] = D(C[i]) ^ C[i-1]. The previous ciphertext block is XORed straight into the plaintext, which means whoever controls `C[i-1]` controls `P[i]` bit for bit - at the cost of destroying block i-1 completely, because D(C[i-1]) is now applied to a block you tampered with.
So the trade is: sacrifice one block of plaintext to garbage, and in exchange rewrite the next block to anything you want. In a CTF the sacrificed block is usually a field nobody validates, and the rewritten block is the one that says admin=false.
# We know P[i] currently reads b";role=user; " and we want b";role=admin; ".
delta = bytes(a ^ b for a, b in zip(current, desired))
ct = bytearray(ciphertext)
for j, d in enumerate(delta):
ct[(i - 1) * 16 + j] ^= d # block i-1 becomes garbage; block i becomes oursIf the field you want is in the *first* block, there is no C[i-1] to corrupt - there is the IV, and P[0] = D(C[0]) ^ IV. Flipping bits in a transmitted IV rewrites the first block with no collateral damage at all. This is why an IV sent alongside the ciphertext, unauthenticated, is itself a finding.
The padding oracle
A padding oracle is any distinguishable difference between "the padding was invalid" and "the padding was fine but something later failed". It does not have to be an error message. A different status code, a different response length, or a measurably different response time all work - a timing difference is a side channel and is exploited the same way.
Given that oracle, you decrypt arbitrary CBC ciphertext without the key. The mechanism is the same identity as bit flipping, run backwards. For the last byte of block i:
- Take
C[i-1] || C[i]as a two-block message and replaceC[i-1]with bytes you control, call itC'. - Vary the last byte of
C'through all 256 values. Exactly one (usually) makes the padding valid, and valid padding of length 1 means the decrypted last byte is0x01. - So
D(C[i])[15] ^ C'[15] = 0x01, which gives youD(C[i])[15]- the *intermediate* byte, independent of any IV. - The real plaintext byte is
D(C[i])[15] ^ C[i-1][15], using the genuine previous block. - Now set the last byte of
C'to produce0x02, brute the second-to-last for valid padding, and walk left through the block.
def decrypt_block(oracle, c_prev, c_block):
inter = bytearray(16) # D(c_block), recovered right to left
for pos in range(15, -1, -1):
pad = 16 - pos
forged = bytearray(16)
for j in range(pos + 1, 16):
forged[j] = inter[j] ^ pad # make every known byte decrypt to pad
for guess in range(256):
forged[pos] = guess
if oracle(bytes(forged) + c_block):
# Guard against the false positive at pos == 15: the hit may be
# 0x02 0x02 rather than 0x01. Perturb the byte to its left and
# re-ask; a genuine 0x01 is unaffected.
if pos == 15:
probe = bytearray(forged); probe[14] ^= 0xFF
if not oracle(bytes(probe) + c_block):
continue
inter[pos] = guess ^ pad
break
else:
raise RuntimeError(f"no valid padding at byte {pos}")
return bytes(a ^ b for a, b in zip(inter, c_prev))c_prev.The same oracle also *encrypts*. Pick the plaintext you want for the final block, use the recovered intermediate values to solve for the ciphertext block before it, and chain leftwards. You end with a ciphertext that decrypts to a message of your choosing, and again you never learned the key.
Stream modes: the nonce is the whole game
CTR, OFB, CFB and GCM turn the block cipher into a keystream generator and XOR it with the plaintext. There is no padding, so no padding oracle. There is instead one catastrophic failure mode: reuse the nonce with the same key and you have generated the same keystream twice, which is exactly the two-time pad from XOR and crib dragging.
C1 = P1 ^ KS(key, nonce)
C2 = P2 ^ KS(key, nonce)
C1 ^ C2 = P1 ^ P2 # crib drag from here; the key never appearsCTR mode is also perfectly malleable, like CBC but without collateral damage: flipping bit *n* of the ciphertext flips bit *n* of the plaintext and nothing else. If you know any plaintext at a known offset, you can replace it with anything of the same length.
GCM, and why nonce reuse is worse there
GCM is CTR plus an authentication tag, and the tag is a polynomial evaluated over GF(2^128) using an authentication key H = E(key, 0^128). Reusing a nonce does not just leak the plaintexts - it makes that polynomial solvable. Two messages under the same nonce give you an equation whose root is H, and once you have H you can forge a valid tag for *any* message under that nonce. This is the "forbidden attack", and it turns a confidentiality failure into a full authentication bypass.
Working an AES challenge in order
- Measure. Encrypt the same input twice. Different output means randomised IV or nonce; identical means deterministic and you are already halfway.
- Classify. Multiple of 16 with a repeat under a long constant input is ECB. Multiple of 16 without repeats is CBC. Not a multiple of 16 is a stream mode.
- Find the oracle. What does the server tell you? Decryption errors, padding errors, timing, response length. Any distinguishable failure is a channel.
- Check for authentication. No MAC and no tag means the ciphertext is malleable, and malleability is usually the intended path.
- Read the nonce and IV handling. Fixed, predictable, or reused is fatal in exactly the ways above.
- Only then look at the key. If a key is derived from something guessable - a PIN, a timestamp, a username - the mode was never the point and you should be cracking the derivation instead.
That order matters because it goes cheapest first. Four probes tell you the mode; the mode tells you which of five attacks applies; and every one of them recovers plaintext without ever touching the 128-bit permutation at the centre.