Zero-knowledge proofs are supposed to be the ultimate trust machine: prove you know something without revealing it. But what happens when the verifier is broken? When the math is correct but the code is wrong? You get forged proofs, double-spends, and identity theft — all cryptographically "valid."
This article dissects the three most dangerous classes of ZK proof verification vulnerabilities that have hit production systems between 2024 and 2026, with concrete code examples and defense patterns.
The Frozen Heart Family: When Fiat-Shamir Goes Wrong
What Is Fiat-Shamir?
The Fiat-Shamir transformation converts interactive proofs into non-interactive ones by replacing the verifier's random challenge with a hash of the proof transcript. It's elegant, widely used, and dangerously easy to implement incorrectly.
The security guarantee is simple: the hash must include every public input, every intermediate value, and the full transcript up to the point of challenge generation. Miss one, and the prover controls the challenge.
The Vulnerability
Frozen Heart vulnerabilities occur when implementations omit public inputs or intermediate protocol values from the Fiat-Shamir hash computation. This gives a malicious prover enough degrees of freedom to craft challenge values that make fraudulent proofs verify.
Trail of Bits discovered this pattern across multiple proof systems:
- PlonK implementations — missing public input commitments in challenge derivation
- Bulletproofs — incomplete transcript binding in range proof verification
- Girault's proof of knowledge — public key omission from challenge hash
Why This Matters for DeFi
Consider a ZK-identity system like Worldcoin's World ID or Gitcoin Passport. The proof says: "I am a unique human who hasn't claimed this airdrop before." A Frozen Heart vulnerability means an attacker can forge this proof for arbitrary identities. The result:
- Sybil attacks on airdrops and governance votes
- Identity theft in ZK-based login systems
- Session hijacking where forged proofs replace legitimate auth tokens
Defense Pattern: Transcript Binding
# VULNERABLE: Missing public inputs in challenge
def compute_challenge_BROKEN(commitment, proof_elements):
transcript = hash(commitment + proof_elements)
return transcript
# SECURE: Full transcript binding
def compute_challenge_SAFE(public_inputs, commitment,
proof_elements, domain_separator):
transcript = hash(
domain_separator + # Prevent cross-protocol attacks
public_inputs + # ALL public inputs
commitment + # Prover's commitment
proof_elements # Full transcript so far
)
return transcript
The rule: if it's public and it matters, it goes in the hash. No exceptions.
snarkjs Input Aliasing: The One-Character Bug That Breaks Groth16
The Setup
snarkjs is the most widely used JavaScript library for Groth16 proof generation and verification. It powers Tornado Cash forks, identity protocols, and dozens of DeFi privacy tools. In February 2025, researchers discovered a devastating bug: the generated Groth16 verifier contracts checked public signals against the base field size (q) instead of the scalar field size (r).
Why q ≠ r Matters
In BN254 (the most common pairing curve for Ethereum ZK systems):
-
Base field
q= 21888242871839275222246405745257275088696311157297823662689037894645226208583 -
Scalar field
r= 21888242871839275222246405745257275088548364400416034343698204186575808495617
Notice: q > r. The difference is small but critical. A public signal value v where r ≤ v < q passes the verifier's range check but wraps around in the proof arithmetic (which operates mod r). This means:
v ≡ v - r (mod r)
Two different input values produce the same proof. That's input aliasing.
The Exploit: Double-Spending in Privacy Protocols
In a Tornado Cash-style mixer:
- Deposit with nullifier
n - Withdraw by proving knowledge of
nand submittingnullifierHash - The contract records
nullifierHashto prevent double-withdrawal
With input aliasing:
- Deposit with nullifier
nwherenullifierHash = H(n) - First withdrawal: submit
nullifierHashnormally - Second withdrawal: submit
nullifierHash' = nullifierHash + r - The verifier accepts because
nullifierHash' < q✓ - The proof verifies because
nullifierHash' ≡ nullifierHash (mod r)✓ - The contract sees a new nullifier hash, so no double-spend detected ✓
Funds withdrawn twice from a single deposit.
The Fix
// VULNERABLE (snarkjs generated)
require(input[i] < q, "Input not in field");
// FIXED
require(input[i] < r, "Input not in scalar field");
One character. q → r. That's the entire fix.
Who's Affected?
Every project that:
- Used
snarkjsto generate Groth16 verifier contracts before the patch - Did not add application-level nullifier range checks
- Uses BN254 (which is virtually all Ethereum ZK projects)
Audit your verifier contracts. Search for the field constant. If it's q, you're vulnerable.
Groth16 Proof Malleability: When One Valid Proof Becomes Infinite
The Math
Groth16 verification checks a pairing equation:
e(A, B) = e(α, β) · e(L, γ) · e(C, δ)
The vulnerability: given a valid proof (A, B, C), an attacker can produce another valid proof (A', B', C') by negating certain elements:
A' = -A
B' = -B
C' remains valid because e(-A, -B) = e(A, B)
The pairing product doesn't change because negating both inputs to a bilinear pairing preserves the output.
Real-World Impact
Proof malleability breaks any system that:
- Uses the proof itself as a unique identifier
- Stores proof hashes to prevent replay
- Assumes one witness produces exactly one proof
This includes:
- Airdrop claims where proof hash = claim identifier
- Voting systems where proof uniqueness = vote uniqueness
- Bridge attestations where proof = cross-chain message authentication
Defense: Nullifier-Based Uniqueness
Never use the proof as an identifier. Use a nullifier derived from the private input:
// WRONG: Using proof hash as unique identifier
bytes32 claimId = keccak256(abi.encode(proof));
require(!claimed[claimId], "Already claimed");
claimed[claimId] = true;
// RIGHT: Using nullifier from circuit output
uint256 nullifier = publicSignals[0]; // Deterministic from private input
require(!nullifiers[nullifier], "Already claimed");
nullifiers[nullifier] = true;
The Compound Risk: When Vulnerabilities Stack
These three vulnerability classes don't exist in isolation. A realistic attack chain:
- Input aliasing creates two valid nullifier values for one deposit
- Proof malleability generates multiple valid proofs per nullifier
- Frozen Heart allows forging proofs for nullifiers the attacker never deposited
Combined, an attacker could drain a privacy pool with zero initial capital.
The zkSync Lesson (April 2025)
While not a ZK verification flaw per se, the zkSync airdrop contract exploit ($5M in ZK tokens minted via a leaked admin key calling sweepUnclaimed()) demonstrates that ZK systems fail at every layer — not just the cryptography. The access control around proof verification is as critical as the verification itself.
The ZK Verification Audit Checklist
For any project deploying ZK proofs on-chain:
1. Fiat-Shamir Transcript Completeness
- [ ] All public inputs included in challenge hash
- [ ] All intermediate commitments included
- [ ] Domain separator prevents cross-protocol replay
- [ ] Challenge is computed after all prover messages
2. Field Boundary Validation
- [ ] Public signals checked against scalar field
r, not base fieldq - [ ] Proof elements (A, B, C) checked against base field
q - [ ] No arithmetic overflow in field operations
- [ ] Edge case: zero values handled correctly
3. Proof Uniqueness
- [ ] Nullifier-based deduplication (not proof-hash-based)
- [ ] Nullifier range check:
nullifier < r - [ ] Nullifier derived deterministically from private inputs in circuit
- [ ] Spent nullifier set is append-only
4. Circuit Constraints
- [ ] No underconstrained signals (every wire has a constraint)
- [ ] Public inputs are actually constrained (not just declared)
- [ ] Circuit output matches expected computation
- [ ] Trusted setup ceremony completed correctly (Phase 2 for Groth16)
5. Contract-Level Security
- [ ] Verifier contract matches deployed circuit VK
- [ ] No admin key can bypass verification
- [ ] Upgrade mechanism requires timelock + multisig
- [ ] Emergency pause doesn't skip proof checks on resume
Tools for ZK Verification Auditing
| Tool | What It Catches | Language |
|---|---|---|
| circomspect | Underconstrained signals, unused variables | Circom |
| ecne | Circuit equivalence checking | Circom |
| Picus | Underconstrained circuit detection | Circom/R1CS |
| halo2-analyzer | Constraint system analysis | Halo2/Rust |
| zkbugs | Known vulnerability patterns | Multi |
None of these catch Frozen Heart or input aliasing automatically. Those require manual review of the Fiat-Shamir implementation and verifier contract field constants.
Conclusion
ZK proofs are not magic. They're math implemented in code, and code has bugs. The gap between "mathematically sound" and "securely implemented" is where hundreds of millions of dollars live.
The three vulnerability classes covered here — Frozen Heart (transcript incompleteness), input aliasing (field confusion), and proof malleability (pairing symmetry) — share a common root cause: the assumption that getting the math right means getting the implementation right.
It doesn't.
Every ZK system in production needs:
- A verifier contract audit separate from the circuit audit
- Field constant verification (
rvsq) - Nullifier-based deduplication
- Fiat-Shamir transcript completeness review
- Continuous monitoring for proof malleability patterns
The next nine-figure DeFi exploit won't be a reentrancy bug. It'll be a one-character field constant in a ZK verifier that nobody checked.
References:
- Trail of Bits: Frozen Heart vulnerability disclosures (PlonK, Bulletproofs, Girault)
- Galxe: ZKP Loophole — snarkjs input aliasing analysis
- Beosin: In-depth analysis of zk-SNARK input aliasing vulnerabilities
- zksecurity.xyz: Groth16 setup exploit writeup
- Halborn: Faulty proof validation in ZKPs
Top comments (0)