Encrypted is not authenticated: padding oracles, ViewState, and why leaked machine keys became a 2025 problem
CBC without a MAC lets an attacker decrypt and forge ciphertext using nothing but the server's error behaviour. The mechanism from first principles, the Telerik and ASP.NET cases, and the one rule that removes the class.
7 min read1 view
There is a belief that survives in a lot of codebases: if data is encrypted, an attacker cannot tamper with it. They cannot read it, so they cannot meaningfully change it.
That is false, and the way it is false is one of the more elegant results in applied cryptography. With CBC mode and no authentication, an attacker who cannot read your ciphertext can still decrypt it entirely, and, separately, can make it decrypt to a value of their choosing. Neither attack requires the key.
The reason is simple to state: encryption provides confidentiality. It does not provide integrity. Those are different properties requiring different mechanisms, and CBC provides only the first.
How CBC works, briefly
CBC splits the plaintext into fixed-size blocks (16 bytes for AES). Each plaintext block is XORed with the previous ciphertext block before encryption. The first uses an initialisation vector.
C[i] = E(P[i] XOR C[i-1])
P[i] = D(C[i]) XOR C[i-1]
Look closely at the decryption line. The plaintext of block i is the block cipher's output XORed with the previous ciphertext block. The previous ciphertext block is data the attacker holds.
That is the whole vulnerability. Flip a bit in C[i-1] and the same bit flips in P[i]. You have corrupted block i-1 beyond recognition, but you have made a precise, controlled, one-bit edit to block i. This is "CBC bit-flipping", and if the plaintext contains something like user=alice&admin=0, controlled bit flips are all you need.
The padding oracle
Bit-flipping needs you to know the plaintext. The padding oracle removes that requirement.
Because the cipher works on whole blocks, the last block is padded. PKCS#7 pads with bytes whose value is the number of padding bytes: one byte of padding is 0x01, two bytes are 0x02 0x02, and so on. On decryption, the implementation checks the padding is well-formed.
If the server behaves differently when padding is invalid than when it is valid. A different error message, a different status code, a different response time, a different log entry. It has told you one bit about the plaintext. That is an oracle, and one bit is enough.
The attack, informally:
- Take the ciphertext block you want to decrypt,
C[i], and prepend a block of bytes you control,X. - Submit
X || C[i]. The server computesP = D(C[i]) XOR X. - Vary the last byte of
Xthrough all 256 values. For (almost always) exactly one, the result ends in0x01and the padding validates. - That tells you
D(C[i])[15] XOR X[15] = 0x01, so you knowD(C[i])[15]. - XOR that with the real previous ciphertext block and you have the actual plaintext byte.
- Repeat targeting
0x02 0x02, then0x03 0x03 0x03, and so on backwards through the block.
256 requests per byte, worst case, decrypting the entire message without the key. And the same machinery run in reverse lets you forge: choose the plaintext you want, and construct a ciphertext that decrypts to it.
The oracle does not have to be an error message. It can be a timing difference, a subtly different generic error page, a 500 versus a 200, or a difference in what gets logged. Anything observable that correlates with padding validity is sufficient.
The real cases
CVE-2017-9248, Telerik UI for ASP.NET AJAX. The dialog handler protected its parameters with weak, guessable cryptography. Exploitation recovers Telerik.Web.UI.DialogParametersEncryptionKey, and from there an attacker forges dialog parameters, reaches a file manager, and uploads a web shell. It is in CISA's Known Exploited Vulnerabilities catalog and has been exploited for years. A 2017 CVE with a long tail because the component is embedded in enterprise applications that nobody remembers include it.
ASP.NET ViewState and the machine key problem. The 2025 version. ViewState is a serialised object graph in a hidden form field, protected by a MAC derived from machineKey. The cryptography is sound. The key management was not.
In February 2025, Microsoft published research on code injection attacks using publicly disclosed ASP.NET machine keys. Developers had for years copied machineKey values out of documentation, tutorials, sample code and public repositories into real web.config files. Microsoft Threat Intelligence observed a threat actor using a publicly available static machine key to forge ViewState and deploy the Godzilla post-exploitation framework, and published hashes for thousands of known-public keys so defenders could check their own estates.
Note what this is: the ViewState MAC worked perfectly. It verified the signature correctly, using the key it was given. The key was in a blog post. A MAC with a public key is a decoration.
And what a forged ViewState buys is worse than tampering, ViewState is deserialised, so a forged ViewState is a .NET deserialization gadget chain, which is remote code execution. This is why the two topics belong in one article: the crypto failure is the delivery mechanism, and deserialization is the payload.
Testing
Identifying a candidate. Base64 or hex blobs in cookies, hidden fields, URL parameters or headers, whose decoded length is a multiple of 16 (or 8 for 3DES). Session tokens, "encrypted" IDs, returnUrl parameters, download tokens, ViewState.
Confirming the oracle. Change the last byte of the blob and compare with changing a middle byte. Three distinguishable responses is the classic signature:
- Valid padding, valid content → normal response
- Valid padding, invalid content → application-level error
- Invalid padding → cryptographic error
If the second and third differ in any observable way, body, status, headers, timing. You have an oracle. PadBuster and similar tools automate the byte-by-byte recovery; the value of understanding the mechanism is knowing when a difference counts, and recognising the pattern in a protocol no tool supports.
Bit-flipping without an oracle. If you can guess the plaintext structure, and user=alice&role=user style structures are guessable. You can flip bits in the previous block to edit a known field, at the cost of destroying the previous block. Sometimes that destroyed block is a field nobody validates.
For ASP.NET specifically: check whether ViewState MAC is enabled at all (enableViewStateMac="false" still appears), and check your machineKey against Microsoft's published list of known-public keys.
Fixing it
Use authenticated encryption. This is the entire fix.
AES-GCM, ChaCha20-Poly1305, or XSalsa20-Poly1305. These produce a ciphertext plus an authentication tag; decryption verifies the tag before doing anything else, and a modified ciphertext fails as a unit. There is no padding to probe because there is no separately-checkable padding step. The class does not exist.
If you cannot use AEAD, encrypt-then-MAC: HMAC over the ciphertext (and the IV), verified in constant time before decryption. Note the order, MAC-then-encrypt and encrypt-and-MAC are both weaker, and getting this wrong is how the original attacks on TLS happened.
In practice, use a library that makes the decision for you: libsodium, Fernet in Python's cryptography, Tink. The correct answer to "which mode and which MAC" is almost always "the one the well-audited library picked".
Also:
- Return one generic error. A single message for every decryption failure, bad padding, bad MAC, bad content, and take care that the code paths are timing-equivalent. This is a mitigation, not a fix: use it in addition to AEAD, never instead.
- Manage keys as secrets. Generate per-deployment, store in a secret manager, rotate, and never let one appear in documentation, a sample, a container image or a repository. Search your own history for
machineKey,SECRET_KEY,JWT_SECRET. - Do not encrypt what you can reference. The most common "encrypted parameter" is a database row identity that has been encrypted so it cannot be tampered with. Store server-side state and hand out an opaque random identifier. No cryptography, no oracle, no key management. It is almost always simpler than the thing it replaces.
Detection
- A high volume of requests with the same parameter varying by one byte. That is the padding oracle's signature and it is unmistakable: hundreds to thousands of nearly-identical requests differing in one position.
- A spike in decryption or padding errors, which most applications do not log at all. Log them, with a counter per client.
- ViewState validation failures, and
__VIEWSTATEsubmitted to endpoints that do not use it. - Web shell indicators after any of the above: new files in web roots,
w3wp.exeor your app pool spawningcmd.exe.
Take this away
Encryption hides data. It does not protect data.
If your ciphertext can be modified by an attacker and your code does not verify a MAC before decrypting, then your attacker can change your plaintext, and quite possibly read it too, one byte at a time, using nothing but your error messages.
Further reading
Was this useful?
Comments
Loading comments…