The ZK Circuit Kill Chain: 7 Zero-Knowledge Proof Vulnerabilities That Have Cost DeFi Over $200M — And How to Audit for Each One

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    The ZK Circuit Kill Chain: 7 Zero-Knowledge Proof Vulnerabilities That Have Cost DeFi Over $200M — And How to Audit for Each One

    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; //
    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 --inspect to 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] 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:

    1. Fork a ZK protocol
    2. Reuse the original trusted setup parameters (or run a ceremony with 3 team members)
    3. One insider retains the toxic waste
    4. 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 .ptau and .zkey files 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: 21888242871839275222246405745257275088548364400416 034343698204186575808495617

    // VULNERABLE: Range check that doesn't account for field wrap
    template RangeCheck(n) {
    signal input in;
    // This only checks in // p - small_number which is HUGE but wraps to negative
    component bits = Num2Bits(n);
    bits.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 // Then verify in // by checking the value is in the expected range
    component lt = LessThan(252);
    lt.in[0] lt.in[1] lt.out === 1;
    }







    How to audit:
    • Identify every arithmetic operation and check for field-boundary behavior
    • Test with values near p - 1, p/2, and 0
    • 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] nullifier }

    // 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] hasher.inputs[1] hasher.inputs[2] nullifier }







    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:


    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:

    1. They're invisible. A compromised verifier looks exactly like a working one until someone forges a proof.
    2. They're irreversible. Once a forged proof is accepted on-chain, there's no revert mechanism.
    3. 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






    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.




    More...
Working...