ANT-2026-GSPVGEYA · gpg/libgcrypt

stack-buffer-overflow medium

CVE-2026-41990 GHSA-78pv-qq8x-94px

Severity Claude high · Security research firm - · Maintainer medium

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Calif.

ANT-2026-GSPVGEYA: ML-DSA context string stack buffer overflow

In cipher/dilithium.c, dilithium_sign() and dilithium_verify() declare uint8_t pre[257] and loop-copy ctxlen bytes of the caller's context into it with no bound check. The reference implementation's if(ctxlen>255) return -1; guard in dilithium-dep.c is wrapped in #ifndef DILITHIUM_INTERNAL_API_ONLY, but libgcrypt defines that macro at dilithium.c:85, so the check is compiled out. The context arrives from the public gcry_pk_sign/gcry_pk_verify API via the (label ...) S-expression token, which pubkey-util.c stores into ctx.label/ctx.labellen with no length cap and pubkey-dilithium.c passes straight through. An attacker who can influence the label to exceed 255 bytes overwrites the stack past pre[], smashing saved registers/return address and likely achieving code execution during signature verification.

Target

Project: gpg/libgcrypt
Location: cipher/dilithium.c:189
Discovery: static analysis — not yet dynamically reproduced

Technical Details

dilithium.c:189 declares uint8_t pre[257] and lines 199-200 execute for(i=0;i<ctxlen;i++) pre[2+i]=ctx[i]; with ctxlen as an unchecked size_t. The upstream 255-byte guard at dilithium-dep.c:1061/1235 is excluded by #define DILITHIUM_INTERNAL_API_ONLY (dilithium.c:85), and no replacement cap exists anywhere in the active gcry_pk_verify → mldsa_verify → dilithium_verify path, so any label >255 bytes writes past the end of the stack array.

Reproduction

  1. Attacker supplies an ML-DSA signed object or handshake message whose domain-separation context/label is longer than 255 bytes.
  2. Application builds the data S-expression including (label #...>255 bytes...#) and calls gcry_pk_verify().
  3. _gcry_pk_util_data_to_mpi reads the label via sexp_nth_buffer with no length cap (pubkey-util.c:760-777).
  4. mldsa_verify passes ctx->label / ctx->labellen directly to dilithium_verify (pubkey-dilithium.c:356-357).
  5. dilithium_verify loop-copies ctxlen bytes into the 257-byte stack array pre[], overflowing it and overwriting saved registers / return address.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Enforce the FIPS-204 / reference-implementation limit that the ML-DSA context string is at most 255 bytes before copying into the fixed pre[] prefix buffer, regardless of which internal API variant is compiled (e.g., re-add if(ctxlen>255) return -1; in dilithium_sign/dilithium_verify or cap labellen in pubkey-util.c / mldsa_sign/mldsa_verify).

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-GSPVGEYA.


Reference: ANT-2026-GSPVGEYA
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Calif.

Verdict
true positive
UPSTREAM FIX

The change that resolved this finding.

diff --git a/cipher/dilithium.c b/cipher/dilithium.c
index 955feb2ae..212c4afe5 100644
--- a/cipher/dilithium.c
+++ b/cipher/dilithium.c
@@ -82,6 +82,7 @@
 #include "gcrypt-int.h"
 #include "const-time.h"
 
+/* With glue code, we only use the "_internal" API of Dilithium.  */
 #define DILITHIUM_INTERNAL_API_ONLY 1
 
 #include "dilithium.h"
@@ -120,23 +121,33 @@ static int crypto_sign_verify_internal_5 (const uint8_t *sig, size_t siglen,
                                           const uint8_t *pre, size_t prelen,
                                           const uint8_t *pk);
 
-int
+gpg_err_code_t
 dilithium_keypair (int algo, uint8_t *pk, uint8_t *sk,
                    const uint8_t seed[SEEDBYTES])
 {
+  int r;
+
   switch (algo)
     {
     case GCRY_MLDSA44:
-      return crypto_sign_keypair_internal_2 (pk, sk, seed);
+      r = crypto_sign_keypair_internal_2 (pk, sk, seed);
+      break;
     case GCRY_MLDSA65:
     default:
-      return crypto_sign_keypair_internal_3 (pk, sk, seed);
+      r = crypto_sign_keypair_internal_3 (pk, sk, seed);
+      break;
     case GCRY_MLDSA87:
-      return crypto_sign_keypair_internal_5 (pk, sk, seed);
+      r = crypto_sign_keypair_internal_5 (pk, sk, seed);
+      break;
     }
+
+  if (r < 0)
+    return GPG_ERR_INTERNAL;
+
+  return 0;
 }
 
-int
+gpg_err_code_t
 dilithium_sign (int algo, uint8_t *sig, size_t siglen,
                 const uint8_t *m, size_t mlen,
                 const uint8_t *ctx, size_t ctxlen,
@@ -145,9 +156,17 @@ dilithium_sign (int algo, uint8_t *sig, size_t siglen,
   size_t i;
   uint8_t pre[257];
   size_t prelen;
+  int r;
 
-  if (ctx == NULL && ctxlen == -1)
-    prelen = 0;
+  if (ctx == NULL)
+    {
+      if (ctxlen == -1)
+        prelen = 0;
+      else
+        return GPG_ERR_INV_DATA;
+    }
+  else if (ctxlen > 255)
+    return GPG_ERR_INV_DATA;
   else
     {
       /* Prepare pre = (0, ctxlen, ctx) */
@@ -158,28 +177,44 @@ dilithium_sign (int algo, uint8_t *sig, size_t siglen,
       prelen = 2 + ctxlen;
     }
 
+  /*
+   * Note that the second argument of the upstream routine is the
+   * pointer to output length of signature.  It assumes the first
+   * argument (pointer to output signature) should have correct (or
+   * more) length, beforehand.
+   *
+   * Before calling the routine, we should check the length.
+   */
   switch (algo)
     {
     case GCRY_MLDSA44:
       if (siglen != CRYPTO_BYTES_2)
-        return -1;
-      return crypto_sign_signature_internal_2 (sig, &siglen, m, mlen,
-                                               pre, prelen, rnd, sk);
+        return GPG_ERR_INV_DATA;
+      r = crypto_sign_signature_internal_2 (sig, &siglen, m, mlen,
+                                            pre, prelen, rnd, sk);
+      break;
     case GCRY_MLDSA65:
     default:
       if (siglen != CRYPTO_BYTES_3)
-        return -1;
-      return crypto_sign_signature_internal_3 (sig, &siglen, m, mlen,
-                                               pre, prelen, rnd, sk);
+        return GPG_ERR_INV_DATA;
+      r = crypto_sign_signature_internal_3 (sig, &siglen, m, mlen,
+                                            pre, prelen, rnd, sk);
+      break;
     case GCRY_MLDSA87:
       if (siglen != CRYPTO_BYTES_5)
-        return -1;
-      return crypto_sign_signature_internal_5 (sig, &siglen, m, mlen,
-                                               pre, prelen, rnd, sk);
+        return GPG_ERR_INV_DATA;
+      r = crypto_sign_signature_internal_5 (sig, &siglen, m, mlen,
+                                            pre, prelen, rnd, sk);
+      break;
     }
+
+  if (r < 0)
+    return GPG_ERR_INTERNAL;
+
+  return 0;
 }
 
-int
+gpg_err_code_t
 dilithium_verify (int algo, const uint8_t *sig, size_t siglen,
                   const uint8_t *m, size_t mlen,
                   const uint8_t *ctx, size_t ctxlen,
@@ -188,9 +223,17 @@ dilithium_verify (int algo, const uint8_t *sig, size_t siglen,
   size_t i;
   uint8_t pre[257];
   size_t prelen;
+  int r;
 
-  if (ctx == NULL && ctxlen == -1)
-    prelen = 0;
+  if (ctx == NULL)
+    {
+      if (ctxlen == -1)
+        prelen = 0;
+      else
+        return GPG_ERR_INV_DATA;
+    }
+  else if (ctxlen > 255)
+    return GPG_ERR_INV_DATA;
   else
     {
       /* Prepare pre = (0, ctxlen, ctx) */
@@ -204,16 +247,24 @@ dilithium_verify (int algo, const uint8_t *sig, size_t siglen,
   switch (algo)
     {
     case GCRY_MLDSA44:
-      return crypto_sign_verify_internal_2 (sig, siglen, m, mlen,
-                                            pre, prelen, pk);
+      r = crypto_sign_verify_internal_2 (sig, siglen, m, mlen,
+                                         pre, prelen, pk);
+      break;
     case GCRY_MLDSA65:
     default:
-      return crypto_sign_verify_internal_3 (sig, siglen, m, mlen,
-                                            pre, prelen, pk);
+      r = crypto_sign_verify_internal_3 (sig, siglen, m, mlen,
+                                         pre, prelen, pk);
+      break;
     case GCRY_MLDSA87:
-      return crypto_sign_verify_internal_5 (sig, siglen, m, mlen,
-                                            pre, prelen, pk);
+      r = crypto_sign_verify_internal_5 (sig, siglen, m, mlen,
+                                         pre, prelen, pk);
+      break;
     }
+
+  if (r < 0)
+    return GPG_ERR_BAD_SIGNATURE;
+
+  return 0;
 }
 
 typedef struct {
diff --git a/cipher/dilithium.h b/cipher/dilithium.h
index dd3597a53..1e868e1e6 100644
--- a/cipher/dilithium.h
+++ b/cipher/dilithium.h
@@ -64,16 +64,16 @@
 #define DILITHIUM_SIGN_STACK_BURN (161 * 1024)
 #define DILITHIUM_VERIFY_STACK_BURN (122 * 1024)
 
-int dilithium_keypair (int algo, uint8_t *pk, uint8_t *sk,
-                       const uint8_t seed[SEEDBYTES]);
-int dilithium_sign (int algo, uint8_t *sig, size_t siglen,
-                    const uint8_t *m, size_t mlen,
-                    const uint8_t *ctx, size_t ctxlen,
-                    const uint8_t *sk, const uint8_t rnd[RNDBYTES]);
-int dilithium_verify (int algo, const uint8_t *sig, size_t siglen,
-                      const uint8_t *m, size_t mlen,
-                      const uint8_t *ctx, size_t ctxlen,
-                      const uint8_t *pk);
+gpg_err_code_t dilithium_keypair (int algo, uint8_t *pk, uint8_t *sk,
+                                  const uint8_t seed[SEEDBYTES]);
+gpg_err_code_t dilithium_sign (int algo, uint8_t *sig, size_t siglen,
+                               const uint8_t *m, size_t mlen,
+                               const uint8_t *ctx, size_t ctxlen,
+                               const uint8_t *sk, const uint8_t rnd[RNDBYTES]);
+gpg_err_code_t dilithium_verify (int algo, const uint8_t *sig, size_t siglen,
+                                 const uint8_t *m, size_t mlen,
+                                 const uint8_t *ctx, size_t ctxlen,
+                                 const uint8_t *pk);
 #endif
 
 #if defined(DILITHIUM_MODE)
diff --git a/cipher/pubkey-dilithium.c b/cipher/pubkey-dilithium.c
index 03958bb08..8c3f650e5 100644
--- a/cipher/pubkey-dilithium.c
+++ b/cipher/pubkey-dilithium.c
@@ -170,7 +170,7 @@ mldsa_generate (const gcry_sexp_t genparms, gcry_sexp_t *r_skey)
       memcpy (seed, seed_supplied, SEEDBYTES);
     }
 
-  dilithium_keypair (info->algo, pk, sk, seed);
+  rc = dilithium_keypair (info->algo, pk, sk, seed);
   _gcry_burn_stack (DILITHIUM_KEYPAIR_STACK_BURN);
 
   if (!rc)
@@ -206,7 +206,6 @@ mldsa_sign (gcry_sexp_t *r_sig, gcry_sexp_t s_data, gcry_sexp_t keyparms)
   size_t data_len;
   const unsigned char *sk;
   const struct mldsa_info *info = mldsa_get_info (keyparms);
-  int r;
 
   if (!info)
     return GPG_ERR_PUBKEY_ALGO;
@@ -258,17 +257,14 @@ mldsa_sign (gcry_sexp_t *r_sig, gcry_sexp_t s_data, gcry_sexp_t keyparms)
   else
     randombytes (rnd, RNDBYTES);
   if (ctx.flags & PUBKEY_FLAG_NO_PREFIX)
-    r = dilithium_sign (info->algo, sig, info->sig_len, data, data_len,
-                        NULL, -1, sk, rnd);
+    rc = dilithium_sign (info->algo, sig, info->sig_len, data, data_len,
+                         NULL, -1, sk, rnd);
   else
-    r = dilithium_sign (info->algo, sig, info->sig_len, da
… (truncated)

https://github.com/gpg/libgcrypt/commit/905e00f046a71e5670517779afaf85a354952832

TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-13 Sent to maintainer
  2. 2026-04-15 Patch released
  3. 2026-04-15 Reported to tracker
  4. 2026-05-28 Maintainer acknowledged
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

86f014c5715067bbe17ca099b1f5c66cb367ce1167c9d0c77c15ca73594cb5c2a6ae966ae8809431af4cb2bc6eb0b7201c5ebdd8f1e2ae2f29987915712a8300

Committed 2026-05-28 08:09 PT

Revealed 2026-08-17 10:47 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-GSPVGEYA",
  "bug_class": "Stack Buffer Overflow",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-04-16T02:33:49+00:00",
  "description": "In cipher/dilithium.c, dilithium_sign() and dilithium_verify() declare `uint8_t pre[257]` and loop-copy `ctxlen` bytes of the caller's context into it with no bound check. The reference implementation's `if(ctxlen>255) return -1;` guard in dilithium-dep.c is wrapped in `#ifndef DILITHIUM_INTERNAL_API_ONLY`, but libgcrypt defines that macro at dilithium.c:85, so the check is compiled out. The context arrives from the public gcry_pk_sign/gcry_pk_verify API via the `(label ...)` S-expression token, which pubkey-util.c stores into ctx.label/ctx.labellen with no length cap and pubkey-dilithium.c passes straight through. An attacker who can influence the label to exceed 255 bytes overwrites the stack past `pre[]`, smashing saved registers/return address and likely achieving code execution during signature verification.",
  "discovered_at": "2026-04-02T00:00:00+00:00",
  "location": "cipher/dilithium.c:189",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "gpg/libgcrypt",
  "reproduction": [
    "1. Attacker supplies an ML-DSA signed object or handshake message whose domain-separation context/label is longer than 255 bytes.",
    "2. Application builds the data S-expression including `(label #...>255 bytes...#)` and calls gcry_pk_verify().",
    "3. _gcry_pk_util_data_to_mpi reads the label via sexp_nth_buffer with no length cap (pubkey-util.c:760-777).",
    "4. mldsa_verify passes ctx->label / ctx->labellen directly to dilithium_verify (pubkey-dilithium.c:356-357).",
    "5. dilithium_verify loop-copies ctxlen bytes into the 257-byte stack array `pre[]`, overflowing it and overwriting saved registers / return address."
  ],
  "technical_details": "dilithium.c:189 declares `uint8_t pre[257]` and lines 199-200 execute `for(i=0;i<ctxlen;i++) pre[2+i]=ctx[i];` with `ctxlen` as an unchecked size_t. The upstream 255-byte guard at dilithium-dep.c:1061/1235 is excluded by `#define DILITHIUM_INTERNAL_API_ONLY` (dilithium.c:85), and no replacement cap exists anywhere in the active gcry_pk_verify → mldsa_verify → dilithium_verify path, so any label >255 bytes writes past the end of the stack array.",
  "title": "ML-DSA context string stack buffer overflow",
  "vendor_severity": null
}