algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

the CBC padding oracle — decrypting without the key

2026-03-183 min readcrypto ctf

The padding oracle is the canonical lesson that confidentiality without integrity is no confidentiality at all. A server using AES-CBC that distinguishes "bad padding" from "bad data" — by error message, status code, or response time — hands you a one-bit oracle, and that one bit is enough to decrypt any ciphertext you can replay.

target
scheme
AES-CBC + PKCS#7 padding
oracle
1 bit — "was the padding valid after decryption?"
goal
recover plaintext (and forge ciphertext) with no key

the structure the attack exploits#

CBC decryption XORs each decrypted block with the previous ciphertext block:

Pi=DK(Ci)Ci1

The attacker can't compute DK, but they fully control Ci1 when replaying. Call the unknown Ii=DK(Ci) the intermediate. If you send a crafted block C in front of a target block Ct, the server computes P=ItC — and tells you whether P ends in valid PKCS#7 padding. You're choosing C, so you can drive P to a padding you want and solve for It.

one byte at a time#

PKCS#7 pads with the pad length repeated (01, or 02 02, or 03 03 03, …). Recover the intermediate's last byte by making the last plaintext byte 0x01:

recover I_t[15] (the last intermediate byte)txt
1
2
3
4
5
6
7
for guess in 0..255:
    C'[15] = guess                       # leave C'[0..14] arbitrary
    send  C' || C_t  to the oracle
    if padding_valid:                    # P'[15] == 0x01
        I_t[15] = guess XOR 0x01         # because P'[15] = I_t[15] XOR C'[15]
        break
plaintext[15] = I_t[15] XOR C_{t-1}[15]  # XOR with the REAL previous block

Then climb to the next byte: set C'[15] so P'[15] == 0x02 (you know I_t[15] now), and brute C'[14] until the oracle reports valid 02 02:

generalise to byte k (target pad value = p = 16-k)txt
1
2
3
4
fix C'[k+1..15] so those plaintext bytes all equal p   (using known I_t bytes)
brute C'[k] until padding_valid:
    I_t[k] = C'[k] XOR p
    plaintext[k] = I_t[k] XOR C_{t-1}[k]

Sixteen bytes, at most 256 queries each — a few thousand requests to decrypt a block, with no key in sight. Walk every block and the whole message falls.

full plaintext from a yes/no
txt
1
2
3
4
5
[*] block 2/3  byte 15: 142 queries  -> 'n'
[*] block 2/3  byte 14:  88 queries  -> 'o'
...
recovered: {"user":"admin","role":"guest"}
segfault{one_bit_of_padding_is_one_bit_too_many}

And it runs in reverse: because you can force any plaintext block by choosing C, the same oracle lets you encrypt chosen plaintext — forging a valid-looking ciphertext (e.g. flipping role:guest to role:admin) without the key.

the fix is integrity#

  • Authenticated encryption. AES-GCM, ChaCha20-Poly1305, or encrypt-then-MAC: the MAC is checked first, so a tampered ciphertext is rejected before any padding is ever examined — there's no oracle to query.
  • Don't leak the distinction. Identical responses (and constant-time padding checks) for "bad padding" and "bad MAC"/"bad data" — but this is a fragile patch next to just authenticating.
  • MAC-then-encrypt is not enough, and unauthenticated CBC never is.

The padding oracle is why "encrypt with AES" is not a security claim. CBC keeps the plaintext secret only as long as nobody can tell a malformed decryption from a well-formed one — and almost every real system, somewhere, tells you.