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
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
- Attacker supplies an ML-DSA signed object or handshake message whose domain-separation context/label is longer than 255 bytes.
- Application builds the data S-expression including
(label #...>255 bytes...#)and calls gcry_pk_verify(). - _gcry_pk_util_data_to_mpi reads the label via sexp_nth_buffer with no length cap (pubkey-util.c:760-777).
- mldsa_verify passes ctx->label / ctx->labellen directly to dilithium_verify (pubkey-dilithium.c:356-357).
- 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
Triage and disclosure were performed by Calif.
- Verdict
- true positive
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
Dates from discovery through public reveal.
- 2026-04-13 Sent to maintainer
- 2026-04-15 Patch released
- 2026-04-15 Reported to tracker
- 2026-05-28 Maintainer acknowledged
- 2026-08-17 Publicly revealed
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
}