# AI meets Cryptography 3: What AI Found in Bron Labs's bron-crypto

> Source: <https://blog.zksecurity.xyz/posts/bron-bugs/>
> Published: 2026-07-22 00:00:00+00:00

This is the third post in the series.
If you have not read the [first one on Cloudflare's CIRCL](../circl-bugs) or the [second on OpenVM's zkVM](../openvm-bugs), we recommend reading them first.
They have more context on why we run these experiments and how the pipeline is set up.
Above all, because they are fun.
This time, we pointed our AI audit pipeline at [bron-crypto](https://github.com/bronlabs/bron-crypto), Bron Labs's Go library for MPC and threshold signatures, and detected some zero-day vulnerabilities.

[bron-crypto](https://github.com/bronlabs/bron-crypto) is Bron Labs's cryptography library.
While it mostly focuses on MPC protocols, it also supports many other primitives that are reusable by other applications.
That makes it a good target for our experiment, since, as we said earlier,
auditing multiple isolated modules can be coordinated effectively by the standard harnesses in popular coding agents, such as Codex and Claude Code.

Clarification, same as in the previous posts: Our automated process produced a lot of "candidate" findings. Experts on our team still validated each issue, confirmed exploitability, minimized the proof of concept, and handled responsible disclosure.

We ran bron-crypto through a dual-agent pipeline, with Claude Opus 4.6 as the primary auditor and Codex 5.3 as an independent validator (and vice versa), and ended up with 30 findings. The four below are the ones worth walking through in detail. The rest were fixed before we could allocate time to validate them. We come back to the aggregate at the end, because it says something about how consistent LLM bug-finding has become.

Besides that, we earned some bounties and were acknowledged for those bugs under [Bron's bug bounty program](https://bugbounty.bron.org/hall-of-fame).

## Severities and fixes at a glance

As in the first post, the severity an AI assigns its own finding is still very noisy. The AI tends to rate library code higher than the maintainer does, because impact depends on downstream callers the model cannot see. Therefore we think it just assumes the worst scenario. Here is each bug as the AI rated it and as Bron Labs confirmed it once fixed.

| # | Bug | AI severity | Bron severity | Fix | Found by |
|---|---|---|---|---|---|
| 1 |
|

[(#197)](https://github.com/bronlabs/bron-crypto/pull/197)`e80a2ea`

[Poseidon](#bug-2-poseidon-hashhash-implementation-breaks-the-go-contract)`hash.Hash`

implementation breaks the Go contract[(#202)](https://github.com/bronlabs/bron-crypto/pull/202)`8d203d0`

`IsOnCurve`

inverted on k256, Pallas, and Vesta[(#196)](https://github.com/bronlabs/bron-crypto/pull/196)`e070f8f`

[BLS12-381 G2](#bug-4-iszero-that-checks-for-one)`IsZero()`

calls `IsOne()`

[(#222)](https://github.com/bronlabs/bron-crypto/pull/222)`4601a36`

Now let's go through each one in detail.

## Bug 1: a swapped operand that corrupts threshold-ECDSA key shares

### Background

This one is in the distributed key generation (DKG) for [Lindell17 threshold ECDSA](https://eprint.iacr.org/2017/552.pdf).
The whole point of threshold signing is that no single party ever holds the private key.
The key is split into shares, and signing happens jointly without any party reconstructing the secret.
DKG is the protocol that produces those shares in the first place, without a trusted dealer.

To understand the bug, the only piece you need to keep in mind is how the shares are stored and processed. Each party chooses a private share $x \in \mathbb{Z}_q$, then publishes the public share as an elliptic curve point $Q = x\cdot G$. Additionally, each party also publishes the encrypted share $c = \text{Enc}(x)$, where $\text{Enc}(\cdot)$ is Paillier encryption, so we will consider $(c, Q)$ as the public share for simplicity.

The reason to use Paillier encryption is its additively homomorphic property, i.e., $\text{Enc}(a)\cdot\text{Enc}(b)=\text{Enc}(a + b)$. Specifically, the protocol decomposes each private share $x \in \mathbb{Z}_q$ into two pieces $x'$ and $x''$ in $\{\dfrac{q}{3},...,\dfrac{2q}{3}\}$, such that $x = 3x' + x''$. The reason for this decomposition is that, given the public share $(c, Q)$ of a party, we need to verify a "zero-knowledge proof of a Paillier encryption of a discrete log" (which we call the $L_{PDL}$ proof), which proves that there exists an $x \in \mathbb{Z}_q$ satisfying $c = \text{Enc}(x)$ and $Q = xG$. But the paper relaxes the soundness of this proof to hold only for $x \in \{\dfrac{q}{3},...,\dfrac{2q}{3}\}$ for efficiency. So the workaround is that, instead of directly giving $Q = xG$ with $x$ in the full range, each party splits $x$ into $x'$ and $x''$ in shorter range, and provides the $L_{PDL}$ proofs for $(c' = \text{Enc}(x'), Q' = x'G)$ and $(c'' = \text{Enc}(x''), Q'' = x''G)$. Then the other parties verify the proofs, and reconstruct $c = \text{Enc}(x')\cdot\text{Enc}(x')\cdot\text{Enc}(x')\cdot\text{Enc}(x'')$ and $Q = 3Q' + Q''$.

The final public key of the DKG protocol is the sum of those $Q$ values from all parties.

### The bug

The implementation built the stored ciphertext like this:

```
// pkg/mpc/tsig/tecdsa/lindell17/keygen/dkg/round.go
p.state.theirPaillierEncryptedShares[id] =
    theirCKeyPrime.HomAdd(theirCKeyDoublePrime).HomAdd(theirCKeyDoublePrime).HomAdd(theirCKeyDoublePrime)
```

Read it through the homomorphism. It starts from $x'$, then adds $x''$ three times. The result is $\text{Enc}(x' + 3x'')$, not $\text{Enc}(3x' + x'')$. The two operands were swapped. The stored ciphertext encrypts a value that is not the participant's share.

This corrupts the encrypted key share stored during DKG. Subsequent threshold ECDSA signing operations using this encrypted share will produce incorrect partial signatures, leading to signing failures.

### The fix

As you might guess, the fix is trivial.
Swap the operands back so that the tripled term is `x'`

:

``` php
// Fixed: start from x'', add x' three times -> Enc(3x' + x'')
p.state.theirPaillierEncryptedShares[id] =
    theirCKeyDoublePrime.HomAdd(theirCKeyPrime).HomAdd(theirCKeyPrime).HomAdd(theirCKeyPrime)
```

It landed in PR [#197](https://github.com/bronlabs/bron-crypto/pull/197) (merge commit [ e80a2ea](https://github.com/bronlabs/bron-crypto/commit/e80a2eae9786fe9a21297212fc91dc8db33527a5)).

### Impact

Threshold ECDSA sessions that use DKG-generated shards, as opposed to trusted-dealer shards, can produce incorrect signatures or fail to sign at all. For a custody system this is an availability problem, not a key-compromise one. The secret is not leaked, but the parties may be unable to produce a valid signature. For a wallet, that means funds that cannot be moved.

## Bug 2: Poseidon `hash.Hash`

implementation breaks the Go contract

### Background

[Poseidon](https://eprint.iacr.org/2019/458) is a hash function designed to be cheap inside arithmetic circuits and ZK proofs.
Like most modern hashes, it is a sponge: you *absorb* input into an internal state, then *squeeze* a digest out of it.
bron-crypto exposes Poseidon in two ways: through its own `Update`

/`Digest`

sponge API, and through Go's standard `hash.Hash`

interface as a drop-in hash.

That standard interface comes with a contract that the whole Go ecosystem relies on. Two parts of it matter here:

`Write`

*accumulates*. Calling`Write(a)`

then`Write(b)`

must hash`a`

followed by`b`

, exactly as if you had written`a || b`

in one call.`Sum(b)`

is*non-mutating and prefix-appending*. It returns`append(b, digest...)`

: the bytes`b`

are a prefix glued in front of the digest, not more input to the hash, and calling it must not disturb the hasher's state.

### The bug

The `hash.Hash`

adapter broke both halves of that contract.

`Write`

was implemented on top of the one-shot `Hash()`

entry point, which resets the sponge before absorbing.
So every call to `Write`

will clear whatever had been written before it:

```
// pkg/hashing/poseidon/poseidon.go
func (p *Poseidon) Write(data []byte) (n int, err error) {
    // ... converts bytes to field elements ...
    p.Hash(elems...) // Hash() resets the sponge, so earlier writes are discarded
    return len(data), nil
}
...
func (p *Poseidon) Sum(data []byte) []byte {
    _, err := p.Write(data)
    if err != nil {
        panic(err)
    }
    return p.Digest().Bytes()
}
```

`Write(a); Write(b)`

therefore hashes only `b`

.
And `Sum`

called straight into `Write`

.
That reset the state, violating the non-mutating rule, and treated the argument as hash input rather than an output prefix.
`Write`

also only accepts input whose length is a multiple of 32 bytes, so `Sum`

would panic on a prefix that was not 32-byte aligned.

### The fix

Make `Write`

absorb without resetting, and make `Sum`

append without mutating:

```
func (p *Poseidon) Write(data []byte) (n int, err error) {
    // ... converts bytes to field elements ...
    p.Update(elems...) // Update absorbs into the current state, no reset
    return len(data), nil
}

func (p *Poseidon) Sum(b []byte) []byte {
    return append(b, p.Digest().Bytes()...) // non-mutating, prefix-appending
}
```

Fixed in PR [#202](https://github.com/bronlabs/bron-crypto/pull/202) (merge commit [ 8d203d0](https://github.com/bronlabs/bron-crypto/commit/8d203d0000b241b54a933f73419e773aa8b07c89)).

### Impact

Any caller that reached Poseidon through the standard `hash.Hash`

interface got wrong results.
Streaming a message across several `Write`

calls hashed only the final chunk, and `Sum`

panicked on a prefix that was not 32-byte aligned.
Fortunately, bron-crypto's own protocols use the direct `Update`

/`Digest`

sponge API rather than this adapter, so the internal MPC code was not affected.

## Bug 3: an on-curve check that says yes when it means no, and vice versa

### Background

Before a library touches an elliptic-curve point that came from outside, it should check that the point actually lies on the curve.
Skip that check and you open the door to invalid-curve attacks: an attacker hands you a point on a weaker related curve, and your scalar multiplications with the secret leak information about it.
The check that catches this is called `IsOnCurve`

in Go's standard library.

### The bug

`bron-crypto`

wraps several of its curves in Go's `elliptic.Curve`

interface.
Funnily enough, the `IsOnCurve`

adapter for k256, Pallas, and Vesta returned the negation of what it should:

```
// pkg/base/curves/k256/elliptic.go (and pasta/elliptic.go for Pallas, Vesta)
return err != nil // BUG: true means "error", i.e. NOT on curve. Inverted.
```

The underlying routine reports an error when the point is *not* on the curve, so a valid point gives `err == nil`

.
Returning `err != nil`

flips the whole thing on its head.
Valid points, including the curve generator, get rejected, and arbitrary off-curve points get accepted.

Because `elliptic.Unmarshal`

leans on `IsOnCurve`

internally, the inversion propagates: deserializing a valid encoded point fails, and deserializing a bad one succeeds.

### The fix

One character per curve, `!=`

to `==`

, in three places:

```
return err == nil
```

Fixed in PR [#196](https://github.com/bronlabs/bron-crypto/pull/196) (merge commit [ e070f8f](https://github.com/bronlabs/bron-crypto/commit/e070f8f5e98322a85e8dc83c08e381a2f597e091)).

### Impact

Valid points get rejected, so nothing that relies on them works. Invalid points get accepted, which can lead to private key extraction due to invalid-curve attacks. Fortunately, this check lives in a Go adapter for k256, Pallas, and Vesta that bron-crypto's own protocols do not use. So the ones actually at risk were outside callers who unmarshal points through Go's standard path.

## Bug 4: `IsZero`

that checks for one

The last one is the simplest.
In the shared finite-field trait, `IsZero`

was wired to the wrong predicate:

```
// pkg/base/curves/impl/traits/finite_field.go
// IsZero reports whether the element is zero.
func (fe *FiniteFieldElementTrait[FP, F, WP, W]) IsZero() bool {
    return FP(&fe.V).IsOne() != 0 // BUG: calls IsOne, copied from the method above
}
```

It is a copy-paste from the `IsOne`

method directly above it.
The result is that `IsZero`

returns true for the element `1`

and false for the actual `0`

.
That trait is embedded by the BLS12-381 G2 field element, so everything built on top inherits the inversion.
Identity-point detection (`IsOpIdentity`

) reports `1`

as the identity and the real identity as non-identity, and the Euclidean valuation used in field arithmetic comes out wrong.

In practice this sat below the reachable surface, which is why both we and Bron Labs scored it Low. The fix is to just call the right method:

```
func (fe *FiniteFieldElementTrait[FP, F, WP, W]) IsZero() bool {
    return FP(&fe.V).IsZero() != 0
}
```

It went in as part of the broader "Robustness fixes" PR [#222](https://github.com/bronlabs/bron-crypto/pull/222) (merge commit [ 4601a36](https://github.com/bronlabs/bron-crypto/commit/4601a36658ca0e8d74e529b10ab6dbacdb044dc1)).

## A few things we learned

We don't want to exaggerate the complexity of these bugs.
They are essentially "typo" bugs that popular harnesses can detect nowadays, even with older models.
To find deeper, more "cryptographic-looking" bugs, you may need a more thorough harness, such as [zkao](https://zkao.io/).
We list these bugs mainly to prove a more interesting pattern about LLM outputs, which people still think of as very unpredictable:

**The set of bugs an LLM can find is more stable than it looks.**
The most interesting thing about our experiment is not any individual bug, but the repetition.
Under a fixed model and a similar prompt, repeated runs converge on roughly the same set of findings.
We see it from our side: the same issues resurface across runs.
We suspect the maintainers saw it from the other side too.
Several of our independent findings were resolved together inside a PR, like "Robustness fixes" ([#222](https://github.com/bronlabs/bron-crypto/pull/222)).
That is exactly the pattern you would expect if the team had run its own model pass, gotten an overlapping set, and swept the results into one PR.
It also matches what everyone running a bug bounty already feels: duplicate reports keep getting more and more common,
as the same model-reachable bugs surface again and again from independent participants.

The practical takeaway is that after a few runs, re-rolling the same setup does not get you more bugs.
What actually moves the set of bugs you can reach is the harness around the model: how expert knowledge is embedded, how the code is decomposed, how context is fed in, and how subagents are coordinated.
That is the whole bet behind [zkao](https://zkao.io/), and it is why we are not satisfied with "run the LLM a few more times."
We are also running a more careful study on the obvious follow-up question: how many runs do you need before you can be confident an LLM has found everything it is going to find on a given target?
We will write that up separately.

Another lesson from these experiments, and from building zkao, is that model performance varies significantly depending on the harness and prompting strategy. When using LLMs to discover vulnerabilities, this becomes especially important. Maintaining an effective setup requires continuous iteration, as the strongest models evolve and respond differently to different forms of guidance. We plan to share more of our experiments aimed at understanding why these differences emerge, as well as the trade-offs involved in designing and maintaining an effective harness.

We also have another "stronger" opinion: whenever you find a bug using an LLM through a highly optimized custom harness, the "secret sauce" is unlikely to remain exclusive for long. Depending on each provider's policies and user settings, the harness may eventually be part of future training. Even when it does not, similar techniques and findings are likely to be shared publicly and become part of the broader body of online knowledge. As a result, a vulnerability that initially required a sophisticated harness may eventually be rediscovered with a much simpler prompt:

```
“Find all the bugs. MAKE NO MISTAKES!”
```

This is why, even when we find significant vulnerabilities with zkao, we don't claim to have solved security or to outperform every security researcher in the field. Our goal is to build a continuously improving autonomous security platform that evolves as new models emerge, our understanding of LLM-based analysis improves, and we incorporate the feedback of our clients and the knowledge of our security researchers. It is a continuous effort trying to stay ahead of hackers.

## What's next

Our thanks to the Bron Labs team, who triaged and fixed all four issues promptly. This is the third post in the series, and we will keep publishing confirmed bugs from other projects as they are resolved.

If you maintain a cryptography or MPC project and this sounds interesting, we would love to look at it with you, whether through zkao or a manual audit.
Reach out at [zksecurity.xyz/contact](https://zksecurity.xyz/contact).
