Playing Tag with GCM

Published: 13 Feb 2026

Playing tag with GCM

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.

A quick refresher on GCM

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:

  • Nonce (IV): A unique, usually random, value that must never be reused with the same key. Reusing a nonce with GCM completely undermines its security.
  • Authentication Tag: A short cryptographic checksum (commonly 16 bytes) that ensures the ciphertext and associated data have not been tampered with. During decryption, the tag must be verified before releasing any plaintext.

This combination makes GCM extremely powerful, if implemented correctly.

The problem: lenient tag verification

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.

Example of vulnerable Ruby usage
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

Node.js note

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.

Not just PHP, Ruby, and Node

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.

A real-world example: Reforge's Ruby SDK

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.

No CVE, no advisory

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.

Why this matters

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.

Takeaway

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.

Photo of Louis Nyffenegger
Written by Louis Nyffenegger
Founder and CEO @PentesterLab