← Back to Blog

ZK Circuit Security: Under-Constrained Circuits, Groth16 Pitfalls, and Verifier Contract Vulnerabilities

2026-04-18 zk zero-knowledge groth16 snark circuit verifier security solidity

Zero-knowledge proofs have moved from academic novelty to production infrastructure. Tornado Cash processed hundreds of millions of dollars using Groth16 proofs. zkSync, Polygon zkEVM, and StarkNet settle billions of dollars of Ethereum transactions using ZK-SNARKs as their correctness backbone. The cryptographic primitives are sound. The implementations are not always.

ZK systems introduce three distinct attack surfaces that do not exist in traditional smart contracts. The first is the circuit itself: the arithmetic constraint system that defines what the prover must demonstrate. If a circuit fails to constrain a variable, an attacker can satisfy the circuit with a value that violates application logic. The second is the verifier contract: the on-chain Solidity code that checks the cryptographic proof. Verifier contracts are often auto-generated and rarely audited for application-level correctness. The third is the application logic layer: how nullifiers are constructed, how proofs are identified, and how public inputs flow between off-chain provers and on-chain contracts.

This post covers six concrete vulnerabilities across all three layers, with the circuit math, the vulnerable code, and the fix for each.


1. Under-Constrained Circuits (Missing Range Checks)

A ZK circuit proves that a prover knows some secret witness satisfying a set of polynomial constraints over a finite field. The field used by most production SNARKs — BN254 — has a prime modulus p of roughly 2^254. When you write a circuit to check age >= 18, you are checking that some field element satisfies that inequality. If you do not also constrain the field element to the range [0, 255], the prover can submit a value like p - 5, which when interpreted as a signed integer is negative, but as a field element is large and passes the >= 18 check.

Vulnerable Circom:

template AgeCheck() {
    signal input age;
    signal output valid;

    // VULNERABLE: only checks >= 18, not that age fits in uint8
    component gte = GreaterEqThan(8);
    gte.in[0] <== age;
    gte.in[1] <== 18;
    valid <== gte.out;
}

An attacker sets age = p - 5 (a valid field element). GreaterEqThan uses bit decomposition internally, but if the bit width is too small to represent p - 5, the decomposition overflows and the constraint is trivially satisfied.

Fix: Add an explicit range check before the comparison.

template AgeCheck() {
    signal input age;
    signal output valid;

    // Constrain age to [0, 255] first
    component rangeCheck = Num2Bits(8);
    rangeCheck.in <== age;

    component gte = GreaterEqThan(8);
    gte.in[0] <== age;
    gte.in[1] <== 18;
    valid <== gte.out;
}

Num2Bits(8) enforces that age decomposes into exactly 8 bits, ruling out field elements outside [0, 255].


2. Trusted Setup Ceremony Compromise (Groth16 Toxic Waste)

Groth16 requires a trusted setup that produces structured reference strings (SRS) for both proving and verification. The setup procedure generates secret scalars — collectively called "toxic waste" — that must be destroyed. If any participant retains the toxic waste, they can forge arbitrary valid proofs without knowing any valid witness.

Algebraically, Groth16 verification checks a pairing equation:

e(A, B) = e(α, β) · e(Σ public_inputs · γ_i, γ) · e(C, δ)

The toxic waste includes the scalars α, β, γ, δ and their powers. Given δ and δ^{-1}, an attacker can construct a C term that satisfies the pairing equation for any A and B they choose, producing a valid-looking proof for a false statement.

The mitigation is a multi-party computation (MPC) ceremony. The Zcash Sprout ceremony (2016) used six participants with the property that the toxic waste is destroyed as long as at least one participant honestly destroys their portion. The ceremony transcript is public so anyone can verify participation. For application-specific circuits (phase 2 of the Powers of Tau), every new circuit needs its own MPC ceremony or must use an existing trusted accumulation like Hermez's ceremony.

Production requirement: Never use a circuit in production whose phase 2 ceremony was conducted by a single party or a team with a single point of failure. The ceremony transcript must be publicly verifiable.


3. Weak Fiat-Shamir Transform (Frozen Heart Vulnerability)

Interactive Sigma protocols are made non-interactive using the Fiat-Shamir heuristic: replace the verifier's random challenge with a hash of the proof transcript. The security of this transformation depends critically on what is included in the hash. The Frozen Heart vulnerability (discovered by TrailOfBits in 2022, affecting Bulletproofs implementations in several production libraries) occurs when public inputs are omitted from the transcript hash.

In a correct Fiat-Shamir transform:

challenge = Hash(A, public_inputs, commitment)

In a vulnerable implementation:

challenge = Hash(A, commitment)  // public_inputs omitted

When public inputs are excluded, a malicious prover can choose the commitment A after observing the challenge, effectively reversing the causality that makes the proof binding. More precisely: the prover can compute the challenge for any commitment they construct, then backsolve for a commitment that makes the proof verify. This breaks soundness entirely for the affected public inputs.

Fix: All public inputs must be included in every hash that produces a Fiat-Shamir challenge. In Circom/SnarkJS, use poseidon or sha256 and explicitly feed all public signals into the transcript before generating the challenge. When reviewing or auditing a Fiat-Shamir implementation, enumerate every public input and verify each appears in the hash preimage.


4. Verifier Contract Missing Public Input Validation

Auto-generated Groth16 verifier contracts (produced by SnarkJS's snarkjs zkey export solidityverifier) check the pairing equation correctly but do not always validate that submitted public inputs are within the scalar field. BN254's scalar field has order r ≈ 2^254. A uint256 can hold values up to 2^256 - 1. Values in the range [r, 2^256 - 1] will wrap around modulo r inside the verifier's arithmetic, potentially aliasing to a valid public input from a legitimate proof.

Vulnerable Solidity:

function verifyProof(
    uint[2] memory a,
    uint[2][2] memory b,
    uint[2] memory c,
    uint[] memory publicInputs
) public view returns (bool) {
    // VULNERABLE: no check that publicInputs[i] < FIELD_MODULUS
    return _verifyProof(a, b, c, publicInputs);
}

Fix:

uint256 constant FIELD_MODULUS =
    21888242871839275222246405745257275088548364400416034343698204186575808495617;

function verifyProof(
    uint[2] memory a,
    uint[2][2] memory b,
    uint[2] memory c,
    uint[] memory publicInputs
) public view returns (bool) {
    for (uint i = 0; i < publicInputs.length; i++) {
        require(publicInputs[i] < FIELD_MODULUS, "Input out of field");
    }
    return _verifyProof(a, b, c, publicInputs);
}

This check costs roughly 300 gas per input and prevents field aliasing attacks. The SnarkJS-generated verifier has included this check since late 2023, but older deployments and custom verifiers frequently omit it.


5. Proof Malleability (Groth16 Rerandomization)

Groth16 proofs are malleable. Given any valid proof (A, B, C) for a statement, an attacker can compute a distinct but equally valid proof for the same statement without knowing the witness. Specifically, for any random scalar r:

A' = r · A
B' = r^{-1} · B
C' = C  (with additional cross-terms depending on the rerandomization)

The pairing equation still holds because e(r·A, r^{-1}·B) = e(A, B). The resulting proof (A', B', C') is byte-for-byte different from the original, verifies against the same public inputs, but is indistinguishable from a fresh proof to the verifier contract.

Any system that uses the proof bytes as a unique identifier — to prevent double-spending, to track whether a specific user has already claimed a reward, or to detect replays — is vulnerable.

Vulnerable pattern:

mapping(bytes32 => bool) public usedProofs;

function claim(bytes calldata proof, uint[] calldata inputs) external {
    bytes32 proofHash = keccak256(proof);
    require(!usedProofs[proofHash], "Proof already used");
    usedProofs[proofHash] = true;
    // ... process claim
}

An attacker rerandomizes the proof, obtains a different proofHash, and calls claim again.

Fix: Never use proof bytes as unique identifiers. Instead, include a nullifier or commitment in the public inputs that is deterministically derived from the private witness. Track the nullifier, not the proof.

mapping(bytes32 => bool) public usedNullifiers;

function claim(bytes calldata proof, uint nullifier, uint[] calldata inputs) external {
    require(!usedNullifiers[bytes32(nullifier)], "Nullifier already used");
    require(verifyProof(proof, inputs), "Invalid proof");
    usedNullifiers[bytes32(nullifier)] = true;
}

6. Nullifier Reuse in UTXO Systems

ZK-based privacy protocols like Tornado Cash use nullifiers to prevent double-spending. When a user deposits a note, they commit to a secret. When they withdraw, they reveal a nullifier derived from that secret — but the circuit must enforce that the nullifier is computed correctly. If the circuit allows the prover to supply an arbitrary nullifier that is not constrained to be a specific function of the committed secret, the same note can be withdrawn multiple times with different nullifiers.

Vulnerable circuit (pseudocode):

template Withdraw() {
    signal private input secret;
    signal private input nullifier;   // VULNERABLE: not constrained
    signal input commitment;
    signal input root;

    // Check commitment = Hash(secret)
    component h = Poseidon(1);
    h.inputs[0] <== secret;
    commitment === h.out;

    // nullifier is never tied to secret — attacker can use any value
}

Because nullifier is never constrained relative to secret, a prover can construct multiple valid proofs for the same commitment using different nullifier values, each passing the on-chain nullifier check.

Fix: The circuit must enforce the nullifier derivation.

template Withdraw() {
    signal private input secret;
    signal input nullifier;           // now a public input
    signal input commitment;
    signal input root;

    // Enforce commitment
    component h1 = Poseidon(1);
    h1.inputs[0] <== secret;
    commitment === h1.out;

    // Enforce nullifier = Hash(secret, 0) — ties nullifier to secret
    component h2 = Poseidon(2);
    h2.inputs[0] <== secret;
    h2.inputs[1] <== 0;
    nullifier === h2.out;
}

The nullifier is now a public signal whose value is proven to equal Poseidon(secret, 0). An attacker cannot produce a different nullifier for the same commitment because the circuit constraint is enforced by the SNARK itself.


What ContractScan Detects

ContractScan analyzes on-chain verifier contracts and off-chain circuit artifacts for ZK-specific vulnerabilities. Static analysis of the Solidity verifier layer catches the majority of high-severity issues without requiring the full circuit source.

Vulnerability Detection Method Severity
Missing public input range check Pattern match: verifyProof function lacks < FIELD_MODULUS guard on each input Critical
Proof bytes used as unique ID Data-flow: keccak256(proof) result stored in mapping used as spend guard High
Nullifier not in public inputs ABI inspection: withdrawal function signature missing nullifier as separate parameter High
Ceremony not multi-party Metadata check: SRS provenance absent or single-party in contract comments/deployment docs High
Missing Fiat-Shamir input coverage Signature analysis: custom verifier hashes that exclude known public input parameters High
Field aliasing in pairing inputs Data-flow: uint256 inputs passed to pairing precompile without modular bound check Critical

For circuit-level vulnerabilities (under-constrained signals, nullifier derivation), ContractScan flags verifier contracts whose public input count does not match the expected circuit interface, surfacing mismatches that often indicate a circuit change was deployed without updating the constraint set.


Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →