# AI meets Cryptography 1: What AI Found in Cloudflare's CIRCL

- **Authors**: Stefanos Chaliasos, Hao Pham, False Witness team
- **Date**: July 07, 2026
- **Tags**: security, crypto, AI, zkao, audit

![Thumbnail](https://blog.zksecurity.xyz/posts/circl-bugs/thumbnail.png)

We pointed our AI audit pipeline at Cloudflare's CIRCL experimental cryptography library and confirmed seven real bugs, from a critical float64 precision loss in threshold RSA to a complete access-control break in attribute-based encryption. All seven are now fixed upstream. This is the first post in a series on bugs our agents found across open source cryptography.

At zkSecurity we are building [zkao](https://zkao.io/), an AI audit agent. The goal is simple to state and hard to do: keep an AI looking at your code, continuously, until no bugs remain that other AI tools can find. We wrote about why that approach matters in [zkao: Security That Compounds](https://blog.zksecurity.xyz/posts/circl-bugs/../zkao-launch).

Building zkao has been an iterative process, with the end goal of creating an automated auditor capable of finding all bugs that can be detected by AI. This has involved brainstorming new ideas and techniques, systematically encoding the expertise of zkSecurity's security researchers into zkao, ensuring that it detects the latest and most severe vulnerabilities without being biased toward benchmarks, and, importantly, continuously conducting experiments to understand what works, what does not, how models evolve, and to deepen our understanding of bug finding with AI. 
Some of those experiments produce things worth sharing on their own, independent of the product, and that is what this post series is about.

There is also a second motivation. These experiments are how we build a benchmark suite for zkao, and along the way they keep surfacing insights into how LLMs actually reason about cryptography: where they are sharp, where they are blind, and how to amplify the former and contain the latter. While the bugs are the visible output, the reasoning patterns are the part we care about most.

A few months ago, we started running experiments on selected codebases. We used LLMs to scan a few open source cryptography projects, in two configurations:

1. LLM only, with a simple prompt.
2. LLM with skills, where the skills were maintained by experts on our team.

Then, for the important projects where the LLMs found real vulnerabilities, we also ran [zkao](https://zkao.io/) to see whether it could have detected the same issues on its own. In most cases, zkao not only found all of them but also identified more complex and more severe ones.

The results were good enough that we decided to write them up. We are starting this series with Cloudflare's [CIRCL](https://github.com/cloudflare/circl), a library of advanced and post-quantum cryptography. On CIRCL, our pipeline produced many candidate findings, and seven of them are worth reporting here. All seven are now fixed upstream. Most of them were confirmed and awarded bounties under Cloudflare's program on [HackerOne](https://hackerone.com/cloudflare).

> **Clarification**: the AI produced candidate findings, not final reports. Humans on our team still validated each issue, checked exploitability, minimized the POC where needed, and handled disclosure. That human-in-the-loop step still matters a lot, because AI candidate findings are cheap while trustworthy reports are not. 
>
> Minimizing that step is one of the main things [zkao](https://zkao.io/) is built to do, and while it is still a work in progress, the current version already takes on much of this validation work itself.

## Severities and fixes at a glance

One thing worth flagging before the details: the severity an AI assigns to its own finding is noisy. Here is each bug as the AI rated it and as Cloudflare confirmed it once fixed. We also checked that all seven are consistently reproducible by the current version of [zkao](https://zkao.io/).

| #   | Bug                                                                                                                       | AI severity | Cloudflare severity | Fix commit                                                                                       | Found by          |
|-----|---------------------------------------------------------------------------------------------------------------------------|-------------|---------------------|--------------------------------------------------------------------------------------------------|-------------------|
| 1   | [float64 precision loss in TSS/RSA polynomial eval](#bug-1-polynomial-evaluation-in-float64)                              | Critical    | Low                 | [`f7d2180`](https://github.com/cloudflare/circl/commit/f7d2180d6a77cfb283379ec6ad357ebf1d444aed) | Opus 4.6 + skills |
| 2   | [qndleq forgery via prover-controlled `SecParam`](#bug-2-a-dleq-proof-forgery-via-a-prover-controlled-security-parameter) | High        | Low                 | [`757dde4`](https://github.com/cloudflare/circl/commit/757dde480dd0ad313fe2a4b2b6dc802266722a6b) | Opus 4.6 + skills |
| 3   | [BLS aggregate missing message distinctness](#bug-3-bls-aggregate-verification-without-message-distinctness)              | Medium      | High                | [`9798df7`](https://github.com/cloudflare/circl/commit/9798df7b83ca7b94e7ccd37aa25c91dfaec1fb31) | Opus 4.6 + skills |
| 4   | [DLEQ soundness break via `FillBytes` sign collision](#bug-4-a-dleq-soundness-break-via-a-fillbytes-sign-collision)       | High        | Low                 | [`19848a5`](https://github.com/cloudflare/circl/commit/19848a5fac78)                             | Opus 4.6 + skills |
| 5   | [HPKE PSK validation bypass via bitwise-OR switch](#bug-5-hpke-psk-validation-bypassed-by-a-bitwise-or-switch)            | Medium      | Medium (Duplicate)  | [`a3b4fa3`](https://github.com/cloudflare/circl/commit/a3b4fa341554)                             | GPT-5.3 + skills  |
| 6   | [TSS/RSA Lagrange coefficients in int64](#bug-6-lagrange-coefficients-in-int64)                                           | High        | Medium              | [`751e372`](https://github.com/cloudflare/circl/commit/751e37241d5a)                             | Opus 4.6 + skills |
| 7   | [CP-ABE access-control break via AND-share bug](#bug-7-a-cp-abe-access-control-break-from-a-one-line-and-share-mistake)   | Critical    | Critical            | [`def2fd3`](https://github.com/cloudflare/circl/commit/def2fd35b8535b0b8fe84f904936ebfd84b5552b) | zkao              |

The gap between the AI severity and the confirmed severity is itself an interesting insight, and we come back to it at the end. Now the seven bugs, one at a time.

## Bug 1: polynomial evaluation in float64

This one lives in CIRCL's threshold RSA implementation (`tss/rsa`). Threshold signing splits a secret across `n` players using Shamir-style secret sharing. `Deal()` evaluates a secret polynomial at each player's index. The coefficients are `big.Int`, as they should be, but the term `x^i` was computed like this:

```go
// tss/rsa/rsa_threshold.go
xi := int64(math.Pow(float64(x), float64(i)))
```

`float64` has a 53-bit mantissa. The moment $x^i$ exceeds $2^{53}$ (roughly $9 \times 10^{15}$), the result is silently rounded before it is ever cast back to an integer. For example, with 100 players and a threshold of 27, evaluating at $x = 100$ with $i = 26$ asks for $100^{26} = 10^{52}$, which overshoots $2^{53}$ by 36 orders of magnitude. Even $x = 20$, $i = 16$ already breaks it.

The consequence is that the polynomial is evaluated incorrectly, so the key shares handed to players are wrong. Depending on the parameters, signature combination either fails outright or produces shares that look fine but do not reconstruct the intended key. Our agent flagged this as **critical**, since it results in the generation of incorrect key shares, compromising the correctness of the protocol. Cloudflare ultimately assessed the issue as Low severity, based on the low likelihood of the affected conditions occurring in practice.

The fix replaces the floating-point exponentiation with the Horner's-method evaluation the code's own TODO comment had been suggesting all along, keeping everything in `big.Int`. Commit [`f7d2180`](https://github.com/cloudflare/circl/commit/f7d2180d6a77cfb283379ec6ad357ebf1d444aed).

## Bug 2: a DLEQ proof forgery via a prover-controlled security parameter

This one is in `zk/qndleq`, CIRCL's DLEQ (discrete-log-equality) proof for the subgroup of squares in $(\mathbb{Z}/n\mathbb{Z})^*$. A DLEQ proof attests that two pairs share the same discrete log; if an attacker can make the verifier accept a proof for a false statement, the proof system is broken.

The challenge in this proof is derived Fiat-Shamir style, and its bit-length is governed by a `SecParam`. The problem was that `SecParam` lived inside the `Proof` struct itself:

```go
type Proof struct {
    Z, C     *big.Int
    SecParam uint
}
```

During verification, the code recomputed the challenge using the proof's *own* `SecParam`. That field is attacker-controlled. Set `SecParam = 1` and the challenge collapses to a single bit, value $0$ or $1$: a coin flip per forgery attempt. Set `SecParam = 8` and brute force is about $2^8 = 256$ attempts. Either way, soundness is gone.

This is a clean instance of a recurring pattern: a security parameter that must be fixed by the verifier instead being read out of prover-supplied data. The fix removes `SecParam` from the proof and makes `Verify` take it as an explicit argument, so the verifier sets it. Commit [`757dde4`](https://github.com/cloudflare/circl/commit/757dde480dd0ad313fe2a4b2b6dc802266722a6b).

## Bug 3: BLS aggregate verification without message distinctness

This is the one bug in the batch the AI **underrated**. The agent labeled it medium. It is in fact a textbook rogue key attack, a widely known critical-class flaw; we reported it as critical, and Cloudflare confirmed it as high.

`VerifyAggregate` in `sign/bls` implements the BLS BASIC aggregation mode. That mode is only secure if all messages in the batch are distinct, which is its defense against rogue key attacks. The function checked the aggregate pairing equation but never checked that the messages were distinct, leaving that critical requirement to the caller.

Without it, the standard rogue key attack applies. An adversary who sees a victim's public key $\mathsf{pk}_v$ and a message $m$ can register $\mathsf{pk}_a = g^{\mathsf{sk}_a} - \mathsf{pk}_v$ and forge an aggregate signature over $(\mathsf{pk}_v, m)$ and $(\mathsf{pk}_a, m)$ without ever knowing the victim's secret key. CIRCL ships no proof-of-possession infrastructure to fall back on, which makes the missing check more dangerous.

> Why did the AI call this medium? We don't know. Reading its reasoning, it correctly spotted the missing distinctness check and even named the rogue key attack, but it then anchored on the fact that the BASIC mode's contract puts the distinctness requirement on the caller. It treated "the caller is supposed to handle this" as a mitigation and marked the severity down.

The fix makes `VerifyAggregate` reject batches built with duplicated messages. Commit [`9798df7`](https://github.com/cloudflare/circl/commit/9798df7b83ca7b94e7ccd37aa25c91dfaec1fb31).

## Bug 4: a DLEQ soundness break via a FillBytes sign collision

Back to `zk/qndleq` for the subtlest, and frankly most interesting, bug in the batch. It does not require touching the proof at all.

Take an honest, valid proof `pi` for the statement $S_1 = (g, g_x, h, h_x)$, which attests that $\log_g(g_x) = \log_h(h_x) = x$. An attacker who does not know $x$ presents the very same `pi` to the verifier, but pairs it with a different statement, $S_2 = (g, -g_x, h, h_x)$, where $-g_x$ is the negative `big.Int` `new(big.Int).Neg(gx)`.

The forged statement is accepted whenever the challenge $c$ is even, because two things line up at once.

**Algebraic cancellation.** The verifier recomputes its value from $-g_x$, and the sign factors straight out:

$$(-g_x)^c \bmod N = (N - g_x)^c \bmod N = (-1)^c \cdot g_x^c \bmod N.$$

When $c$ is even, $(-1)^c = 1$, so the verifier reconstructs exactly the same intermediate values the honest prover did.

**A sign collision in the hash.** The challenge is derived by hashing the statement, and the hashing uses `FillBytes`, which writes the absolute value of a `big.Int` and strips the sign. So `doChallenge(..., -gx, ...)` and `doChallenge(..., gx, ...)` hash to the same thing.

Here, $c$ is even with probability at least $1/2$ (it is just the low bit of the hash output), so the attack succeeds on roughly half of all honestly generated proofs. The verifier walks away convinced that $\log_g(-g_x) = \log_h(h_x)$, which is false. Here is the core of the proof-of-concept:

```go
// honest proof for (g, gx, h, hx), selected to have an even challenge c
gxNeg := new(big.Int).Neg(gx) // -gx, attacker needs no knowledge of x
forgedAccepted := proof.Verify(g, gxNeg, h, hx, N) // accepted!
```

What makes this one stand out is that it is not a single sloppy line. It is the interaction between an algebraic identity ($(-1)^{\text{even}} = 1$) and an innocent-looking serialization choice (`FillBytes` dropping the sign). Neither is wrong on its own. Together they break soundness. Reasoning across that kind of boundary is exactly where we have been most surprised by what the models surface.

On severity, the agent rated this high because it is a soundness break, but Cloudflare confirmed it as low due to its high attack complexity.

The fix adds a `checkBounds` step to challenge computation that requires every input to satisfy `0 &lt; x &lt; N`. A negative `-gx` has a negative sign and is rejected before it can do any damage. Commit [`19848a5`](https://github.com/cloudflare/circl/commit/19848a5fac78).

## Bug 5: HPKE PSK validation bypassed by a bitwise-OR switch

This one is almost a language-level trap. In HPKE's `verifyPSKInputs`, the `switch` labels were written with bitwise-OR:

```go
// hpke/util.go
case modeBase | modeAuth:    // 0x00 | 0x02 == 0x02, i.e. only modeAuth
case modePSK | modeAuthPSK:  // 0x01 | 0x03 == 0x03, i.e. only modeAuthPSK
```

In Go, a `case a | b:` is a single case whose value is the OR of the two constants, not two cases. So `case modePSK | modeAuthPSK` is really `case 0x03`, and `modePSK` (`0x01`) matches no case at all. The branch that is supposed to reject a missing PSK in PSK mode is simply skipped.

The effect: `SetupPSK(..., nil, nil)` proceeds with an empty PSK instead of being rejected. PSK mode is supposed to *require* PSK material; this silently drops the authentication precondition and lets a deployment run in a weaker mode than it was configured for. The fix is the one-character-class change from OR to comma-separated cases (`case modePSK, modeAuthPSK:`). Commit [`a3b4fa3`](https://github.com/cloudflare/circl/commit/a3b4fa341554). It was confirmed as a duplicate.

## Bug 6: Lagrange coefficients in int64

Back in `tss/rsa`, once shares are dealt, combining signatures requires Lagrange interpolation. `computeLambda` built the numerator and denominator of each Lagrange coefficient in `int64`:

```go
// tss/rsa/rsa_threshold.go
num := int64(1)
den := int64(1)
for _, s := range S {
    jprime := int64(s.Index)
    if jprime == j { continue }
    num *= i - jprime    // overflows int64 for moderate player counts
    den *= j - jprime
}
lambda.Div(big.NewInt(num), big.NewInt(den)) // truncating integer division
lambda.Mul(delta, &lambda)
```

There are actually two completely independent bugs here, either of which is enough to corrupt a signature. The first is overflow: with about 21 players the products run past the `int64` ceiling ($\approx 9.2 \times 10^{18}$) and wrap silently, because Go does not panic on integer overflow. `computeLambda` then returns a wrong, often negative, coefficient.

The second is truncation, and it bites even when nothing overflows. The code computes `num / den` first and only then multiplies by `delta`. In Shoup's scheme $\delta \cdot \text{num}$ is guaranteed to be exactly divisible by `den`, but `num` alone is not whenever the share indices are non-consecutive, which is the normal case for a $t$-of-$n$ subset. Take a 3-of-5 scheme combining shares ${1, 3, 5}$: for one coefficient $\text{num} = (0-3)(0-5) = 15$ and $\text{den} = (1-3)(1-5) = 8$. The buggy order computes $\delta \cdot \lfloor 15/8 \rfloor$, which with $\delta = 120$ is $120$; the correct value $\delta \cdot 15 / 8$ is $225$.

The fix moves the whole computation to `big.Int` and reorders the multiplication and division so the exact-divisibility guarantee holds. It is commit [`751e372`](https://github.com/cloudflare/circl/commit/751e37241d5a). The two issues are unrelated, but the agent reported them together as a single finding, and we left the submission that way, out of respect for the agent's work.

## Bug 7: a CP-ABE access-control break from a one-line AND-share mistake

This is the bug [zkao](https://zkao.io/) found on its own. After confirming the six issues above, we pointed it at the same library to see what it would find, and it reported this one. It is a full break of the access-control guarantee in CIRCL's ciphertext-policy attribute-based encryption (`abe/cpabe/tkn20`), and Cloudflare confirmed its validity.

Ciphertext-Policy Attribute-Based Encryption (CP-ABE) lets you encrypt a message under a policy such as `(location: usa AND department: finance) OR (role: admin)`. A user holds a key tied to their own attributes, and the scheme guarantees they can decrypt if and only if those attributes satisfy the policy. The finance employee in the USA and the admin can read the message, and nobody else can, even though everyone receives the same ciphertext.

Internally, `tkn20` turns the policy into a tree of AND and OR gates with attributes at the leaves, and secret-shares one secret (the key that protects the message) down that tree. The sharing has to follow the boolean logic:

- An OR gate gives the full secret to both children, because satisfying either branch should be enough.
- An AND gate splits the secret, so you need both children to rebuild it. One child gets a random value `r`, the other gets `parent - r`, and only `r + (parent - r)` recovers the parent.

To understand this bug, the AND gate is the only piece you need to keep in mind: each child must get a partial share, and neither child alone should reconstruct the parent.

Here is how `share` actually handled the AND case:

```go
// abe/cpabe/tkn20/internal/tkn/formula.go
case Andgate:
    shares[gate.In0], err = randomMatrixZp(rand, k.rows, k.cols) // In0 = random r
    ...
    shares[gate.In1] = newMatrixZp(k.rows, k.cols)               // In1 = 0
    shares[gate.In0].sub(shares[gate.Out], shares[gate.In1])     // In0 = parent - 0
```

The random share is generated and then immediately discarded. `In1` is set to zero, and the last line overwrites `In0` with `parent - In1`, which is just `parent`. So one child receives the whole secret and the other receives nothing. The AND gate is no longer an AND: its first leaf reconstructs the parent on its own.

Note that this does not break correctness. The two shares still sum to the parent (`parent + 0 = parent`), so any key that satisfies the policy still decrypts, and ciphertexts made by the old code stay compatible. What it breaks is secrecy: a single AND leaf now recovers a secret that was supposed to require both. 

What turns it into a full break is where this broken AND gate ends up. To reach CCA security, `tkn20` applies a Boneh-Katz transform that wraps every policy in a new outer AND gate, and the left child of that gate is an internal "wildcard" leaf. Every attribute key the authority issues carries that wildcard, so every key satisfies that one leaf. Now put the two facts together: the wildcard leaf is the first child (`In0`) of an AND gate, and `In0` is exactly the child that receives the full secret. Every key therefore holds a single leaf that reconstructs the message key by itself, regardless of the policy!

Although it looks like a simple typo bug, what impressed us was zkao's ability to reason about a complex concept such as CP-ABE and correctly assess its impact. Many LLMs would still identify the typo but dismiss it as a "defense-in-depth" or "code hygiene" issue without reasoning further. This can lead developers to underestimate or overlook the vulnerability.

The fix is one line. Share the parent correctly, so the random share survives in `In0` and `In1` becomes the complement:

```go
shares[gate.In1].sub(shares[gate.Out], shares[gate.In0]) // In1 = parent - random
```

Now `In0` keeps its random value and `In1` holds `parent - In0`, so neither AND leaf carries the secret alone. Commit [`def2fd3`](https://github.com/cloudflare/circl/commit/def2fd35b8535b0b8fe84f904936ebfd84b5552b).

## A few things we learned

Three observations stuck with us.

**The AI is bad at severity, and bad in an asymmetric way.** Look again at the table at the top. If we rely on Cloudflare confirmed severity as "ground truths", then in most cases the agent overrated the impact of what it found. But on [the BLS bug](#bug-3-bls-aggregate-verification-without-message-distinctness) it went the other way and underrated a widely known critical flaw, labeling a clean rogue key attack as merely medium. We do not have a complete explanation nor solution yet. Our working hypothesis is that it is genuinely hard when the target is a library used across many different applications, as CIRCL is, because the impact depends on downstream callers the model cannot see. We think adding a consistent severity matrix plus an explicit threat-modeling step would help the model reason about impact at the level of the whole system (and its potential integration) rather than the local code. For now, severity remains the part of triage we trust a human with. 

> In [zkao](https://zkao.io/), we temporarily work around this by letting developers clarify their threat model and severity preferences through a user configuration (`zkao.md`), and we keep improving zkao's severity setting iteratively. The fact that zkao can consistently produce PoCs also reduces false positives, so we have confidence in the real issues. Still, we are working on improving its default severity assignment in a more systematic way, as this is critical for developers.
>
> It is also worth noting that Cloudflare assessed severity through the lens of its bounty program, which weighs whether a bug affects its live services. [Bug 2](#bug-2-a-dleq-proof-forgery-via-a-prover-controlled-security-parameter), for example, was rated low even though it completely breaks the proof's soundness. This rating only indicates that the affected code is either unused by Cloudflare services or has limited impact in Cloudflare's environment. It does not mean the impact is just as small in other deployments, which matters especially for a library that many projects may build on.

**Model pairings are not symmetric, and the roles can flip.** Five of the six bugs were found by Claude Opus 4.6 paired with our skills. Under the same skills and the same system prompt, GPT-5.3 mostly validated rather than discovered; [the HPKE bug](#bug-5-hpke-psk-validation-bypassed-by-a-bitwise-or-switch) in the table is the one it surfaced on its own. We did not expect that division of labor to remain stable, and it did not. A few weeks later, we reran the scan with then-newest pairing, Opus 4.7 and GPT-5.4, and the roles had essentially reversed: GPT-5.4 finds more bugs, while Opus 4.7 is only capable of validating them. This is a good reminder not to overfit conclusions to any particular model name. The frontier has moved on again since, and it will keep moving. We will explore this in another post.

> This pattern is exactly why we are building [zkao](https://zkao.io/) to be "model-agnostic", keeping its performance at the best state without your needing to guess which model will be the best next month.

**The AI gathers issues but does not always chain them.** [Bug 6](#bug-6-lagrange-coefficients-in-int64) is the tell. The agent packed two completely independent bugs, an overflow and an integer truncation, into a single finding. Both are real, so this is genuinely useful work. But it presented them side by side rather than reasoning about how they relate, and we have seen the same pattern elsewhere: several true observations gathered together without any explanation or any attempt to chain them into a more impactful exploit.

> This is exactly where, in [zkao](https://zkao.io/), we have built novel processes to enable the kind of bug composition that chains separate issues into a real end-to-end exploit.

## What's next

Our thanks to the CIRCL maintainers, who promptly fixed all reported issues. This is the first post in a series; we will keep publishing confirmed bugs from other projects as they are resolved.

We scanned more than 200 cryptographic projects (choosing from top most-downloaded cryptography crates/packages) and ended up with over a thousand candidate findings. As a result, the biggest bottleneck today is triage. Every reported bug has to be checked by our experts for technical validity before it reaches a project, because we hate AI spam as much as everyone else does. That takes a lot of human effort, because we do not yet fully trust the automated triage flow we are building (it is getting better). So we prioritized some of the most popular projects that are well maintained.

If you maintain a cryptography project and this is interesting to you, we would love to verify alongside you, so that serious bugs get found and fixed in time. We are happy to run a fresh scan if there is not one yet, or if your codebase has changed significantly since the last. Continuous AI coverage of this kind is exactly what [zkao](https://zkao.io/) is built for. Reach out at [zksecurity.xyz/contact](https://zksecurity.xyz/contact).

---

This article was published on the [ZK/SEC Quarterly](https://blog.zksecurity.xyz) blog by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. ZK Security has audited some of the most critical ZK systems in production, discovered vulnerabilities in major protocols including Aleo, Solana, and Halo2, and built open-source tools like [Clean](https://github.com/Verified-zkEVM/clean) for formally verified ZK circuits. For more articles, see the [full list of posts](https://blog.zksecurity.xyz/llms.txt).
