If you’ve ever used EncryptSymmetric()/DecryptSymmetric() in AMPScript, you’ve probably thought when you saw the “AES-256” label that: it’s good, that’s solid, I don’t need to worry about the strength of the encryption. And you’d be right about the algorithm. AES-256 has no known practical break with no shortcut key recovery, no feasible brute force attack, nothing. That part of the story is exactly as secure as the name implies.
The problem is that “the algorithm is secure” and “the way I called the function is secure” are two completely different claims, and it’s entirely possible to have the first without the second. I spent time testing a real, working DecryptSymmetric() implementation and found that I could recover an unknown plaintext value and forge a brand-new valid encrypted message from scratch without ever knowing the encryption key. All this done via a textbook attack against how the ciphertext was being checked, not how it was encrypted.
The setup
I built two minimal Cloud Pages to test this against a real implementation. One encrypts whatever you send it representing “the code path that issues a token somewhere”:
%%[
VAR @plainText, @cipherText
SET @plainText = RequestParameter("pt")
SET @cipherText = EncryptSymmetric(@plainText, "aes;mode=cbc;padding=pkcs7", "MyKeyAlias", @null, "MySaltAlias", @null, "MyIVAlias", @null)
]%%
<p>Encrypted Output: %%=v(@cipherText)=%%</p>
The other decrypts whatever you send it representing “the code path that reads a token back,” e.g. a preference-center link, a one-click action, anything that decrypts a value out of a URL:
%%[
VAR @cipherText, @plainText
SET @cipherText = RequestParameter("ct")
SET @plainText = DecryptSymmetric(@cipherText, "aes;mode=cbc;padding=pkcs7", "MyKeyAlias", @null, "MySaltAlias", @null, "MyIVAlias", @null)
]%%
<p>Decrypted Output: %%=v(@plainText)=%%</p>
Nothing looks inherently wrong in the AMPScript. AES-256, CBC mode, keys pulled from Key Management instead of hardcoded. This is what doing it correctly should look like. However, AMPScript has no try/catch, so I didn’t even have to write bad error handling to create the vulnerability. The absence of any error handling was enough: a malformed ciphertext makes DecryptSymmetric throw an error, the page has nothing to catch it, and SFMC’s own generic failure page renders instead. A distinct HTTP 422 (“The page content contains errors and cannot be processed”), every single time, versus a clean HTTP 200 with the decrypted value on success. That two-way split becomes the oracle and I didn’t need to build anything extra to create it.
The flaw
CBC mode uses padding (PKCS7) to round plaintext up to a multiple of the block size, and that padding gets validated as part of decryption. If a system’s response differs based on whether that padding was valid by providing a different status code, a different page, or even just a different response time then that difference is observable from the outside, and once it’s observable, it’s not just a bug. It’s becomes an oracle or a giver of wisdom or clues in this case.
This is a fifteen-year-old, extremely well-documented attack class invented by Serge Vaudenay in 2002, and it’s famous enough to have its own Wikipedia page. It shows up over and over in web security precisely because “the response differs when padding is invalid” is such an easy thing to end up with by accident, and such an easy thing to never notice, because the page still looks like it’s working correctly for every legitimate request.
How the attack actually works
CBC decryption of a ciphertext block follows this formula:
P_i = D_key(C_i) XOR C_(i-1)
D_key(C_i) is the raw block-cipher decryption of ciphertext block C_i — a value that depends on the secret key, and which we never learn directly. C_(i-1) is the preceding ciphertext block (or the IV, for the very first block). Here’s the part that matters: if you’re constructing a multi-block ciphertext yourself, you get to choose what C_(i-1) is. It’s just bytes you’re sending in the request. And because XOR is invertible, controlling C_(i-1) means controlling what P_i decrypts to if you know D_key(C_i).
You don’t know it. But the oracle will tell you, one byte at a time.
Finding the last byte
PKCS7 padding of length 1 means the decrypted block’s last byte must equal 0x01. Take any ciphertext block C_i you want to attack, prepend a block of your own choosing, and submit [your_block || C_i]. Try all 256 possible values for the last byte of your_block, leaving the other 15 bytes fixed. For exactly one of those 256 guesses (occasionally two, handled below), the oracle returns success. This means the decrypted last byte came out to 0x01. Call that winning guess g. Since P_15 = D_key(C_i)_15 XOR g, and we know P_15 must be 0x01 for this to have validated, we now know D_key(C_i)_15 = g XOR 0x01 for a fact, but learned without the key.
Finding every other byte
Now fix that last byte of your crafted block to D_key(C_i)_15 XOR 0x02 (so the last decrypted byte becomes 0x02), and brute-force the second-to-last byte the same way, searching for the guess that makes both trailing bytes equal 0x02 — valid padding of length 2. That reveals D_key(C_i)_14. Repeat, working backward one byte at a time, and after 16 rounds you have the complete D_key(C_i) for that block — up to roughly 256 queries per byte, though in practice far fewer, since you stop as soon as you hit a match.
Getting the real plaintext
Once you have D_key(C_i), XOR it against the actual original preceding block (not your crafted one) and you get the real P_i. This works for any block from the second one onward, and nothing stops you from repeating the process moving forward through the message, block by block.
Worth being precise about the one block this doesn’t cover for free: block 1’s plaintext is D_key(C_1) XOR IV, not XOR the previous ciphertext block as there isn’t one. The oracle still hands you D_key(C_1) the same way, but turning that into the real P_1 needs the actual IV value. In a well-built system that’s usually a non-issue, since IVs aren’t secret and are normally stored or sent alongside the ciphertext anyway. It only becomes a real gap when the IV is hidden from you entirely, which is exactly the setup I had.
Running it backward, the forgery
This is the part that surprised us most. Once you know D_key(C_i) for any block, including one you invented yourself, that was never legitimately encrypted by anything then you can pick literally any plaintext P* you want and compute C_prev = D_key(C_i) XOR P*. Submit [C_prev || C_i] and it decrypts to exactly P*. You’ve just manufactured a valid ciphertext for a message that never existed, using nothing but the oracle.
One wrinkle worth naming: on the very first byte you solve (padding length 1), it’s possible to hit a false match on a guess that happens to make the block look like it has longer valid padding (e.g., the last two bytes both landing on 0x02 by coincidence) rather than the length-1 padding you were actually searching for. The standard fix is to double-check every length-1 “hit” by perturbing the second-to-last byte and confirming the result still validates; if it stops validating, it was a false positive and you keep searching.
What the CloudPages Gives Away
Before running the full attack, I ran a smaller sanity check: take a single ciphertext block, flip only its last byte through all 256 values, and then tally the responses. If padding validity were not observable, meaning the system behaved identically regardless of padding then we’d expect no meaningful pattern. If it is observable, basic probability says roughly 1 out of 256 random blocks should coincidentally have valid padding by chance (dominated by the case where the decrypted last byte happens to land on 0x01). After my sample runs here were the results:
250 responses: HTTP 422 (invalid padding)
3 responses: HTTP 200 (valid padding)
3 responses: HTTP 429 (rate-limited — the platform's own throttling kicking in)
3 hits against ~1 expected isn’t a smoking gun by itself at that sample size, but it was enough to justify running the real attack. Recovering a real, randomly-generated 20-character secret end to end took 1,976 queries and about nine minutes, limited less by the math and more by the platform’s rate-limiting forcing periodic pauses. Building a forged ciphertext for an arbitrary chosen string took a comparable 1,779 queries. Neither number is small, but neither is remotely out of reach for anything scriptable. No super computing is required here.
That forgery result is the one worth sitting with. A padding oracle doesn’t just mean “an attacker can read things they shouldn’t.” It means an attacker can author new, valid-looking encrypted values on demand a forged subscriber token, a forged permission flag, a forged discount code all without ever seeing a real example of what they’re forging.
Why “just use AES-256” didn’t save this
Because the vulnerability was never in the cipher. DecryptSymmetric() did exactly what it was asked to do, correctly, every time. The gap was entirely in the execution around it: successes and failures were distinguishable from the outside. Fix that gap by returning an identical response regardless of why decryption failed, and don’t leak timing differences either then this entire attack disappears, with the exact same AES-256 underneath.
The actual fix
A few concrete things, in order of impact:
- Normalize decrypt failures. Wrap the decrypt call so that any exception including bad padding, bad format, wrong key, anything for that matter produces the exact same response as every other failure. No distinguishing error text, no distinguishing status code. The best way to do this in MCE is to wrap your AMPScript in SSJS so you can apply
try/catchblocks to your code. - Add integrity, don’t just rely on encryption. This whole attack class exists because CBC alone has no way to detect tampering. It will happily “successfully” decrypt ciphertext that’s been manipulated, but it just might produce garbage. An HMAC computed over the ciphertext and verified before trusting anything decrypted from it closes this off structurally, regardless of how carefully error handling is written elsewhere.
- Try to avoid exposing the decrypt process publicly. Oracle attacks require a lot of queries. Anything that adds authentication, rate-limiting, or just isn’t reachable by an anonymous request meaningfully raises the cost of this class of attack, even if it doesn’t eliminate it outright.