Back to all posts

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

banner

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?

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:

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:

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.

zkSecurity offers auditing, research, and development services for cryptographic systems including zero-knowledge proofs, MPCs, FHE, and consensus protocols.

Learn more →

Share This Article