ZK/SEC Research notes from zkSecurity
All posts
security · zk · circom

The First ZK Exploits Happened, and They Weren't What We Expected

Groth16 trusted setup exploit

TL;DR: The first two known exploits against live ZK circuits happened in the past week. Both stem from the same root cause. They were not subtle underconstrained bugs, but rather Groth16 verifiers (generated by snarkjs) with an incorrect setup (just missing the last step). One was exploited by white-hat hackers for ~$1.5M, the other was drained for 5 ETH.

Since the deployment of Zcash, then Filecoin and many more protocols afterwards, we have always had the impression that ZK-related code is hard and that's the reason why we haven't observed any exploits by malicious actors.

Well, it turns out this intuition was at least partially incorrect. First, for some protocols and vulnerabilities we simply don't know if they were exploited. For example, we don't know if the infamous counterfeiting vulnerability on Zcash was ever exploited. Second, there have been many bugs found in ZK protocols in the wild and some were not complex at all. For example, there was a bug back in 2019 in circomlib that could have drained Tornado Cash. The bug was a very simple completely unconstrained signal:

outs[0] = S[nInputs - 1].xL_out;
// instead of
// outs[0] <== S[nInputs - 1].xL_out;

Also, in our experience after performing many audits on ZKP protocols, indeed the codebases are typically complex and may involve intricate math and cryptography, but we often find pretty simple bugs. In my opinion this happens mainly due to three reasons. Developers get anxious about the complicated parts and focus on making their systems as secure as possible there, but they might miss simpler issues. There is almost no basic tooling for helping developers find simple issues without burdening them with lots of false positives (and no timeouts). And third, the ZK mental model is different from normal programming and some of the ZK DSLs are easy to misuse due to their low-level nature.

Don't get me wrong though, sometimes we find extremely complicated bugs in ZK code or subtle bugs that require deep expertise in the domain to catch!

Long story short, many bug bounties have been paid to white-hat hackers for ZK bugs, many protocols are in production with a lot of TVL, but no exploits were ever recorded in ZK protocols to date. This might have made us feel a bit too comfortable compared with the smart contracts space where we have catastrophic exploits every few months. Maybe we've been just lucky? Maybe there is not enough ROI for hackers? We don't actually know. We just want to believe that the people working in the ZKP space are very security-focused, and researchers in this field prefer to wear whitehats. Also, maybe a famous group simply hasn't started reading about ZKPs yet...

That brings us to today and the following tweet from last Sunday. You can also find the write up from beacon302 at this link.

Veil Hack Tweet

Yesterday, duha_real (a zkSecurity team member) recognized this issue and took over the The Foom Heist Challenge Bug Bounty which was live since June 27 of 2025 (with ~500k USD in value). Almost at the same time another whitehat hacker exploited the Foom contracts in Ethereum to prevent any malicious exploits. You can find a very detailed PoC again from beacon302 here, but it's pretty similar to the previous original bug exploit (note that some websites reported the issue as an attack, which was not the case).

Foom Tweet

So, what's wrong here? Both protocols use Circom and snarkjs, probably the most used combo of frameworks out there for SNARKs and especially the Groth16 protocol (at least based on number of deployments), which requires a trusted setup. Spoiler alert: in both cases something went extremely wrong in the setup phase.

What Is a Trusted Setup Ceremony?

(If you want a deeper refresher on Groth16 itself before reading on, our Groth16 explainer post walks through the protocol end-to-end.)

Groth16 proofs rely on a set of public parameters that must be generated before anyone can create or verify proofs. This generation process is called a trusted setup. Specifically, the setup produces cryptographic parameters that include special values like $\alpha$, $\beta$, $\gamma$, and $\delta$. These are elliptic curve points derived from secret random numbers (sometimes called "toxic waste"). The security of the entire proof system depends on these secrets being truly random and then permanently destroyed. If anyone retains them, they can forge proofs.

To mitigate this risk, the setup is split into two phases, commonly run as a multi-party computation (MPC) ceremony:

  • Phase 1 (Powers of Tau): Generates the initial parameters. Multiple participants each contribute randomness in sequence. As long as at least one participant is honest and destroys their secret, the output is secure. This phase can be reused across different circuits.
  • Phase 2 (Circuit-specific): Takes the Phase 1 output and a specific circuit, then generates the final proving and verification keys. Again, multiple participants contribute randomness. This phase randomizes the $\delta$ parameter.

The output of both phases is the verification key containing the finalized $\alpha$, $\beta$, $\gamma$, and $\delta$ points. A critical invariant: $\gamma$ and $\delta$ must be distinct, independent group elements. If they're equal, the proof system's soundness collapses entirely.

Setting Up a Circuit with snarkjs

Let's walk through an example from the Circom docs to see how we deploy a circuit using snarkjs.

First, let's create our circuit:

pragma circom 2.0.0;

template Multiplier2 () {
   signal input a;
   signal input b;
   signal output c;

   c <== a * b;
}

component main = Multiplier2();

This is a simple circuit that proves we know two factors of the number $c$. For example, for $c = 4$, we provide a proof that demonstrates we know two factors such as $a = b = 2$. Quite a simple example, but it serves our purposes.

The next step is to compile the circuit:

circom multiplier2.circom --r1cs --wasm --sym

After that, we start Phase 1 by initializing a new Powers of Tau ceremony:

snarkjs powersoftau new bn128 12 pot12_0000.ptau -v

And we contribute to the ceremony:

snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau \
  --name="First contribution" -v

Now we have the contributions in pot12_0001.ptau and can proceed to Phase 2. As we mentioned, Phase 2 is circuit-specific. So we first prepare it:

snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v

Next, we generate a .zkey file that will contain the proving and verification keys together with all Phase 2 contributions. We start a new zkey specific to our circuit:

snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey

We contribute to the second phase:

snarkjs zkey contribute multiplier2_0000.zkey multiplier2_0001.zkey \
  --name="1st Contributor Name" -v

And finally we export the verification key:

snarkjs zkey export verificationkey multiplier2_0001.zkey verification_key.json

To generate a proof, we first create a witness:

echo '{"a": "2", "b": "2"}' > input.json
cd multiplier2_js
node generate_witness.js multiplier2.wasm ../input.json ../witness.wtns
cd ..

And then create a proof and verify it:

snarkjs groth16 prove multiplier2_0001.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
# [INFO]  snarkJS: OK!

We have just completed the quick start of Circom. So what went wrong in the real-world exploits? Did they have complicated, heavily optimized circuits with a subtle underconstrained bug (the worst nightmare of every ZK engineer) that the attacker/white-hats exploited? No. They simply didn't have any contributions to the second phase of the setup ceremony. So what happens under the hood in this scenario?

What Happens When You Skip Phase 2

In src/zkey_new.js -- snarkjs, when creating a new zkey, snarkjs initializes both $\gamma_2$ and $\delta_2$ to the same value, the $\mathbb{G}_2$ generator point:

const bg2 = new Uint8Array(sG2);
curve.G2.toRprLEM(bg2, 0, curve.G2.g);

await fdZKey.write(bg2);        // gamma2
await fdZKey.write(bg1);        // delta1
await fdZKey.write(bg2);        // delta2

This is intentional, as it is a placeholder. The user is expected to run Phase 2 contributions (snarkjs zkey contribute) which randomize $\delta$ while leaving $\gamma$ unchanged.

In src/zkey_contribute.js, the contribution multiplies $\delta$ by a random scalar:

zkey.vk_delta_1 = curve.G1.timesFr(zkey.vk_delta_1, curContribution.delta.prvKey);
zkey.vk_delta_2 = curve.G2.timesFr(zkey.vk_delta_2, curContribution.delta.prvKey);

After a proper Phase 2 contribution, $\delta_2$ becomes $[\text{random_key}] \cdot G_2$, making it different from $\gamma_2$. But if no contribution is ever applied, both remain the $\mathbb{G}_2$ generator. That's exactly the vulnerability that was exploited.

We can verify this ourselves by running the setup without the contribution step:

circom multiplier2.circom --r1cs --wasm --sym --c && \
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v && \
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau \
  --name="First contribution" -v && \
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v && \
snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey && \
snarkjs zkey export verificationkey multiplier2_0000.zkey verification_key.json && \
jq '.vk_gamma_2' verification_key.json && \
jq '.vk_delta_2' verification_key.json

Both vk_gamma_2 and vk_delta_2 print the same value, the BN254 $\mathbb{G}_2$ generator:

[
  ["10857046999023057135944570762232829481370756359578518086990519993285655852781",
   "11559732032986387107991004021392285783925812861821192530917403151452391805634"],
  ["8495653923123431417604973247489272438418190587263600148770280649306958101930",
   "4082367875863433681332203403145435568316851327593401208105741076214120093531"],
  ["1", "0"]
]

How to Exploit the Bug

Groth16 Verification

Before anyone can prove or verify, the trusted setup ceremony produces a proving key (used to generate proofs) and a verification key (used to check proofs). The verification key contains the following parameters:

Parameter Group Role
$\alpha$ $\mathbb{G}_1$ Fixed element from setup
$\beta$ $\mathbb{G}_2$ Fixed element from setup
$\gamma$ $\mathbb{G}_2$ Randomizes the public input term
$\delta$ $\mathbb{G}_2$ Randomizes the proof element $C$
$IC[0..n]$ $\mathbb{G}_1$ Encode the circuit's public inputs

Notably, $\gamma$ and $\delta$ must be independent random elements. This is what Phase 2 of the ceremony produces.

A Groth16 proof is composed of three elliptic curve points:

$$\pi = (A, B, C) \quad \text{where } A, C \in \mathbb{G}_1 \text{ and } B \in \mathbb{G}_2$$

These are computed by the prover using the witness and the proving key.

The verifier checks a single equation using elliptic curve pairings. A pairing $e(P, Q)$ takes a $\mathbb{G}_1$ point and a $\mathbb{G}_2$ point and maps them to a target group $\mathbb{G}_T$. The key property is bilinearity:

$$e(aP, bQ) = e(P, Q)^{ab}$$ $$e(P + P', Q) = e(P, Q) \cdot e(P', Q)$$

The verification equation is:

$$e(A, B) = e(\alpha, \beta) \cdot e(\text{vk}_x, \gamma) \cdot e(C, \delta)$$

Or equivalently, moving $e(A, B)$ to the right via negation:

$$e(-A, B) \cdot e(\alpha, \beta) \cdot e(\text{vk}_x, \gamma) \cdot e(C, \delta) = 1$$

Where $\text{vk}_x$ is computed from the public inputs:

$$\text{vk}_x = IC[0] + \text{input}[0] \cdot IC[1] + \text{input}[1] \cdot IC[2] + \cdots$$

In our Multiplier2 case, there's one public input ($c$), so:

$$\text{vk}_x = IC[0] + c \cdot IC[1]$$

So, we have:

  • $e(\alpha, \beta)$ a fixed constant from the setup. Binds the proof to this specific circuit.
  • $e(\text{vk}_x, \gamma)$ binds the proof to the specific public inputs. Because $\gamma$ has an unknown discrete log, you can't manipulate this term.
  • $e(C, \delta)$ binds the proof element $C$ to the setup. Because $\delta$ has an unknown discrete log, you can't freely choose $C$.
  • $e(A, B)$ encodes knowledge of the witness from the prover.

Without the witness, finding $A$, $B$, $C$ that satisfy this equation is computationally infeasible, as long as $\alpha$, $\beta$, $\gamma$, $\delta$ are independent random elements with unknown discrete logs.

Since the setup was skipped, we have $\gamma = \delta = G_2$ (the generator). This gives the attacker two cancellations.

Step 1: Cancel the public-input and proof terms.

Because $\gamma = \delta$, the equation's last two terms share the same $\mathbb{G}_2$ point:

$$e(\text{vk}_x, \gamma) \cdot e(C, \delta) = e(\text{vk}_x, \gamma) \cdot e(C, \gamma) = e(\text{vk}_x + C, \gamma)$$

If we choose $C = -\text{vk}_x$ (negate the y-coordinate):

$$e(\text{vk}_x + (-\text{vk}_x), \gamma) = e(\mathcal{O}, \gamma) = 1$$

The point at infinity $\mathcal{O}$ kills the pairing. Both terms vanish.

Step 2: Cancel the setup terms.

We still need $e(-A, B) \cdot e(\alpha, \beta) = 1$. Since $\alpha$ and $\beta$ are public (readable from the verification key), we set:

$$A = \alpha, \quad B = \beta$$

Then:

$$e(-\alpha, \beta) \cdot e(\alpha, \beta) = e(-\alpha + \alpha, \beta) = e(\mathcal{O}, \beta) = 1$$

Step 3: The full equation collapses.

$$e(-A, B) \cdot e(\alpha, \beta) \cdot e(\text{vk}_x, \gamma) \cdot e(C, \delta) = 1 \cdot 1 = 1$$

Verification passes. We never needed the witness.

Forging a Proof for Our Multiplier2

To forge a proof for $c = 999$ (without knowing any $a$, $b$ such that $a \cdot b = 999$):

  1. Compute $\text{vk}_x = IC[0] + 999 \cdot IC[1]$ using elliptic curve scalar multiplication and addition on $\mathbb{G}_1$.

  2. Set $A = \alpha$ and $B = \beta$ (copied directly from the verification key).

  3. Set $C = -\text{vk}_x = (\text{vk}_{x}.x, \; p - \text{vk}_{x}.y)$, where $p$ is the BN254 field prime.

We wrote a small Python script for forging the proof. Running it:

$ python3 forge_proof.py
Forged proof for c = 999
  A = alpha from VK
  B = beta from VK
  C = -vk_x = (866389343102574678537910566160387043799892038892793114915893462415946246044\
5, 8309882939490366207347146925892408415726053504120286978219270336676306681419)
Written: forged_proof.json, forged_public.json

And then verifying the forged proof:

$ snarkjs groth16 verify verification_key.json forged_public.json forged_proof.json
[INFO]  snarkJS: OK!
The verifier happily accepts that "someone knows $a$, $b$ where $a \cdot b = 999$", but nobody proved any such thing.

Real-World Exploits

This exact vulnerability, $\gamma = \delta = G_2$, has been exploited in the wild against two deployed protocols, both using Circom/snarkjs with a skipped Phase 2 ceremony.

Foom Protocol (~1.4M)

Foom is a lottery/gambling dApp on Base and Ethereum Mainnet that used Groth16 proofs for withdrawals via a collect() function. The verification key's $\delta$ and $\gamma$ were both set to the BN254 $\mathbb{G}_2$ generator, allowing anyone to forge valid proofs for arbitrary public inputs.

A whitehat rescue led by @duha_real on Base and independently by whitehat-rescue.eth on Ethereum drained the contracts before a malicious actor could. The exploit contract read $\alpha$, $\beta$, and $IC[0..6]$ from the on-chain verifier, computed $\text{vk}_x$ for each iteration with an incrementing nullifier, set $C = -\text{vk}_x$, and called collect() in a loop. On Base, 10 iterations drained 99.97% of tokens; on Ethereum, 30 iterations drained 99.99%.

Chain Drained Drain %
Base ~$4.588 \times 10^{30}$ tokens 99.97%
ETH Mainnet ~$1.969 \times 10^{31}$ tokens 99.99%

Veil Protocol (~\$5K)

Veil is a privacy pool on Base forked from Tornado Cash, where users deposited a fixed denomination of 0.1 ETH and withdrew by generating a Groth16 proof of a valid deposit. The same root cause applied: the verifier's $\delta_2$ and $\gamma_2$ were both set to the $\mathbb{G}_2$ generator.

An attacker drained the entire pool in a single transaction: they deployed a contract that looped 29 times, each time computing $\text{vk}_x$ from public inputs (using fabricated nullifier hashes 0xdead0000 through 0xdead001c), setting $C = -\text{vk}_x$, and calling withdraw(). Each call extracted 0.1 ETH, totaling 2.9 ETH, the pool's entire balance.

Field Value
Chain Base
Withdrawals 29
ETH drained 2.9 ETH
Nullifiers used 0xdead0000 to 0xdead001c

Conclusion

The first thought when we went over this vulnerability was that this could have happened to a project that we had audited, since many times the deployment is not in scope and is often not shared with us. This is something we are going to change. We will always insist on reviewing the deployment code and scripts. Further, we would advise any team out there to have experts look at every part of their codebase before they deploy.

Finally, once we figured out the impact of the issue and potentially more exploits that could have happened, we reached out to our good friends at Dedaub (especially thanks to Yannis Smaragdakis) who ran a full scan across many EVM chains (those that have full support at app.dedaub.com) to identify contracts that store the same $\mathbb{G}_2$ element twice, using their advanced tooling. Although we identified a few contracts (many irrelevant to the issue), none of the relevant contracts (groth16 verifiers where $\delta$ = $\gamma$) have significant recent activity or value locked. Additionally, we looked for repos in GitHub with that pattern and a few have it. One example is a TC rebuild for educational purposes with 248 stars. In any case: check your verification keys.

As part of our response to this incident, we are also adding detection for this exact class of vulnerability to zkao, our AI-powered continuous security scanner for Circom circuits. zkao runs multi-agent analysis trained on 100+ real ZK audits, and a missing Phase 2 contribution is exactly the kind of deployment-level issue that benefits from automated, continuous checks rather than one-time reviews. Learn more about zkao.

Keep reading
Recommended

Groth16, Intuitively

Groth16 is still the gold standard for succinct SNARKs: 128-byte proofs, constant-size verification, and a decade of real-world deployment. But despite its ubiquity, almost nobody explains *why* it works the way it does. In this post, we build Groth16 from the ground up, starting from R1CS and QAPs, then layer in pairings, trusted setup parameters, and the separator tricks (α, β, γ, δ) that make the scheme sound. By the end, you should have an intuitive grasp of every term in the final verifier equation.

David Wong · May 01, 2026

Common Circom Pitfalls and How to Dodge Them, Part 1

Programming in Circom comes with its fair share of challenges. After reviewing numerous Circom codebases, we’ve identified certain anti-patterns that occur frequently. In this series, we’ll provide a comprehensive overview of these issues to help you avoid the most common pitfalls. Of course, this won’t be a complete list of every mistake possible (Circom has plenty of ways to trip you up). But the footguns we’ll cover are the ones that tend to catch developers off guard the most.

Marco Besier · June 24, 2025

Do in secret. Assert in public. Don't under-constrain your prover's witness computation in ZK programs

Curious about diving into zero-knowledge (ZK) application development? Our blog post is here to guide you through the common security pitfalls and mindset shifts essential for tackling ZK programming. We reflect on our journey from the early days of Mina Protocol to the dynamic space today, filled with tools like SnarkyJS and newer approaches to split the prover and verifier roles effectively. Discover the errors to avoid, like under-constraining data and letting divisors slip through unchecked, and learn how a solid review and audit process can save your ZK applications from disastrous security issues. Dive in to get the insights you need to navigate the ZK landscape confidently!

Brandon Kase · June 01, 2023
More to explore

10 Must-Read Papers That Shaped Modern Zero-Knowledge Proofs

Zero-knowledge proofs have come a long way in 40 years, thanks to groundbreaking work from many brilliant minds. We're taking you on a tour of the key milestones in this fascinating journey, from the foundational concepts in 1985 by Goldwasser, Micali, and Rackoff, to modern applications and innovations like STARKs and zkVMs. You'll get a taste of major papers and projects that have pushed these proofs from theoretical curiosity to practical and scalable solutions. Dive into the history, understand the evolution, and see how these cryptographic marvels continue to shape the future of secure computation!

ZK/SEC · November 12, 2024

Uncovering the Phantom Challenge Soundness Bug in Solana's ZK ElGamal Proof Program

In June 2025, we uncovered a serious soundness issue in Solana's ZK ElGamal Proof Program that could let attackers manipulate confidential token transfers undetected. We worked with the Anza team to quickly address the flaw by pausing and disabling vulnerable components. This post dives into the root cause, which was a subtle mistake in handling prover-generated challenges within sigma OR proofs, revealing broader lessons in zero-knowledge protocol security. If you're interested in cryptographic protocol design, this could provide valuable insights.

Suneal Gong · June 26, 2025

Breaking Jolt’s Verifier with an Unbound Uni-Skip Claim

We found a critical soundness bug in Jolt’s transparent verifier that allowed a forged proof for an invalid execution to verify. The issue was fixed quickly after disclosure, and the full PoC and write-up are available in the linked repository.

Minsun Kim · May 09, 2026