This is the third post in the series. If you have not read the first one on Cloudflare's CIRCL or the second on OpenVM's zkVM, 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, Bron Labs's Go library for MPC and threshold signatures, and detected some zero-day vulnerabilities.
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.
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)e80a2ea
Poseidonhash.Hash
implementation breaks the Go contract(#202)8d203d0
IsOnCurve
inverted on k256, Pallas, and Vesta(#196)e070f8f
BLS12-381 G2IsZero()
calls IsOne()
(#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. 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'
:
// 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 (merge commit e80a2ea).
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 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. CallingWrite(a)
thenWrite(b)
must hasha
followed byb
, exactly as if you had writtena || b
in one call.Sum(b)
isnon-mutating and prefix-appending. It returnsappend(b, digest...)
: the bytesb
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 (merge commit 8d203d0).
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 (merge commit e070f8f).
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 (merge commit 4601a36).
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. 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). 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, 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.