Zero-knowledge proofs were supposed to be the silver bullet — mathematically provable, cryptographically sound, "fraud is literally impossible." Then Foom Cash lost $2.3M in March 2026 because someone misconfigured a verifier circuit, and suddenly "mathematically provable" felt a lot less comforting.
Here's the uncomfortable truth: ZK circuits are the most complex code in your entire protocol stack, and almost nobody audits them properly. The constraints are invisible. The bugs are non-obvious. And when they fail, they fail catastrophically — allowing attackers to forge proofs, mint tokens from nothing, or drain bridges without leaving a trace.
This article breaks down the 7 vulnerability classes in ZK circuits that have led to real losses, with concrete detection patterns and code-level fixes for each.
1. Under-Constrained Circuits: The Silent Proof Forger
What it is: A ZK circuit that doesn't fully constrain all witness values, allowing multiple valid witnesses for a single public input. The prover can satisfy the circuit with a fabricated witness that proves something false.
Real-world impact: This is the #1 ZK vulnerability class. Under-constrained circuits have appeared in Tornado Cash forks, bridge verifiers, and rollup provers.
The pattern:
// VULNERABLE: Missing constraint on intermediate value
signal intermediate;
intermediate <-- in1 * in2; // Assignment only, no constraint!
out <== intermediate + 1;
// FIXED: Constrain the intermediate computation
signal intermediate;
intermediate <== in1 * in2; // <== both assigns AND constrains
out <== intermediate + 1;
In Circom, the difference between <-- (assignment only) and <== (assignment + constraint) is a single character. That single character has been responsible for millions in losses.
How to audit:
- Grep for every
<--in Circom circuits — each one is a potential under-constraint - Use
circom --inspectto detect unconstrained signals - Run differential testing: generate proofs with different witness values for the same public input. If two different witnesses both verify, you have an under-constraint
2. Verifier Misconfiguration: The Foom Cash Pattern
What it is: The ZK verifier contract accepts proofs it shouldn't — either because verification parameters are wrong, the verification key doesn't match the circuit, or the public input binding is broken.
The Foom Cash exploit (March 2026): Attackers exploited a misconfiguration in Foom Cash's zk-SNARK verifier to authorize unauthorized loan withdrawals, draining $2.3M. The verifier accepted proofs generated against a different circuit than intended.
The pattern:
// VULNERABLE: Verifier doesn't bind proof to specific action
function withdraw(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[1] memory input // Only nullifier, no amount binding!
) external {
require(verifier.verifyProof(a, b, c, input), "Invalid proof");
// Amount comes from calldata, not from the proof
payable(msg.sender).transfer(amount); // amount is unbounded!
}
// FIXED: Bind all critical values as public inputs
function withdraw(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[4] memory input // nullifier + root + amount + recipient
) external {
require(verifier.verifyProof(a, b, c, input), "Invalid proof");
require(input[2] <= maxAmount, "Amount exceeds maximum");
payable(address(uint160(input[3]))).transfer(input[2]);
}
How to audit:
- Map every public input in the verifier contract to its corresponding circuit signal
- Verify the verification key matches the deployed circuit (re-compile and compare)
- Check that ALL security-critical values (amounts, recipients, nullifiers, merkle roots) are public inputs, not unconstrained calldata
3. Frozen Heart: Forging Proofs via Fiat-Shamir Weakness
What it is: The Fiat-Shamir heuristic converts interactive proofs to non-interactive ones by hashing the transcript into challenges. If the hash doesn't include all necessary transcript elements, an attacker can manipulate challenges to forge valid-looking proofs.
Real-world impact: Trail of Bits discovered "Frozen Heart" vulnerabilities in multiple ZK libraries (Plonky2, Halo2, and others). A weak Fiat-Shamir implementation allows proof forgery — the attacker can prove any statement.
The pattern:
# VULNERABLE: Challenge doesn't commit to all prover messages
challenge = hash(public_input) # Missing commitment values!
# FIXED: Challenge commits to entire transcript
challenge = hash(
public_input,
commitment_1,
commitment_2,
prover_message_round_1,
# ... all prior transcript elements
)
How to audit:
- Trace the Fiat-Shamir transcript in your proving system
- Verify every prover message from every round is included in the hash
- Check that the verifier recomputes challenges identically to the prover
- Use the Trail of Bits ZK audit checklist as a reference
4. Trusted Setup Compromise: The Nuclear Option
What it is: Groth16 and similar ZK-SNARK schemes require a "trusted setup" ceremony. The toxic waste (secret randomness) from this ceremony must be destroyed. If any participant retains it, they can forge arbitrary proofs.
Why it matters in 2026: Many DeFi protocols fork existing circuits (Tornado Cash, Zcash) but run their own ceremonies with fewer participants, weaker operational security, or skip the ceremony entirely by reusing someone else's parameters.
The kill chain:
- Fork a ZK protocol
- Reuse the original trusted setup parameters (or run a ceremony with 3 team members)
- One insider retains the toxic waste
- Silently forge proofs to drain the protocol over months
How to audit:
- Verify the ceremony transcript is publicly available
- Check participant count (>100 independent participants is the minimum bar)
- For production protocols, prefer PLONK/Halo2/STARKs which don't require per-circuit trusted setups
- If using Groth16, verify the
.ptauand.zkeyfiles against a known ceremony
5. Arithmetic Field Overflow: When Math Betrays You
What it is: ZK circuits operate over prime fields (typically BN254 or BLS12-381). Developers often assume standard integer arithmetic, but field arithmetic wraps around at the prime modulus. This creates overflow conditions invisible to standard testing.
The pattern:
// BN254 prime: 21888242871839275222246405745257275088548364400416034343698204186575808495617
// VULNERABLE: Range check that doesn't account for field wrap
template RangeCheck(n) {
signal input in;
// This only checks in < 2^n, but field elements can be
// p - small_number which is HUGE but wraps to negative
component bits = Num2Bits(n);
bits.in <== in;
}
// FIXED: Explicit upper bound check against field prime
template SafeRangeCheck(n) {
signal input in;
// First decompose to bits
component bits = Num2Bits(n);
bits.in <== in;
// Then verify in < 2^n (not just that it decomposes to n bits)
// by checking the value is in the expected range
component lt = LessThan(252);
lt.in[0] <== in;
lt.in[1] <== 1 << n;
lt.out === 1;
}
How to audit:
- Identify every arithmetic operation and check for field-boundary behavior
- Test with values near
p - 1,p/2, and0 - Verify all range checks account for field wrap-around
- Use formal verification tools (Ecne, Picus) to check circuit soundness
6. Nullifier Reuse: Double-Spending Through Hash Collisions
What it is: Privacy protocols use nullifiers to prevent double-spending. If the nullifier derivation is weak or the nullifier check has gaps, users can spend the same note multiple times.
The pattern:
// VULNERABLE: Nullifier only depends on secret, not on the note
template WeakNullifier() {
signal input secret;
signal output nullifier;
// Same secret always produces same nullifier
// But what if user has multiple notes with same secret?
component hasher = Poseidon(1);
hasher.inputs[0] <== secret;
nullifier <== hasher.out;
}
// FIXED: Nullifier commits to the specific note
template StrongNullifier() {
signal input secret;
signal input leafIndex; // Unique per note
signal input merkleRoot; // Binds to specific tree state
signal output nullifier;
component hasher = Poseidon(3);
hasher.inputs[0] <== secret;
hasher.inputs[1] <== leafIndex;
hasher.inputs[2] <== merkleRoot;
nullifier <== hasher.out;
}
How to audit:
- Verify nullifiers are derived from ALL unique note attributes
- Check the on-chain nullifier set for collision resistance
- Test: can two different notes produce the same nullifier?
- Test: can the same note produce two different nullifiers?
7. Public Input Injection: Manipulating What the Verifier Sees
What it is: The boundary between public inputs (visible to the verifier) and private inputs (only known to the prover) is critical. If an attacker can influence public inputs that the contract trusts, they can make the verifier accept proofs for false statements.
The pattern:
// VULNERABLE: Public input comes from user-controlled calldata
function verifyAndExecute(
bytes calldata proof,
uint256 merkleRoot // User provides this!
) external {
uint256[1] memory input;
input[0] = merkleRoot; // Attacker controls the \"trusted\" root
require(verifier.verify(proof, input), "Bad proof");
// Attacker proved membership in THEIR OWN merkle tree
}
// FIXED: Public inputs come from on-chain state
function verifyAndExecute(bytes calldata proof) external {
uint256[1] memory input;
input[0] = currentMerkleRoot; // From contract storage
require(verifier.verify(proof, input), "Bad proof");
}
How to audit:
- Trace every public input from the verifier contract back to its source
- Any public input derived from calldata or user-controlled storage is suspect
- Merkle roots, timestamps, and state commitments should come from on-chain state
- Cross-reference the circuit's public input count with the verifier's expected inputs
The ZK Audit Checklist
Before deploying any ZK circuit to production:
| Check | Tool | Priority |
|---|---|---|
| All signals constrained | circom --inspect |
🔴 Critical |
No <-- without matching ===
|
grep + manual review | 🔴 Critical |
| Verification key matches circuit | Re-compile & diff | 🔴 Critical |
| Public inputs from on-chain state | Manual review | 🔴 Critical |
| Fiat-Shamir transcript complete | Transcript analysis | 🟡 High |
| Field overflow at boundaries | Fuzzing with edge values | 🟡 High |
| Nullifier uniqueness | Property-based testing | 🟡 High |
| Trusted setup ceremony verified | Ceremony transcript | 🟡 High |
| Formal verification of constraints | Ecne / Picus | 🟢 Recommended |
| Differential testing (multiple provers) | Custom harness | 🟢 Recommended |
The Bigger Picture: Q1 2026 in Context
The $137M lost across 15 DeFi protocols in Q1 2026 wasn't primarily from ZK bugs — most losses came from access control failures, oracle manipulation, and key compromise. But ZK vulnerabilities are uniquely dangerous because:
- They're invisible. A compromised verifier looks exactly like a working one until someone forges a proof.
- They're irreversible. Once a forged proof is accepted on-chain, there's no revert mechanism.
- They're catastrophic. A single under-constrained signal can drain an entire protocol in one transaction.
As ZK-rollups handle more TVL and ZK-bridges connect more chains, circuit-level security becomes existential. The $2.3M Foom Cash loss was a warning shot. The next one might not be so small.
Resources
- Trail of Bits — Frozen Heart Vulnerability
- 0xPARC — ZK Bug Tracker
- OWASP Smart Contract Security Top 10 (2026)
- Ecne — Automated ZK Circuit Verification
- Certora Solana Prover — Formal Verification
This is part of my ongoing DeFi Security Research series. Previously: 5 Smart Contract Anti-Patterns That Cost DeFi $137M in Q1 2026, EVMbench Deep Dive.
Follow @ohmygod for weekly security research.
Top comments (0)