Zero-Knowledge Proof Vulnerabilities — A Technical Classification of Soundness, Completeness, and Protocol Failures
- Background — The Three Guarantees a ZK System Must Provide
- Category 1 — Trusted Setup Misconfiguration
- Category 2 — Fiat-Shamir Transcript Binding Failures
- Category 3 — Under-Constrained Witnesses (“Assigned but Not Constrained”)
- Category 4 — Trivially Bypassed Proof Verification
- Category 5 — L1/L2 State Discrepancy (Parameter Not Bound by ZK Proof)
- Category 6 — Range Proof Validation Failures
- Category 7 — Supply Chain and Ecosystem Attacks
- Defense Strategies
- Conclusion
- Frequently Asked Questions
- References
Zero-knowledge proof (ZKP) systems allow a prover to convince a verifier that a statement is true without revealing any information beyond the validity of that statement. As ZK technology has moved from academic constructions into deployed blockchain infrastructure, the attack surface has grown proportionally. This article classifies the principal vulnerability categories that have been discovered or exploited in production ZKP systems, from trusted setup misconfigurations to circuit-level soundness failures.
This article has been made with the help of Claude Code and several custom skills.
[TOC]
Background — The Three Guarantees a ZK System Must Provide
Any ZK proof system must satisfy three properties:
Completeness. If the statement is true and both parties are honest, the verifier accepts the proof with overwhelming probability.
Soundness. If the statement is false, no computationally bounded prover can convince the verifier to accept, except with negligible probability. Soundness errors are the dominant source of exploitable bugs.
Zero-Knowledge. The proof reveals nothing about the witness beyond the fact that a valid one exists. Failures here compromise privacy but rarely enable direct fund theft.
The formal soundness condition is:
[\begin{aligned} \Pr\left[\mathcal{V}(\text{crs}, x, \pi) = 1 \;\middle|\; x \notin \mathcal{L},\; \pi \leftarrow \mathcal{A}(\text{crs}, x)\right] \leq \text{negl}(\lambda) \end{aligned}]
where \(\mathcal{L}\) is the language of true statements, \(\text{crs}\) is the common reference string, and \(\pi\) is the proof produced by an adversary \(\mathcal{A}\). Every vulnerability class in this article is, in one way or another, a violation of this bound.
Category 1 — Trusted Setup Misconfiguration
Mechanism
Pairing-based SNARKs (Groth16, PLONK) require a trusted setup ceremony to generate a common reference string. The Groth16 setup produces structured reference string elements that include two blinding factors, conventionally called gamma (\(\gamma\)) and delta (\(\delta\)), which are drawn from random scalars and destroy themselves after the ceremony (the “toxic waste”). The resulting CRS encodes \([\gamma]_2\) and \([\delta]_2\) as BN254 G2 points used during proof verification.
The verifier equation checks:
[\begin{aligned} e(A, B) = e(\alpha, \beta) \cdot e!\left(\frac{\text{pub_input} \cdot g_1}{\gamma}, [\gamma]_2\right) \cdot e(C, [\delta]_2) \end{aligned}]
If \(\gamma = 1\) (i.e., the G2 generator is used directly), the term \(1$/\gamma = 1\) and the pairing constraint degenerates. An adversary who knows \(\gamma\) can produce valid-looking proofs for false statements because the blinding intended to prevent malleable proofs collapses.
Real-World Exploitation
Veil Cash (February 20, 2026, ~$5$K): The Groth16 verifier was deployed with \(\gamma = \delta = G_{2,\text{gen}}\), the default BN254 G2 generator point. The trusted setup ceremony (npx snarkjs zkey contribute) was never executed. An attacker submitted 29 fabricated proofs using null-prefix nullifier hashes and drained the contract in a single transaction.
FoomCash (February 26, 2026, $2.26$M): Six days after Veil Cash’s post-mortem was published, a copycat attacker applied the identical technique to FoomCash, a ZK privacy lottery with an identical broken verifier on Ethereum and Base. Two independent actors drained both pools the same morning. The net loss after whitehats returned the majority was approximately $420$K.
Legitimate Groth16 Setup Broken Setup (Veil Cash / FoomCash)
────────────────────────── ──────────────────────────────────
gamma = random secret gamma = G2_generator (default)
delta = random secret delta = G2_generator (default)
CRS published; toxic waste CRS published; NO ceremony run
destroyed
Verifier: checks e(A,B) == ... Verifier: check degenerates
=> any fabricated proof accepted
Mitigation
Verification keys must be checked against a reproducible transcript from the MPC ceremony (e.g., using snarkjs zkey verify). Before deployment, the live verifier contract should be tested by submitting a fabricated proof: if it is accepted, the setup is broken.
Category 2 — Fiat-Shamir Transcript Binding Failures
Mechanism
Non-interactive ZK proof systems (NIZKs) are constructed from interactive proof systems using the Fiat-Shamir heuristic: the verifier’s random challenges are replaced by the output of a cryptographic hash function applied to the transcript so far. The correctness of this transformation depends on the hash function being applied to all prover-controlled values that influence a subsequent verification equation.
The “Frozen Heart” vulnerability (named by Trail of Bits in 2022) arises when one or more algebraic components that affect a verification equation are omitted from the hash input. The attacker can observe the challenge, then work backward to choose committed values that satisfy the verification equation for a false statement.
Correct Fiat-Shamir Frozen Heart Failure
──────────────────────── ────────────────────────────
Prover commits A, B, C Prover commits A, C
H = Hash(A, B, C, statement) H = Hash(A, C, statement) [B omitted]
challenge c = H challenge c = H
Attacker cannot backsolve Attacker sees c, then freely
because c depends on all values picks B to satisfy e(A,B) == c
including B before c is known after the fact => forged proof
Real-World Examples
Solana ZK ElGamal Bug #1 (April 16–18, 2025): The ZK ElGamal Proof program, which verifies confidential transfer proofs for Token-2022, omitted certain algebraic components from its Fiat-Shamir transcript. An attacker could forge proofs enabling unlimited token minting or unauthorized withdrawal from confidential token accounts. Discovered by LonelySloth; $0$ loss (patched in 26 hours). Bounty undisclosed.
Solana ZK ElGamal Bug #2 (June 10, 2025): A separate binding failure in the same program, discovered by suneal_eth (@zksecurityXYZ), approximately 60 days after the first bug was patched. A different component was excluded from the transcript. The ZK ElGamal program was disabled at epoch 805 (June 19, 2025). No exploitation occurred because real-world usage of confidential transfers was near-zero.
Six zkVM Fiat-Shamir Failures (September 2025–March 2026, OtterSec): Himanshu Sheoran and Valter Wik at OtterSec disclosed independent Fiat-Shamir binding failures in six zkVM implementations over a six-month period:
| System | Reported | Patched |
|---|---|---|
| Jolt (a16z) | September 2025 | October 3, 2025 |
| Nexus | October 2025 | October 24, 2025 |
| Cairo-M (Kakarot) | October 2025 | October 31, 2025 |
| Binius64 | December 2025 | December 29, 2025 |
| Expander (Polyhedra) | November 2025 | January 21, 2026 |
| Ceno (Scroll) | November 2025 | March 5, 2026 |
The pattern recurrence across independent implementations reflects the absence of standardized Fiat-Shamir construction guidance in protocol specifications.
Mitigation
The standard mitigations are:
- Use a domain-separated hash (e.g., include a protocol identifier and version in the transcript).
- Hash all group elements and scalar commitments before deriving any challenge, following Bernhard et al.’s “strengthen” paradigm.
- Use a transcript abstraction (Merlin, Poseidon-based) that makes omissions structurally hard: all prover messages are appended before any challenge is squeezed.
Category 3 — Under-Constrained Witnesses (“Assigned but Not Constrained”)
Mechanism
In arithmetic circuit proving systems (R1CS, PLONK, Halo2), the circuit consists of two logically separate operations:
- Witness assignment: computing values for circuit variables from the private inputs.
- Constraint enforcement: producing polynomial equations that the prover must satisfy.
A constraint of the form \(a \cdot b = c\) is only meaningful if it is actually added to the constraint system. In Rust-based frameworks (Bellman, franklin-crypto), assigning a value via AllocatedNum::alloc(cs, || Ok(v)) creates a new witness variable without adding any constraint. A subsequent constraint must be written explicitly.
The same pattern appears in Circom: the operator <-- assigns a signal without constraining it; === or <== are needed to add the constraint. When developers confuse assignment with constraint, the verifier accepts proofs for any value of that variable, not just the intended one.
[\begin{aligned}
\text{Unconstrained variable: } &\exists\; a’ \neq a \text{ such that the circuit accepts witness } (a’, b, c, \ldots)
\text{Correct: } &\forall\; a’ \neq a,\; \pi(a’, b, c, \ldots) \text{ fails verification}
\end{aligned}]
Real-World Examples
zkSync Lite — Floating-Point Mantissa Bug (October 5, 2023, $200$K bounty): zkSync Lite serializes transaction amounts using a packed floating-point format. The parse_with_exponent_le function in franklin-crypto decoded the packed representation by allocating the mantissa with AllocatedNum::alloc(cs, || Ok(mantissa_value))?. No constraint linked this variable to the exponent or packed input.
An attacker could produce a proof with any (packed_amount, unpacked_amount) pair where the mantissa was arbitrary. Possible impacts included unauthorized token minting, transfer amount tampering, and token theft. Discovered by LonelySloth; fixed November 7, 2023 by replacing alloc() with into_allocated_num(), which derives the mantissa from the computation and enforces the constraint.
Aztec Connect — TurboPlonk Claim Proof Bug (September 12, 2023, $450$K bounty, $5$M TVL at risk): The claim proof circuit computed a proportional allocation:
[\begin{aligned}
\text{user_output} &= \left\lfloor \frac{\text{total_input} \times \text{bridge_output_a}}{\text{bridge_output_b}} \right\rfloor
\text{remainder} &= (\text{total_input} \times \text{bridge_output_a}) \bmod \text{bridge_output_b}
\end{aligned}]
Two bugs combined: (1) limbs[3] was given a range constraint of 68 bits instead of a narrower bound derived from MAX_INPUT_BITS, and (2) remainder had no range constraint at all. A malicious sequencer could choose any remainder, shifting user_output upward to extract more than the proportional share, up to total_input. Discovered by lucash-dev; fixed September 27–October 3, 2023.
zkSync Era — RAM Permutation Soundness Bug (September 15, 2023, 50,000 USDC bounty, $1.9$B at risk): EraVM decomposes 256-bit memory values into a 192-bit value and a 64-bit value_residual to fit within the BN254 field. During MemoryWriteQuery construction, the upper 128 bits were constrained via a LinearCombination:
lc.add_assign_number_with_coeff(&u64_word_2.inner, shifts[0]);
lc.add_assign_number_with_coeff(&u64_word_3.inner, shifts[64]);
lc.add_assign_number_with_coeff(&highest_128.inner, minus_one);
// BUG: neither lc.enforce_zero(cs) nor lc.into_num(cs) was called
The LinearCombination was constructed but never enforced. The upper 128 bits of any stored value were unconstrained. An attacker controlling the prover could manipulate the _amount field in L2EthToken.withdraw() to forge a withdrawal claim for approximately 100,000 ETH while burning only 0.00002 ETH. Discovered by ChainLight; the first ZK circuit bounty ever paid for zkSync Era.
EraVM MemoryWriteQuery decomposition:
256-bit register value
┌───────────────────────────────────────────────────────────┐
│ upper 64 bits (u64_word_3) │ mid 64 bits (u64_word_2) │ lower 128 bits
│ ↕ UNCONSTRAINED ↕ │ ↕ UNCONSTRAINED ↕ │ (value field)
└───────────────────────────────────────────────────────────┘
lc = u64_word_2 + u64_word_3*2^64 - highest_128
Intent: lc == 0 (constraint)
Actual: lc never enforced => any value in upper 128 bits passes verifier
Mitigation
- Prefer circuit DSLs or wrappers that make assignment and constraint a single operation (e.g.,
cs.enforcein Bellman,<==in Circom). - Implement a constraint count test: the expected number of constraints is derived from the spec; any difference flags missing constraints.
- Review all
AllocatedNum::alloc(),cs.alloc(), and Circom<--usages as constraint-free assignments that require explicit follow-up constraints.
Category 4 — Trivially Bypassed Proof Verification
Mechanism
The simplest class of ZK failure is not a cryptographic error but a code bug: the verification function is never executed. This typically occurs when:
- A stub
return true;is left in production code. - An emergency exit path receives different code coverage than the main path.
- An upgrade replaces a working verifier with a broken one.
Real-World Example
ZKSwap — return true in verifyExitProof() (July 9, 2025, $5$M): ZKSwap’s bridge emergency exit mechanism, verifyExitProof(), began with an unconditional return true. All subsequent ZK proof verification logic was unreachable dead code. Any fabricated proof submitted against any of the 15 queried token IDs was accepted, enabling the attacker to drain $5$M in approximately 13 minutes.
verifyExitProof(proof, publicInput) {
return true; // ← dead code gate: verification never runs
// everything below is unreachable:
uint256[] memory vk = getVerificationKey();
return Verifier.verify(vk, proof, publicInput);
}
The rollup had been inactive since February 2025, and the emergency code path had evidently not been reviewed since its last revision.
Mitigation
- Integration tests must submit deliberately invalid proofs to each verification path and confirm rejection.
- Emergency exit paths should be activated on a test fork before main-net deployment of any upgrade.
Category 5 — L1/L2 State Discrepancy (Parameter Not Bound by ZK Proof)
Mechanism
ZK-rollup settlement depends on two components agreeing on the same data: the ZK proof, which commits to all transactions in a block via a hash or polynomial commitment, and the L1 settlement contract, which processes the block and applies state changes. If a parameter controlling the scope of L1 processing is read from raw calldata rather than from the ZK-verified public inputs, an attacker can set it independently of what the proof commits to.
ZK-Rollup Settlement — Correct vs. Vulnerable
CORRECT:
calldata → [numRealTxs | txData | zkProof]
↑ bound by circuit (public input)
L1 contract: loop i in [0, numRealTxs) ← same value the proof commits to
VULNERABLE (Aztec Connect June 2026):
calldata → [numRealTxs=1 | slots[2..32]=deposit_txs | zkProof]
↑ NOT bound by circuit
ZK proof: hashes all 32 slots (detects nothing wrong)
L1 loop: stops at slot 1 (deposits in 2-32 silently skipped)
Result: L2 balance credited for deposits L1 never processed
Real-World Example
Aztec Connect Settlement Bypass (June 14–15, 2026, $2.28$M): The RollupProcessorV3.processRollup() settlement loop read numRealTxs directly from calldata. The ZK circuit committed to all 32 transaction slots via SHA-256, but only iterated up to numRealTxs during L1 settlement. By setting numRealTxs = 1 and packing fabricated deposit transactions into slots 2–32, attackers created L2 balances that had no L1 backing. The attacker then submitted legitimate withdrawal proofs against those balances, draining $2.19$M$ in a single transaction on Day 1 and approximately $88$K on Day 2.
Mitigation
Any parameter that governs how much of a committed block is processed must be a public input to the ZK proof, constrained by the circuit to equal the value the verifier contract reads from calldata. A discrepancy between these two sources is the attack vector.
Category 6 — Range Proof Validation Failures
Mechanism
Range proofs are a ZK primitive distinct from general-purpose SNARKs. In Monero-derived privacy chains, transaction amounts are committed using Pedersen commitments and their non-negativity is proven using Bulletproofs:
[\begin{aligned}
C &= v \cdot G + r \cdot H
\text{Prove: } &v \in [0,\, 2^{64})
\end{aligned}]
where \(G\) and \(H\) are independent group generators. A range proof proves that the committed value \(v\) lies within the stated interval without revealing \(v\). If the range proof constraints are insufficient, an attacker can commit to a negative value modulo the field order, which wraps around to a very large positive number, effectively minting unbacked tokens.
Real-World Example
Haven Protocol (December 10, 2024): Haven Protocol is a Monero fork that adds oracle-based stablecoin minting. A flaw in the range proof validation allowed transactions with amounts outside the valid range to be accepted by the consensus rules, enabling unlimited XHV minting. The attack targeted the Bulletproof-based amount commitment system integral to the privacy architecture.
Category 7 — Supply Chain and Ecosystem Attacks
Although not a failure of ZK cryptography itself, the ZK ecosystem is an attractive supply chain target because:
- ZK protocols handle large TVL with smaller user bases than general DeFi.
- SDK packages are typically installed by developers with broad system access.
- Wallets, keys, and API credentials co-locate on developer machines.
dYdX npm/PyPI Supply Chain Attack (January 27–28, 2026): A threat actor compromised a dYdX package maintainer’s credentials and injected malicious code into @dydxprotocol/v4-client-js (four versions) and dydx-v4-client (one PyPI version). The npm variant exfiltrated wallet seed phrases and private keys; the PyPI variant installed a Remote Access Trojan enabling persistent backdoor access.
Defense Strategies
The following controls address the categories above:
| Vulnerability Class | Primary Control |
|---|---|
| Trusted setup misconfiguration | Reproducible ceremony transcript; test fabricated proof rejection before deployment |
| Fiat-Shamir transcript binding | Transcript abstraction (Merlin/Poseidon); include all group elements before squeezing challenges |
| Under-constrained witnesses | Constraint count test; ban raw alloc(); prefer <== over <-- in Circom |
| Trivially bypassed verification | Integration tests that assert invalid proof rejection on all paths, including emergency exits |
| L1/L2 state discrepancy | All iteration-bound parameters must be public inputs to the circuit |
| Range proof failures | Formal verification of range constraints; consensus-level tests for boundary values |
| Supply chain | Dependency pinning; 2FA on npm/PyPI; reproducible builds |
Conclusion
Zero-knowledge proof vulnerabilities span a broader surface than their name suggests. The cryptographic core (the proof system itself) can fail through trusted setup shortcuts, Fiat-Shamir transcript omissions, and under-constrained witnesses. The surrounding infrastructure can fail through dead verification code, L1/L2 parameter mismatches, and range proof boundary errors. The incidents reviewed here share a common trait: the ZK proof itself was accepted as valid by the verifier, yet the attacker’s claim was false. Soundness held formally for the circuit as written, but the circuit did not correctly encode the property that needed to be proven.

Frequently Asked Questions
Q: What is the difference between a soundness bug and a completeness bug in a ZK circuit?
A soundness bug allows a prover to produce a valid proof for a false statement. This is the dangerous class: it enables fraud, forged withdrawals, or unauthorized minting. A completeness bug prevents a prover from producing a valid proof for a true statement. Completeness bugs cause denial of service (honest transactions fail to be proven) but do not allow theft. Most real-world exploits are soundness bugs. The Scroll zkEVM Poseidon hash circuit over-constrained one of its custom lines, a completeness bug, preventing legitimate proofs from being generated.
Q: Why does setting gamma and delta to the G2 generator break Groth16 security?
In Groth16, \([\gamma]_2\) and \([\delta]_2\) appear in the verification equation as blinding factors. If \(\gamma = 1\) (represented by the generator point \(G_2\)), the pairing term involving \(1$/\gamma\) becomes trivially computable. The algebraic binding that prevents a prover from constructing proofs for false statements depends on \(\gamma\) being an unknown discrete logarithm. When it equals 1 (known), the binding collapses and any fabricated proof that satisfies the degenerate equation will be accepted. The attacker does not need to know the original witness; any values satisfying the weakened check suffice.
Q: How does an unconstrained mantissa in zkSync Lite enable token minting?
zkSync Lite encodes transfer amounts as (packed_amount, unpacked_amount) pairs inside the ZK circuit. The packed-to-unpacked relation is: unpacked = mantissa * 10^exponent. If the mantissa variable is allocated without a constraint linking it to the packed representation, the prover can choose any mantissa value independently. A proof claiming a transfer of mantissa = 1 (packed) maps to unpacked = 1 * 10^exponent, but the prover can set mantissa = 10^15 in the unconstrained variable and prove a transfer of 10^15 * 10^exponent instead, with the same packed amount. The verifier accepts the proof because the circuit never checks that packed_amount correctly encodes the mantissa.
Q: Why did Solana have two independent Fiat-Shamir binding failures in the same ZK ElGamal program within 60 days?
The two bugs affected different components of the proof transcript. Fixing the first binding omission (reported by LonelySloth, April 2025) did not trigger a systematic review of all other components in the transcript. The second bug (reported by suneal_eth, June 2025) involved a different algebraic element that was also absent from the hash. This illustrates that a point-fix for a Fiat-Shamir binding error does not constitute a guarantee that all other elements are correctly bound. A complete audit of the entire transcript, listing every prover-controlled value and confirming each appears in the hash before the corresponding challenge, is necessary after any such fix.
Q: In the Aztec Connect June 2026 exploit, the ZK proof was valid. Why was the attack still possible?
The ZK proof covered all 32 transaction slots in each rollup block via a SHA-256 commitment. It was a valid proof of the claimed computation. However, the processRollup() function on L1 did not use the ZK proof’s commitment to determine how many slots to process; it read numRealTxs directly from calldata. By setting numRealTxs = 1 while placing fabricated deposit transactions in slots 2–32, the attacker submitted a proof that was valid for the full 32-slot block, but caused L1 to process only slot 1. The deposit rows in slots 2–32 were committed to by the proof but never settled on L1, producing L2 credit without L1 backing. The ZK system proved the computation correctly; the error was that the computation being proven did not match what L1 enforcement actually required.
Q: What makes the “assigned but not constrained” pattern recurring across different circuit frameworks and teams?
The root cause is a conceptual separation in circuit DSLs between witness computation (which produces values for proof generation) and constraint generation (which produces the polynomial equations the verifier checks). In Bellman and franklin-crypto, these are two distinct Rust operations: assigning a value does not automatically create a constraint on that value. In Circom, <-- assigns without constraining; === constrains without assigning. Developers accustomed to normal programming, where an assignment establishes a binding, may not add the explicit constraint, leaving the variable free. The bug is hard to catch in code review because the code looks correct: the value is computed and stored. Only a symbolic constraint-count test or formal analysis reveals that the verifier does not actually check the assigned value.
References
- Veil Cash / FoomCash — Rekt News
- FoomCash — Rekt News
- Aztec Connect Claim Proof Bug — HackMD
- Aztec Multiple-Spend Bugfix Review — Immunefi
- zkSync Era Soundness Bug — ChainLight
- zkSync Lite Proof Verification Bugfix — Immunefi
- Solana ZK ElGamal Post-Mortem June 2025
- OtterSec zkVM Fiat-Shamir Disclosures
- ZKSwap Exploit — Blockaid
- Claude Code