You can read AES-CBC ciphertext without the key
You captured a ciphertext. It is AES-CBC (Cipher Block Chaining), the key is on a server you do not control, and there is no key leak anywhere in sight. Most beginners stop here and assume AES means game over. It is not. If the server ever tells you, directly or indirectly, whether a ciphertext you submit has valid padding, you can decrypt every byte of that ciphertext without ever touching the key.
That single yes-or-no answer is called a padding oracle, and it leaks plaintext one byte at a time. Its twin is CBC bit-flipping: because of how CBC chains blocks together, flipping one bit in the ciphertext flips exactly one predictable bit in the decrypted plaintext, which lets you forge a message you were never supposed to be able to write (the classic move is turning admin=0 into admin=1). Both attacks come from the same structural fact about CBC, so this post covers them together.
A padding oracle does not crack AES. It tricks the server into spelling the plaintext out for you, one yes-or-no question at a time.
Here is the whole picture before the details. CBC decryption computes P_i = D(C_i) XOR C_{i-1}, where D is the raw block decrypt. You do not know D(C_i), but you fully control C_{i-1} because it is just bytes in the ciphertext you submit. The oracle lets you discover D(C_i) one byte at a time by manipulating C_{i-1} until the padding comes out valid. Once you know D(C_i), you recover the real plaintext, and you can also forge any plaintext you like. The rest of this post is just that idea, slowed down.
How does CBC actually chain blocks together?
AES is a block cipher. On its own it only knows how to transform one fixed-size block (16 bytes for AES) into another. A mode of operation is the wrapper that turns that single-block primitive into something that encrypts a whole message. CBC is one such mode, standardized in NIST SP 800-38A.
CBC encryption XORs each plaintext block with the previous ciphertext block before encrypting, so each block depends on every block before it:
Encryption: C_i = E(P_i XOR C_{i-1}) with C_0 = IVDecryption: P_i = D(C_i) XOR C_{i-1} with C_0 = IVE = AES encrypt one block with the secret keyD = AES decrypt one block with the secret keyIV = initialization vector, prepended as the 'block before block 1'
The decryption equation is the one that matters for the entire attack, so stare at it. When the server decrypts block i, it computes D(C_i) (a value you cannot predict, because D uses the secret key) and then XORs in C_{i-1}, the previous ciphertext block. That previous block is just 16 bytes sitting in the ciphertext. You are holding the ciphertext, so you control those 16 bytes completely.
D(C_i) is fixed the moment the ciphertext exists, because C_i is fixed and the key is fixed. The only knob you turn is C_{i-1}. That means you can choose the plaintext P_i the server computes, even though you do not know the key, simply by choosing C_{i-1}. Hold onto that. It is the whole attack.One consequence worth naming now: the IV is not a secret. It is just the block that comes before block 1. If you control the IV you can control the decryption of the very first block exactly the way you control later blocks by editing the ciphertext before them.
What is PKCS#7 padding and why does the server check it?
CBC only encrypts whole blocks. Real messages are rarely an exact multiple of 16 bytes, so the message gets padded to a block boundary before encryption. The near-universal scheme is PKCS#7: if you need N bytes of padding, you append N copies of the byte value N.
1 byte short -> ... 012 bytes short -> ... 02 023 bytes short -> ... 03 03 03...exact multiple -> a full extra block of 10 10 10 ... 10 (0x10 = 16)
When the server decrypts, it strips the padding before handing the message to the application. To strip it, it must first validate it: read the last byte N, confirm that the final N bytes are all equal to N, and reject the message if they are not. That validation step is where the leak lives. The server has to look at the decrypted bytes and decide "valid padding" or "invalid padding", and any way that decision becomes observable to you is an oracle.
What exactly is a padding oracle?
A padding oracle is anything that tells you whether a ciphertext you submit decrypts to something with valid PKCS#7 padding. It does not have to be a polite "bad padding" error message. The signal can be subtle, and CTF authors love hiding it:
- An explicit error string:
"invalid padding"versus"decryption failed"versus a generic 500. - A different HTTP status code, or a different response length, for bad padding versus a padding-valid-but-content-invalid message.
- A timing difference: the server checks padding first and only does expensive work (parsing, a MAC check) when padding passes.
- A connection that hangs, resets, or returns a stack trace on one path but not the other.
Any of these splits the universe of ciphertexts you submit into two buckets, "padding OK" and "padding bad". That binary answer is all you need. Vaudenay showed in 2002 that this single bit of feedback is enough to fully decrypt CBC ciphertext (Security Flaws Induced by CBC Padding, EUROCRYPT 2002).
The server thinks it is answering a harmless question about formatting. It is actually handing you a decryption machine.
How do you decrypt one block, byte by byte?
Take two ciphertext blocks: C_{i-1} and C_i. You want to recover P_i = D(C_i) XOR C_{i-1}. The unknown is the intermediate block I = D(C_i). If you learn I, then P_i = I XOR C_{i-1} with the real C_{i-1} falls out by a single XOR.
To learn I, you submit a forged two-block ciphertext to the oracle: C' || C_i, where C' is a 16-byte block you choose. The server decrypts the second block and computes P' = I XOR C'. You do not care what P' means; you only care whether its padding is valid. Now work the last byte.
Recover the last byte. Set the first 15 bytes of C' to anything (zeros are fine) and walk the last byte C'[15] through all 256 values. For exactly one value, the decrypted block ends in a valid pad. In the overwhelmingly common case that value makes the last plaintext byte equal 0x01, a valid one-byte pad. At that moment:
P'[15] = I[15] XOR C'[15] = 0x01 (valid 1-byte padding)=> I[15] = C'[15] XOR 0x01and the REAL plaintext byte is:P_i[15] = I[15] XOR C_{i-1}[15] (using the genuine previous block)
0x02, your search might land on a 0x02 0x02 pad instead of 0x01. Confirm the hit by changing C'[14] and re-querying: if padding stays valid, you really found the 0x01 case; if it breaks, you hit a longer pad and should account for it. pwntools and padbuster handle this edge case for you.Recover the next byte. Now force a two-byte pad. You know I[15], so you can set C'[15] = I[15] XOR 0x02 to make the last plaintext byte equal 0x02. Then walk C'[14] through 256 values until the block ends in a valid 0x02 0x02 pad. When it does:
P'[14] = I[14] XOR C'[14] = 0x02=> I[14] = C'[14] XOR 0x02P_i[14] = I[14] XOR C_{i-1}[14]
Repeat down to byte 0, each time forcing the known tail to the next pad length (0x03 0x03 0x03, then 0x04..., and so on) and brute-forcing the one unknown byte to its left. Sixteen positions, at most 256 queries each, so at most 4096 oracle queries recover a full 16-byte block. Do this for every block in the ciphertext and you have the entire plaintext, key never required.
A working Python padding-oracle loop
Here is a self-contained decryptor. Replace oracle() with whatever talks to your target: an HTTP request, a netcat send, a function call. It must return True when the submitted ciphertext has valid padding and False otherwise. Everything else is generic.
#!/usr/bin/env python3# CBC padding-oracle decryptor. Recovers plaintext with no key.BLOCK = 16def oracle(ct: bytes) -> bool:# TODO: send ct to the target, return True iff padding is valid.# Example shape for an HTTP oracle:# r = requests.post(URL, data={'c': ct.hex()})# return b'invalid padding' not in r.contentraise NotImplementedErrordef decrypt_block(prev: bytes, cur: bytes) -> bytes:# Recover I = D(cur), then P = I XOR prev.inter = bytearray(BLOCK) # the intermediate D(cur)forged = bytearray(BLOCK) # the C' block we controlfor pad in range(1, BLOCK + 1):pos = BLOCK - pad# set the already-known tail to the current pad lengthfor k in range(pos + 1, BLOCK):forged[k] = inter[k] ^ pad# brute-force the one unknown bytefound = Falsefor guess in range(256):forged[pos] = guessif oracle(bytes(forged) + cur):# guard against the 0x01-vs-longer-pad false positiveif pad == 1:forged[pos - 1] ^= 0xFFok = oracle(bytes(forged) + cur)forged[pos - 1] ^= 0xFFif not ok:continueinter[pos] = guess ^ padfound = Truebreakif not found:raise RuntimeError(f'no valid byte at position {pos}')return bytes(inter[i] ^ prev[i] for i in range(BLOCK))def attack(ct: bytes) -> bytes:# ct includes the IV as the first block.blocks = [ct[i:i+BLOCK] for i in range(0, len(ct), BLOCK)]out = b''for i in range(1, len(blocks)):out += decrypt_block(blocks[i-1], blocks[i])print(f'[+] block {i}: {out[-BLOCK:]!r}')pad = out[-1] # strip PKCS#7 paddingreturn out[:-pad]if __name__ == '__main__':CIPHERTEXT = bytes.fromhex('....') # IV + ciphertext blocksprint(attack(CIPHERTEXT))
CIPHERTEXT so block 1 gets decrypted like any other. Second, the inner loop is independent per byte value, so wrap oracle() in a thread pool or async client to fire all 256 guesses at once. On a network oracle that is the difference between minutes and seconds.How does CBC bit-flipping forge plaintext?
The padding oracle reads plaintext. Bit-flipping writes it. They both exploit P_i = D(C_i) XOR C_{i-1}, but bit-flipping needs no oracle at all when you control or know the relevant ciphertext. The classic setup: the server hands you an encrypted token (a cookie, a session blob) that decrypts to something like user=guest&admin=0, and you want it to decrypt to admin=1 instead.
Look at the equation. P_i is the XOR of a fixed unknown, D(C_i), with a byte you control, C_{i-1}. XOR is its own inverse, so if you flip a bit in C_{i-1}, the same bit flips in P_i. You do not need to know D(C_i) at all. You only need to know the current plaintext byte and the byte you want:
Goal: change plaintext byte P_i[j] from 'have' to 'want'.new C_{i-1}[j] = old C_{i-1}[j] XOR have XOR wantWorked example: flip the '0' (0x30) in admin=0 to '1' (0x31).delta = 0x30 XOR 0x31 = 0x01C_{i-1}[j] ^= 0x01 # done. P_i[j] is now '1'.
C_{i-1} corrupts the decryption of block i-1 entirely, because that block runs through D and comes out as garbage. So bit-flipping sacrifices one block to rewrite the next. You aim the bytes you care about into block i, accept that block i-1 turns to noise, and rely on the application ignoring or tolerating that garbage block. If the target you must corrupt is block 1, flip the IV instead, since the IV is the "previous block" for block 1 and corrupting it costs you nothing downstream.A compact pwntools-flavored forge looks like this:
from pwn import xorBLOCK = 16ct = bytearray(token) # IV + ciphertext, attacker-held# say plaintext byte at absolute offset 'off' is 'have' and we want 'want'have = ord('0')want = ord('1')prev = off - BLOCK # the byte that controls offset 'off'ct[prev] ^= have ^ wantsend(bytes(ct)) # server now decrypts admin=1
If the message has a MAC or any integrity check, naive bit-flipping is caught: the corrupted block (and the flipped block) will fail the MAC. That is exactly the defense, and it is the next section. But plenty of CTF targets, and a depressing number of real systems historically, used CBC with no integrity protection at all.
CBC gives you confidentiality and nothing else. Without a MAC, the ciphertext is editable clay, and bit-flipping is the chisel.
When should you reach for padbuster instead of your own loop?
Writing the oracle loop yourself is the right call when the target speaks an unusual protocol or hides the padding signal somewhere you have to reverse first. But when the oracle is a plain HTTP endpoint, an off-the-shelf tool is faster and handles the false positives for you. PadBuster is the long-standing one. It decrypts ciphertext, encrypts arbitrary plaintext (the forge direction), and auto-profiles responses to find the padding signal.
# Decrypt a captured token. EncryptedSample is the ciphertext (hex/base64).padbuster http://target/app EncryptedSample 16 --encoding 2# Forge a token that decrypts to chosen plaintext (the bit-flip direction):padbuster http://target/app EncryptedSample 16 --encoding 2 \-plaintext 'user=admin&role=root'# Common flags:# 16 block size in bytes (AES = 16)# --encoding 2 0=base64 1=hex-lower 2=hex-upper 3=.NET UrlToken# -cookies send a cookie with each probe# -error the string that marks INVALID padding
Why does this work, and how do you actually stop it?
Both attacks trace to one design error: CBC provides confidentiality but no integrity, and the padding check leaks information about decrypted bytes. The fixes follow directly.
- Use authenticated encryption (AEAD). AES-GCM and ChaCha20-Poly1305 compute an authentication tag over the ciphertext. Any edit to the ciphertext, including a bit-flip or a padding-probe block, fails the tag check before the plaintext is ever processed. There is no padding to attack and no malleable ciphertext to flip. This is the real answer in 2026: reach for GCM and stop hand-rolling CBC.
- If you must use CBC, encrypt-then-MAC. Compute a MAC (HMAC) over the ciphertext and verify it in constant time before decrypting. A failed MAC means you never run the padding check, so there is no oracle. Order matters: MAC-then-encrypt and encrypt-and-MAC both leave gaps; encrypt-then-MAC is the safe one.
- Do not leak the padding result. Return one indistinguishable error for any decryption failure and keep the timing constant. This is a band-aid, not a fix, because timing and length side channels are hard to fully close. Prefer the first two.
Where to practice and what to read next
picoCTF does not have a textbook padding-oracle web challenge, but its crypto track trains the exact muscle you need: reasoning about an oracle that answers one structured question and turning that answer into plaintext.
- picoCTF 2021 No Padding No Problem is the natural companion: an RSA oracle that decrypts anything except the exact target ciphertext, defeated by the homomorphic blinding trick. Same mindset as a padding oracle, different primitive.
- picoCTF 2024 RSA Oracle hands you a decryption oracle with one forbidden input and asks you to recover the flag anyway. It is the cleanest picoCTF example of "the server will decrypt almost anything for me, so I make it decrypt a manipulated version of what I want."
For the surrounding theory, the AES for CTF post covers ECB and CBC structure and key-recovery patterns, the RSA attacks for CTF post covers the oracle attacks on the public-key side, and the stream ciphers post covers keystream reuse, the malleability problem in its purest form.
Quick reference
The two equations that run everything
CBC decrypt: P_i = D(C_i) XOR C_{i-1} (IV is C_0)Intermediate: I = D(C_i) is fixed; you only control C_{i-1}Padding oracle (recover):force tail to pad length N, brute one byte until valid:I[pos] = guess XOR N ; P_i[pos] = I[pos] XOR C_{i-1}[pos]<= 256 queries/byte, <= 4096 queries/blockBit-flip (forge):new C_{i-1}[j] = old C_{i-1}[j] XOR have XOR wantcost: block i-1 decrypts to garbage (flip the IV to avoid it on block 1)
Decision order at the keyboard
- Confirm it is CBC and find the block size (almost always 16). Split the ciphertext, treat the first block as the IV.
- Is there a padding signal (error string, status, length, timing)? If yes, run the padding-oracle loop to read plaintext.
- Do you only need to change a known plaintext (admin=0 to admin=1)? Skip the oracle and bit-flip
C_{i-1}or the IV directly. - Plain HTTP oracle with a string signal? Let padbuster do it. Weird protocol or timing signal? Use your own loop.
- Defending? Switch to AES-GCM, or encrypt-then-MAC with constant-time verification before decrypt.
Tweet-length takeaway: AES-CBC keeps your data secret but never proves it was not tampered with, so one leaked bit of padding validity is enough to read it and one flipped bit of ciphertext is enough to rewrite it.