Zero-knowledge proof systems have moved from academic curiosity to production infrastructure. Rollups settle billions of dollars of value through ZK proofs. Identity protocols, private voting systems, and anonymous credential schemes all rely on the same foundational primitive: a circuit that encodes a computation, and a proof system that convinces a verifier the computation was performed correctly without revealing its inputs.
The security surface of these systems is unlike anything in classical software engineering. There is no runtime, no call stack, no memory to overflow. The “program” is a set of polynomial constraints over a finite field, and correctness means satisfying those constraints. When a circuit is wrong, it is wrong mathematically — and no firewall, audit trail, or access control list can compensate for a constraint that was never written.
This article covers the full landscape of ZK circuit vulnerabilities: what constraint systems are, how under- and over-constraining break them in opposite directions, the difference between soundness and zero-knowledge failures, witness generation as an attack surface, and the patterns that appear repeatedly across real circuit codebases. It closes with a practical security checklist for teams building or auditing ZK systems.
Constraint Systems and the Definition of Correctness
A ZK circuit does not compute a function in the traditional sense. It defines a relation — a set of valid input/output pairs — by encoding that relation as a system of polynomial equations over a finite field F_p.
The most common constraint system in production today is Rank-1 Constraint System (R1CS). Every constraint in R1CS takes the form:
(A · w) * (B · w) = (C · w)
where w is the witness vector (containing all private and public inputs plus intermediate values), and A, B, C are coefficient matrices. A valid witness is one that satisfies every constraint in the system simultaneously.
Plonkish arithmetization, used by systems like Halo 2 and UltraPlonk, generalizes this to custom gates:
q_L · a + q_R · b + q_M · a·b + q_O · c + q_C = 0
where q_L, q_R, q_M, q_O, q_C are selector polynomials and a, b, c are wire values. Copy constraints (permutation arguments) enforce that a wire value used in one gate equals a wire value used in another.
AIR (Algebraic Intermediate Representation), used by STARKs, expresses constraints as polynomial identities between rows of an execution trace.
Regardless of arithmetization, the definition of correctness is always the same:
The circuit is correct if and only if the set of witnesses that satisfy all constraints is exactly the set of witnesses corresponding to valid executions of the intended computation.
This single sentence contains two failure modes. If too many witnesses satisfy the constraints, the circuit is underconstrained — it accepts invalid proofs. If too few witnesses satisfy the constraints, the circuit is overconstrained — it rejects valid proofs.
Underconstrained Circuits: Invalid Proofs Accepted
An underconstrained circuit is the more dangerous failure. It means a malicious prover can construct a witness that satisfies all constraints but does not correspond to any honest execution of the computation. The verifier accepts the proof. The system has been broken at the mathematical level.
A Simple Example
Consider a circuit intended to prove knowledge of a value x such that x² = y for a public y.
// Intended: prove x such that x*x = y
signal input x;
signal input y;
// BUGGY: only one constraint
x * x === y;
This looks correct, but consider a more complex circuit where x feeds into further logic:
signal input x;
signal private_bit;
signal output result;
// Developer intended: result = x if private_bit=1, else 0
// BUGGY: missing boolean constraint on private_bit
result <== x * private_bit;
The developer assumes private_bit ∈ {0, 1}, but that assumption is never enforced. A malicious prover can set private_bit to any field element. If x = 5 and private_bit = 3, then result = 15, which the circuit happily accepts. The missing constraint is:
// REQUIRED boolean check
private_bit * (private_bit - 1) === 0;
This is arguably the single most common ZK circuit vulnerability: a value is assumed to be boolean, but the assumption is never expressed as a constraint.
Arithmetic Over a Prime Field
The subtlety deepens because all arithmetic happens modulo a large prime p. Consider:
signal input n;
// Intended: n < 2^64
// BUGGY: no range constraint
In a 254-bit field (as used by BN254), n could be p - 1, which is approximately 2^254. Any downstream logic that treats n as a 64-bit integer is operating on a wildly out-of-range value, but the circuit has no way of knowing — because it was never told.
Overconstrained Circuits: Completeness Failures
Overconstraining is the mirror-image failure. The circuit is too strict — it rejects witnesses that correspond to valid executions. This is a completeness failure: honest provers cannot generate valid proofs for true statements.
Overconstraining does not directly enable an attacker to forge proofs, but it has serious security implications:
- Denial of service: a system that cannot produce proofs for valid states is broken in production.
- Hidden soundness bugs: developers who discover that valid inputs are rejected sometimes loosen constraints without fully understanding which ones to remove, accidentally creating an underconstrained circuit in the process.
- Specification drift: the implemented circuit no longer matches the specification, meaning audits of the specification do not apply to the implementation.
Example: Overconstraining a Hash Preimage
signal input preimage[4];
signal input hash[2];
// BUGGY: enforces a specific intermediate decomposition
// that is only achievable via one particular witness ordering
component hasher = Poseidon(4);
hasher.inputs[0] <== preimage[0];
hasher.inputs[1] <== preimage[1];
hasher.inputs[2] <== preimage[2];
hasher.inputs[3] <== preimage[3];
// Extra constraint that was added "for safety"
// but rules out legitimate witnesses
hasher.inputs[0] * hasher.inputs[1] === preimage[2]; // WRONG
hash[0] === hasher.out[0];
hash[1] === hasher.out[1];
The spurious multiplicative constraint eliminates all witnesses where preimage[0] * preimage[1] ≠ preimage[2], which is almost all of them.
Soundness vs. Zero-Knowledge Failures
These are two distinct security properties, and they fail in completely different ways.
Soundness
A proof system is sound if no computationally bounded prover can produce a valid proof for a false statement, except with negligible probability. Soundness is a property of the proof system relative to the circuit. If the circuit does not correctly encode the intended relation, soundness of the underlying proof system is irrelevant — the proof system faithfully proves something, just not what you intended.
Soundness failures in circuits occur when:
- Constraints are missing (the relation is too broad)
- Constraints use the wrong signals (encoding a different relation)
- Constraints are satisfiable by unintended witnesses (field arithmetic surprises)
Zero-Knowledge
A proof system has the zero-knowledge property if the proof reveals nothing about the witness beyond what is implied by the public inputs and the statement being proved. Zero-knowledge is a property of the proof system, but circuits can undermine it by leaking information through their structure.
Zero-knowledge failures in circuits occur when:
- A signal that should be private is accidentally exposed as a public input
- The circuit has a unique witness for each public input (determinism leaks the witness)
- Auxiliary outputs reveal too much about internal state
The Critical Distinction
A soundness failure means the system accepts lies. A zero-knowledge failure means the system reveals secrets. Both are catastrophic, but they require entirely different mitigations and different testing strategies.
A circuit can be perfectly sound (no invalid proof is ever accepted) while completely failing zero-knowledge (every proof reveals the private input). Conversely, a circuit can be zero-knowledge (proofs reveal nothing) while being unsound (invalid proofs are accepted).
When auditing a circuit, these two properties must be analyzed independently.
Witness Generation Security
Witness generation — computing the full witness vector from inputs — is often treated as an implementation detail separate from the circuit’s mathematical definition. This is a mistake. Witness generation code is security-critical for at least three reasons.
Non-Deterministic Witness Generation
Most constraint systems allow multiple valid witnesses for a given public input. The circuit defines which witnesses are valid, but the witness generator must produce a specific one. If the witness generator is non-deterministic across platforms, implementations, or versions, the system can exhibit inconsistent behavior — including scenarios where a modified witness generator is substituted to produce witnesses that satisfy constraints but violate application invariants.
// Circuit: proves that out is a square root of inp
signal input inp;
signal output out;
out * out === inp; // CORRECT constraint
// Witness generator (pseudocode):
// Both sqrt(inp) and -sqrt(inp) satisfy the constraint.
// If the generator is unspecified, different implementations
// produce different witnesses, enabling substitution attacks.
The fix is to add a canonicalization constraint — for example, requiring that out is in the lower half of the field:
// Additional: enforce canonical square root
component lt = LessThan(254);
lt.in[0] <== out;
lt.in[1] <== (p - 1) / 2;
lt.out === 1;
Trusted Setup Artifacts
In pairing-based proof systems using a trusted setup (Groth16, older PLONK variants), the toxic waste from the setup ceremony must be provably destroyed. If a participant in the setup ceremony retains the toxic waste, they can forge proofs for any statement the circuit can express — bypassing soundness entirely. This is not a circuit design flaw per se, but it means that circuit-level soundness is conditional on the security of the setup.
Prover-Side Exploits
Witness generation code runs on the prover’s machine, but in systems where provers are third parties (zkEVM sequencers, proving services), malicious witness generation can construct proofs for invalid state transitions that the circuit’s constraints would not catch — if the constraints are themselves incomplete. This makes underconstrained circuits especially dangerous in decentralized proving systems.
Common ZK Circuit Vulnerability Patterns
1. Missing Range Checks
Pattern: A signal is used in a context that requires it to be within a specific range, but no constraint enforces that range.
// BUGGY: age is used as a comparison target
// but can be any field element
signal input age;
signal input threshold;
signal output is_adult;
component lt = LessThan(8); // 8-bit comparator
lt.in[0] <== threshold;
lt.in[1] <== age;
is_adult <== lt.out;
// If age = p - 1 (a huge field element), the 8-bit
// LessThan component produces undefined behavior
// because its internal decomposition overflows.
Fix: Always range-check inputs before feeding them into comparators or arithmetic that assumes a bounded domain.
component age_check = Num2Bits(8);
age_check.in <== age; // enforces age < 2^8
component lt = LessThan(8);
lt.in[0] <== threshold;
lt.in[1] <== age;
is_adult <== lt.out;
Num2Bits(n) works by decomposing a number into n bits and verifying that they reconstruct the original number — implicitly enforcing that the original number is less than 2^n.
2. Non-Deterministic Witness (Signal Aliasing via Square Roots and Inversions)
Pattern: A circuit computes a value (like a modular inverse or square root) that may have multiple valid results, and does not constrain which result is chosen.
// BUGGY: compute modular inverse of x
signal input x;
signal output x_inv;
// Constraint: x * x_inv = 1
// This is correct IF x != 0.
// But if x = 0, there is no inverse — what happens?
x * x_inv === 1;
// A malicious prover with x = 0 cannot satisfy this
// constraint... unless the circuit has a bypass elsewhere
// that was intended for a "zero check" but was implemented
// as an independent branch without connecting the result.
The dangerous variant is when a developer adds a conditional:
signal input x;
signal input is_zero;
signal output x_inv;
// BUGGY: independent constraints, not logically connected
is_zero * x === 0; // if is_zero=1, x must be 0
(1 - is_zero) * (x * x_inv - 1) === 0; // if is_zero=0, x*x_inv=1
// Missing: is_zero must be boolean
// Missing: if x != 0, is_zero must be 0
// A prover can set is_zero=1 even when x != 0,
// bypassing the inverse constraint entirely.
Fix:
// Enforce boolean
is_zero * (is_zero - 1) === 0;
// Enforce: if x != 0, then is_zero = 0
// (Equivalently: is_zero = 1 implies x = 0)
// Additional: x * (1 - is_zero) must be provably non-zero
// when is_zero = 0. Use auxiliary signal:
signal x_or_one;
x_or_one <== x + is_zero; // if x=0 and is_zero=1, this=1; else x
x_or_one * x_inv_candidate === (1 - is_zero);
3. Signal Aliasing Across Components
Pattern: A circuit reuses a signal name in a way that a compiler interprets as a new signal, leaving the intended constraint unwritten.
In Circom, the following is a silent bug:
// BUGGY Circom pseudocode
template Aliasing() {
signal input a;
signal input b;
signal output c;
signal intermediate;
intermediate <== a * b;
// Developer intended to constrain c === intermediate
// but wrote an assignment to a NEW signal instead:
signal intermediate; // silently shadows the previous one
c <== intermediate; // c is now unconstrained relative to a*b
}
This kind of shadowing bug is particularly dangerous because the circuit compiles without error. All constraints are syntactically valid. The bug is semantic: c is no longer constrained to equal a * b.
Fix: Use a linter that detects unused signals and signals that are assigned but never constrained. In Circom specifically, every signal must appear in at least one constraint (===), not just an assignment (<==).
4. Arithmetic Over Unexpected Field Elements
Pattern: Logic that is correct over integers or booleans breaks down when evaluated in F_p.
// BUGGY: check that x is in {0, 1, 2}
// Developer thinks: (x-0)*(x-1)*(x-2) = 0 implies x is 0, 1, or 2
signal input x;
(x) * (x - 1) * (x - 2) === 0;
// In F_p, there are EXACTLY three solutions to this polynomial:
// x = 0, x = 1, x = 2.
// This is actually CORRECT for this specific case.
// BUT: the following is wrong:
// Check x < 3 using subtraction
signal diff;
diff <== 3 - x;
// Assume diff > 0 means x < 3.
// If x = 5, diff = -2 = p - 2 in F_p, which is a large positive number.
// A comparator without range checks cannot distinguish this from a valid diff.
This pattern appears frequently in circuits that mix integer semantics with field arithmetic without explicit range enforcement.
5. Incomplete Merkle Path Verification
Pattern: A Merkle tree membership proof checks hash connections but omits a leaf pre-image check.
// BUGGY Merkle verifier
template MerkleVerify(depth) {
signal input leaf;
signal input path[depth];
signal input dirs[depth]; // 0 = left, 1 = right
signal input root;
// MISSING: boolean check on dirs[i]
// A prover can set dirs[i] to a non-boolean field element,
// causing the sibling selection to behave unexpectedly.
signal current[depth + 1];
current[0] <== leaf;
for (var i = 0; i < depth; i++) {
// BUGGY: sibling selection without boolean enforcement
signal left, right;
left <== (1 - dirs[i]) * current[i] + dirs[i] * path[i];
right <== dirs[i] * current[i] + (1 - dirs[i]) * path[i];
component h = Poseidon(2);
h.inputs[0] <== left;
h.inputs[1] <== right;
current[i + 1] <== h.out;
}
root === current[depth];
}
Fix: Add dirs[i] * (dirs[i] - 1) === 0 for each i.
How Circuit Auditing Differs from Smart Contract Auditing
Smart contract auditing and ZK circuit auditing are both critical disciplines, but they operate at completely different levels of abstraction and require different skills, tools, and mental models.
Level of Abstraction
Smart contract auditing examines executable code: function calls, storage reads and writes, external calls, access control, reentrancy, integer overflow. The unit of analysis is a transaction — a sequence of operations with observable effects on EVM state.
Circuit auditing examines constraint satisfaction: whether a set of polynomial equations correctly characterizes an intended relation. There is no execution in the traditional sense. There are no loops that run, no memory that is written. The unit of analysis is a constraint — and its absence is just as significant as its presence.
What a Bug Looks Like
In a smart contract, a bug usually manifests as a wrong state transition, a theft of funds, or a denial of service — something observable on-chain. In a circuit, a bug manifests as a prover being able to satisfy all constraints with a witness that does not correspond to honest computation. The proof is valid. The verifier accepts it. Nothing in the transaction record indicates anything is wrong.
Tooling
Smart contract auditing uses tools like static analyzers (Slither, Aderyn), fuzzers (Echidna, Foundry), and formal verifiers (Certora). Circuit auditing uses tools like:
- Circomspect: static analysis for Circom circuits
- ZKAP and Picus: automated constraint analysis
- Symbolic execution over constraint systems: checking whether any witness satisfies constraints while violating a property
- Differential testing: comparing the constraint system against a reference implementation
The Specification Gap
Every circuit has an intended computation — the specification. The circuit is correct when its constraint system exactly captures that specification. Smart contract auditors can usually read the code and identify what it does. Circuit auditors must independently determine what the intended relation is, then verify that the constraints encode exactly that relation and nothing broader or narrower.
This makes specification documentation a security artifact. An undocumented circuit is almost impossible to audit correctly, because there is no ground truth to check the constraints against.
Completeness vs. Soundness Testing
In smart contracts, testing checks that correct inputs produce correct outputs. In circuits, testing must check both dimensions:
- Soundness testing: Can a malicious prover satisfy all constraints with an invalid witness? (Requires negative test cases — inputs that should not produce valid proofs.)
- Completeness testing: Can an honest prover always generate a valid proof for any valid witness? (Requires comprehensive positive test cases.)
Most teams test completeness adequately (their protocol works) but soundness inadequately (they never try to break it).
Composability Risks
Circuits are composed by connecting signals. When a sub-circuit’s output signal is fed into a parent circuit, the parent circuit is implicitly trusting that the sub-circuit correctly constrains that signal. If a library component has an underconstrained output, every circuit that uses it inherits the vulnerability. Unlike smart contracts (where a well-audited library can be imported with high confidence), circuit libraries must be re-audited in the context of each new use, because the security of a signal depends on all constraints on that signal across the entire composed system.
ZK Circuit Security Checklist
Use this checklist during circuit design, implementation, and audit.
Constraint Completeness
- Every input signal that has a bounded domain is range-checked before use
- Every signal intended to be boolean has a
x * (x - 1) === 0constraint - Every signal that feeds into a comparison or decomposition component is proven to be within the component’s supported range
- No signal is used in downstream logic without being constrained relative to its source
Witness Uniqueness and Determinism
- For every public input, the set of satisfying witnesses is well-defined and expected
- Square roots, modular inverses, and other multi-valued operations have canonicalization constraints
- The witness generator is deterministic and its output matches the intended canonical witness
- Non-determinism in witness generation is documented and analyzed for substitution attacks
Component Composition
- Every sub-circuit output that is used as an input to another component is fully constrained by that sub-circuit
- Boolean outputs from library components (e.g., comparators, hash functions) are not assumed — they are verified
- Permutation/copy constraints correctly link signals that must be equal across gates
- All library components are audited in the specific context of their use, not just in isolation
Field Arithmetic Correctness
- All arithmetic that assumes integer semantics (subtraction, division, comparison) is protected by range checks
- The circuit’s behavior at field element boundaries (
0,1,p-1,p/2) is explicitly tested - Multiplicative inversions handle the
x = 0case explicitly - No implicit assumptions about field element magnitude
Soundness Coverage
- Negative test cases exist for every constraint: test that removing each constraint allows an invalid witness
- Automated tools (Circomspect, Picus, or equivalent) have been run and findings triaged
- The circuit has been symbolically checked for under-constrained signals
- The intended relation is formally specified, and the constraint system has been checked against it
Zero-Knowledge Properties
- No private signal appears in the public input list
- Auxiliary outputs do not leak information about private witnesses
- The circuit’s witness set has sufficient entropy — a unique witness per public input is a ZK failure
- Trusted setup parameters (if applicable) were generated in a ceremony with sufficient participants and verifiable randomness destruction
Merkle and Accumulator Circuits
- Direction bits in Merkle paths are boolean-constrained
- Leaf pre-image is constrained to the intended schema
- Tree depth is fixed and enforced — variable-depth paths require explicit length constraints
- Sibling nodes cannot be confused with leaf nodes (domain separation in hashing)
Specification and Documentation
- A formal or informal specification of the intended relation exists before the circuit is written
- Every constraint in the circuit is traceable to a requirement in the specification
- Signals that are intentionally unconstrained (for flexibility) are explicitly documented with justification
- Divergences between the circuit and any reference implementation are documented
Deployment and Operational Security
- The verifier contract correctly checks all public inputs expected by the circuit
- The proof system’s security parameters (field size, hash function, security level) are appropriate for the threat model
- Upgradeability of the circuit is considered — a new circuit version may require a new trusted setup
- Fallback behavior if proof generation fails is defined and does not create unsafe states
Closing Notes
ZK circuit security is a discipline that sits at the intersection of cryptography, formal methods, and software engineering. It demands a different mental posture than classical security work: instead of asking “what can an attacker do with this code,” you must ask “what witnesses satisfy these constraints — and are any of them invalid?”
The most important insight is that constraints define correctness. A constraint that is missing is not a bug that was introduced — it is a statement of correctness that was never made. The circuit does not know what it was supposed to do; it only knows what it was told. Every security property must be expressed as a constraint, or it does not exist.
Teams building ZK systems should treat circuit auditing as a distinct engagement from smart contract auditing, staffed by reviewers who are fluent in arithmetization, polynomial constraint systems, and the specific proof system being used. They should invest in formal specifications before writing a single constraint. They should test soundness as aggressively as they test completeness. And they should remember that the mathematical guarantees of zero-knowledge proofs are only as strong as the circuit that defines what is being proved.