The curious case of encrypted URL parameters
As intra-app URLs used in web applications are generated and parsed by the same code base, there’s no external force pushing developers towards using a human-readable form of serialization. Sure, it’s easier to do debugging and development, but that’s why I used the word “external”. Many frameworks use custom encodings, but one of the most extreme things a developer can do in this regard is completely encrypting request parameters. We encountered such a setup during a recent web app security assessment, let’s see how it worked out.
Seeing Base64-encoded parameters is pretty standard nowadays, but many of those are just for making sure things are safely passing through various forms of ASCII-based containers (URL, JSON and XML encoding and similar stuff). However, when the entropy is high and the parameter is called enc
, things get interesting pretty fast.
My first instinct was to just invoke Intruder in Burp and brute force the last 2 Base64 symbols to see if there’s any chance of a padding oracle attack. As it turned out, the application returned the required 3 kinds of responses: one for a perfect payload, one for a broken padding, and one for correct padding but corrupted payload. I tried using PadBuster, but the web app being picky about the requests and the lack of the ability for low-level configuration in PadBuster was a bad combination.
Quick recap for those who haven’t dealt with padding oracles, or it was way too long in the past (like us) – you can skip these three paragraphs, if you wake up with MACs and go to bed with IVs. Take a look at the diagram below to see how CBC decryption works on three consecutive blocks, we’ll refer to this in the next few sentences. If we focus on the last (rightmost) block below, we’ll see that right before the applications gets the plaintext, it gets xor‘d with the ciphertext of the previous (middle) block. Since we can control the ciphertext completely, this means we can flip any number of bits in the plaintext by doing so on the previous ciphertext block – this of course means that the plaintext of the previous block will be garbage, but as you’ll see, we’ll be able to ignore that.
The neat trick is that since block ciphers require the plaintext to be formatted as blocks, padding must be used for messages that are not the multiples of the block length. The most common padding nowadays is PKCS7 which pads the message with octet(s) that has/have the same value as the length of the padding. So if the last block would have only 14 bytes and the block length is 16, two 0x02 bytes will be appended. Decryption routines with built-in padding support will read the last byte, check if all the previous bytes are the same value, and signal an error if the padding is incorrect. Thus if we flip the bits through all 256 possible combinations for the last byte, in the ideal case, we’ll have three distinct cases:
- the original one where an unknown number of padding bytes are used – the padding is perfect, and the payload is intact,
- 254 cases where the padding is incorrect, processing halts, and the application gives an error message, and last but not least
- a case where we managed to set the last byte of the plaintext to the byte 0x01, which is a valid 1-byte padding – although the payload is probably (at least partially) garbage, if we can make a distinction between this and the previous case, we won.
Now by xoring the original last byte of the ciphertext in the previous (middle) block with the replaced value where we got the third case above and the resulting byte (0x01 in the above situation), we can calculate the original last byte. We can also calculate a byte which will set the last byte to 0x02 instead and follow the process on the (n-1)th byte, and so on with the rest of the ciphertext, resulting in the plaintext in at most 256 requests per bytes.
In our case, at first, I wrote a basic Python script to cycle through all possible values of the last byte of the next to last block. There was only one value that resulted in the padding being correct, and continuing this approach led to almost all bytes of plaintext being discovered.
The only problem was the first block and the IV – latter was not the usual block of all zeroes or 0xFF bytes, and the plaintext of the first block was xor‘d with that prior to encryption. So putting an all-zero block before the first one and using the padding oracle revealed IV ⊕ the plaintext of the first block. This became useful, as the payload was plain text with an easy to recognize pattern, which made guessing the first block easier.
However, since the IV was not explicitly in the request, forging requests was not an option, since the first block would be broken. Thus we searched the web further in the hope of finding something about this method. As the application used ASP.NET, we added that to the list of search query terms and found a Stack Overflow question that leads to a blog post by Mads Kristensen from 2007 (I edited the URL because of link rot).
As it turned out, the developers just copy-pasted this code, which was full of interesting design choices regarding cryptography:
- the encryption key was derived using the class PasswordDeriveBytes,
- according to a Cryptography Stack Exchange question, this implements PBKDF1 using 100 rounds of SHA-1,
- the passphrase used as the input was the string
"key"
- the salt used as the input was the decimal representation of the length of the above string, thus
"3"
, - the key and the IV were always the first 32 and the next 16 bytes of the KDF output, respectively.
We wrapped the source code in a simple program with an entry point to just print the key and the IV and confirmed that this was the implementation our target used indeed.
Fortunately for the developers, they didn’t really depend on the perceived “security” this encryption routine provided. Unfortunately, many methods of defense in depth built into modern web stacks depend on being able to inspect parameters. In our example, this included
- ASP.NET Request Validation, which usually catches simple XSS payloads and
- browser hardening measures (such as XSS Auditor in Chrome and Safari)
that detect input parameters in the HTML response and deny evaluation these expressions and of course - any expensive WAFs the customer could’ve put in front of his/her precious application.
However, in this case, both solutions saw the encrypted values only, thus it was pretty trivial to exploit an XSS vulnerability in the application, which would’ve been a pain in the ass to abuse otherwise.
For developers reading this blog post: please use a library that doesn’t expose so many footguns and provides authenticated encryption in a single function or method call like the secretbox API of libsodium/NaCl. If that’s not possible for some reason, at least use integrity protection, preferably in an ETM (Encrypt-then-MAC) scheme, and use a new IV for every encrypted message. And of course keys should be generated randomly for your application (and preferably a different key for each instance such as development, testing and production). And of course develop your application in a way that such an encryption scheme is just a defense-in-depth measure, and the system works in a secure manner even if the key is compromised.