Skip to content
All posts
cryptorevised May 6, 20269 min read

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

ObservationWhat it means
Length is an exact multiple of 16A block mode with padding: ECB or CBC. Also true of raw CBC-MAC output.
Length is arbitrary, not a multiple of 16A 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 identicalECB, essentially always. No other mode does this.
Ciphertext is 16 bytes longer than plaintext, and the first block changes every timeCBC with a prepended random IV.
Ciphertext is 28 bytes longer than plaintextGCM: a 12-byte nonce and a 16-byte tag.
Same plaintext encrypts to the same ciphertext twiceDeterministic. Either ECB, or a fixed IV/nonce - and a fixed nonce is fatal in the stream modes.
Encrypt the same input twice and diff the outputs. That single experiment separates deterministic modes from randomised ones and costs one request.

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]
128 bits of secret in about 4,000 oracle calls, at 256 calls per byte. If the oracle is slow, note that you only need to try printable bytes first.

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 here
Two crafted registrations and a splice. The server decrypts a message it never produced, and every block individually verifies.

CBC 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 ours

If 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:

  1. Take C[i-1] || C[i] as a two-block message and replace C[i-1] with bytes you control, call it C'.
  2. 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 is 0x01.
  3. So D(C[i])[15] ^ C'[15] = 0x01, which gives you D(C[i])[15] - the *intermediate* byte, independent of any IV.
  4. The real plaintext byte is D(C[i])[15] ^ C[i-1][15], using the genuine previous block.
  5. Now set the last byte of C' to produce 0x02, 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))
256 requests per byte worst case, 4,096 per block - and it recovers plaintext, not the key. To decrypt block 0 you pass the IV as 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 appears

CTR 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

  1. Measure. Encrypt the same input twice. Different output means randomised IV or nonce; identical means deterministic and you are already halfway.
  2. 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.
  3. Find the oracle. What does the server tell you? Decryption errors, padding errors, timing, response length. Any distinguishable failure is a channel.
  4. Check for authentication. No MAC and no tag means the ciphertext is malleable, and malleability is usually the intended path.
  5. Read the nonce and IV handling. Fixed, predictable, or reused is fatal in exactly the ways above.
  6. 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.