It all started with a CVE. It feels like it always does 😉. CVE-2025-54887 (CVSS 9.1) disclosed a missing GCM authentication tag validation in the ruby-jwe library. JWT and Ruby, two things I love, so I couldn't not dig into it. The advisory referenced a NorthSec talk on GCM, and that's when one CVE started turning into many more issues.
Galois/Counter Mode (GCM) is a popular authenticated encryption mode built on top of block ciphers like AES. It is widely used because it provides both confidentiality and integrity in one pass, and it’s efficient even in high-throughput environments.
Two elements are critical to understand:
This combination makes GCM extremely powerful, if implemented correctly.
In the NorthSec talk, the speaker highlighted that in both PHP and Ruby, decryption APIs don’t strictly enforce the length of the authentication tag. If you pass a shorter tag, these libraries will happily verify only those bytes and continue. That means it’s up to developers to enforce the tag size themselves.
cipher = OpenSSL::Cipher.new("aes-128-gcm")
cipher.decrypt
cipher.key = key
cipher.iv = iv
# BUG: No length check! a 1-byte tag will be accepted.
cipher.auth_tag = provided_tag
plaintext = cipher.update(ciphertext) + cipher.final
If you want to play at home (in PHP) this time:
<?php
$key = random_bytes(32);
$plaintext = "test message";
$nonce = random_bytes(12);
// Encrypt
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key,
OPENSSL_RAW_DATA, $nonce, $tag);
echo "PHP - Testing tag sizes:\n";
foreach ([16, 15, 14, 12, 8, 4, 2, 1, 0] as $size) {
$truncated_tag = $size == 0 ? "" : substr($tag, 0, $size);
$result = @openssl_decrypt($ciphertext, 'aes-256-gcm', $key,
OPENSSL_RAW_DATA, $nonce, $truncated_tag);
if ($result !== false && $result === $plaintext) {
echo " Tag size $size bytes: ACCEPTED ⚠️\n";
} else {
echo " Tag size $size bytes: REJECTED ✓\n";
}
}
?>
And you get:
PHP - Testing tag sizes:
Tag size 16 bytes: ACCEPTED ⚠️
Tag size 15 bytes: ACCEPTED ⚠️
Tag size 14 bytes: ACCEPTED ⚠️
Tag size 12 bytes: ACCEPTED ⚠️
Tag size 8 bytes: ACCEPTED ⚠️
Tag size 4 bytes: ACCEPTED ⚠️
Tag size 2 bytes: ACCEPTED ⚠️
Tag size 1 bytes: ACCEPTED ⚠️
Tag size 0 bytes: REJECTED ✓
The worst part, an issue has been open in July 2016 to get this behaviour changed in ruby/openssl: https://github.com/ruby/openssl/issues/63
When digging further, I discovered that Node.js accepts tags as short as 4 bytes by default. The original issue was opened in 2017 and closed without a fix. A new issue was opened in 2024, noting that among 41 popular GitHub projects using Node's crypto module, only 7 properly protected against short tags, while 15 would allow forgeries within 232 queries. This eventually led to a runtime deprecation (DEP0182) and a documentation warning for setAuthTag(). But as of now, the default behaviour still accepts truncated tags.
After looking deeper, I found at least three other languages that suffer from the same class of bug. Erlang (and by extension Elixir) had the same issue and updated their documentation to warn about tag length. Another language is still being discussed with its maintainers. The short version: this problem is wider than most people think.
Once I understood the pattern, I started looking for it in other Ruby projects. I came across Reforge's Ruby SDK which uses AES-256-GCM to encrypt data. Their decrypt method looked like this:
def decrypt(encrypted_string)
unpacked_parts = encrypted_string.split(SEPARATOR).map { |p| [p].pack("H*") }
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
cipher.decrypt
cipher.key = @key
cipher.iv = unpacked_parts[1]
cipher.auth_tag = unpacked_parts[2] # No length check!
decrypted = cipher.update(unpacked_parts[0])
decrypted << cipher.final
decrypted
end
The encrypted string format is ciphertext--iv--tag, all hex-encoded. Since there is no validation of the tag length, anyone who can manipulate the encrypted string (e.g. intercepting it in transit, modifying a stored value) could truncate the tag to a single byte. With Ruby's OpenSSL bindings happily accepting a 1-byte tag, an attacker would only need around 256 attempts to forge a valid ciphertext.
I reported the issue to Reforge on October 1, 2025. To their credit, they responded quickly and shipped a fix on October 7, 2025, adding an explicit tag length check:
AUTH_TAG_LENGTH = 16
def decrypt(encrypted_string)
encrypted_data, iv, auth_tag = encrypted_string.split(SEPARATOR).map { |p| [p].pack("H*") }
# Currently the OpenSSL bindings do not raise an error if auth_tag is
# truncated, which would allow an attacker to easily forge it. See
# https://github.com/ruby/openssl/issues/63
if auth_tag.bytesize != AUTH_TAG_LENGTH
raise "truncated auth_tag"
end
cipher = OpenSSL::Cipher.new(CIPHER_TYPE)
cipher.decrypt
cipher.key = @key
cipher.iv = iv
cipher.auth_tag = auth_tag
decrypted = cipher.update(encrypted_data)
decrypted << cipher.final
decrypted
end
The fix is straightforward: check that the tag is exactly 16 bytes before passing it to OpenSSL. This is the kind of one-line check that every GCM implementation should have, but that the underlying libraries don't enforce.
Despite being a real vulnerability in a public SDK, no CVE was issued and no security advisory was published. The fix was quietly merged as version 1.11.2. This is unfortunately common: many libraries treat these as "improvements" rather than security fixes, which means downstream users have no signal to update urgently. If you're relying on GCM for authenticity, this is exactly the kind of silent fix you'd miss.
Truncating the tag drastically reduces the difficulty of forgery. If only one byte is verified, an attacker needs just 256 tries to find a valid tag. If only 4 bytes are checked, it's 232 possibilities, still within reach of motivated attackers.
This is pretty wild. If you're a developer, don't blindly trust the built-in GCM functions to enforce the correct tag size. Always add an explicit length check before passing the tag to decryption. Treat the tag as a fixed-size value (typically 16 bytes) and reject anything else.
If you want to practice exploiting GCM tag truncation yourself, try our GCM Tag Truncation exercise.