A zero-knowledge proof system makes one promise: if the verifier accepts, then the prover knew a witness that satisfies the circuit. An attacker reading a ZK application does not try to break the mathematics behind that promise. They look for the gap between the circuit the developers think they wrote and the set of constraints the proving system actually enforces, because every witness that satisfies the constraints but not the intent is a valid proof of something false.
The failure profile differs from ordinary smart contracts. A Solidity bug usually leaks a bounded amount in a visible transaction. A soundness bug lets an attacker prove arbitrary statements, silently, with proofs that look identical to honest ones, until the funds are gone or the chain's state is irreversibly wrong.
This article is for teams building rollups, bridges, privacy protocols and identity systems on ZK circuits, and for the auditors who review them. We cover where circuits break, why the breaks are hard to see, and how we test for them.
Witness computation is not constraint generation
Every circuit framework has two layers that look like one. The witness generator is ordinary code that computes every signal from the inputs. The constraint system is the set of equations the prover must satisfy. The honest prover runs the witness generator and the constraints hold. The malicious prover ignores the witness generator, chooses whatever signal values they like, and only needs the constraints to hold.
This is the root of most circuit bugs. A developer writes what looks like an assignment: compute out as the integer division of a by b. In the witness layer it is exactly that. In the constraint layer there may be no equation relating out to a and b at all. The witness generator always produces a correct out; the attacker produces any out they want.
In Circom this is the difference between the assignment-only arrow operator and the assign-and-constrain arrow operator; a signal set with the former and never constrained is a free variable. Frameworks that hide the distinction behind a higher-level language have the same gap wherever the compiler emits a hint or unchecked witness value. The audit question is the same: for every signal, which constraints bind it, and do they bind it to only the intended value?
Under-constrained circuits
An under-constrained circuit accepts more witnesses than the specification allows. This is the dominant class of ZK vulnerability and it has several recurring shapes.
Unconstrained intermediate signals
A signal is computed, used, and never tied to its inputs. A hash output assigned from witness computation but not constrained to equal the hash of the preimage lets the prover claim any preimage hashes to any value. A nullifier not constrained to derive from the secret lets the prover spend one note with many nullifiers.
One-directional constraints
Division is the standard example: constraining out * b == a fixes the quotient when b is non-zero, but when b is zero, out can be anything. Square roots, modular inverses and bit decompositions have similar edge cases where a constraint that looks complete has a degenerate solution.
Missing equality between components
One gadget verifies a Merkle path; another computes a leaf; a third checks a signature. If the leaf output of the second is not constrained to equal the leaf input of the first, the prover presents a valid path for one leaf and a valid signature for another. Composition wiring is where under-constraint hides in large circuits: each gadget is correct in isolation and the bug is in the line that should connect them.
Comparison and selection gadgets
A less-than check via bit decomposition is only sound if the inputs are constrained to fit the assumed bit width. A multiplexer is only sound if its selector is constrained to zero or one; a selector of two, in a field, selects something the developer never intended.
The defence is systematic: for each signal, document the constraints that bind it; for each gadget, document the assumptions it makes about its inputs and confirm the caller enforces them. We do this as a table during audit, and the empty rows are the findings.
Missing range and type checks
Circuits operate over a finite field. Nothing in the field knows what a byte, a 64-bit integer or a boolean is. Those are conventions the circuit must enforce with explicit constraints, and where it does not, arithmetic silently wraps.
Field overflow
Adding two values the developer thinks of as 64-bit integers produces a field element that may not fit in 64 bits. If the circuit then compares it or passes it to a gadget that assumes a bounded input, the result is wrong without any constraint failing. An attacker who can wrap a balance past the field modulus and back to a small number has printed money.
Aliasing in bit decomposition
A value decomposed into as many bits as the field has can have more than one valid decomposition, because the bits can represent the value plus the modulus. Range-checking a decomposition without constraining it to the canonical one leaves an alias the prover can choose.
Booleans that are not booleans
Any signal used as a flag needs the constraint that it equals its own square. Without it, a selector or an enable bit can take any field value, and the gadget it controls does something undefined.
The rule: every circuit input and every value crossing a gadget boundary gets an explicit range constraint matching the type the next consumer assumes. If the check is expensive, that is a cost to pay, not a check to skip.
Fiat-Shamir and the frozen heart class
Non-interactive proof systems replace the verifier's random challenges with a hash of the transcript, and the security of that replacement depends entirely on what goes into the hash. If any value the prover commits to is left out before the challenge is derived, the prover can choose it after seeing the challenge, and the system is no longer sound.
This is the "frozen heart" class of bugs, found across several widely used implementations: Fiat-Shamir transforms that omitted public inputs, commitments or earlier challenges from the hash. The fix is the same in every case: hash everything the verifier would have seen at that point in the interactive protocol, with unambiguous domain separation.
We read the transcript construction in both prover and verifier line by line, write down every value the interactive protocol would have fixed before each challenge, and confirm each is absorbed before that challenge is squeezed. A transcript built differently on the two sides is a related finding: either honest proofs fail or the verifier checks something weaker than intended.
Public input handling
The circuit proves a statement about its public inputs. The verifier contract supplies those inputs. If the two disagree about what the inputs mean, the proof is sound and the system is still broken.
The common failures: inputs passed in a different order from the circuit's declaration; an input that should be fixed, such as a Merkle root or chain ID, supplied by the caller rather than read from contract state; inputs hashed into one field element differently in the circuit and the contract; and unreduced inputs.
The last deserves emphasis. If the verifier accepts any uint256 and the pairing check reduces it modulo the field prime, an input of x and an input of x + p are the same to the proof and different to the contract. A contract that records the unreduced value as "the nullifier that was spent" accepts a second spend with the alias. Every public input must be checked to be less than the field modulus before it reaches the verifier.
Verifier contract bugs
The on-chain verifier is a smart contract and inherits every issue in the 2026 smart contract audit checklist, plus some of its own.
Proof malleability
Some proof systems admit more than one valid encoding of the same proof. If the contract uses the proof bytes or their hash as a replay key, a malleated version of a used proof is accepted as new. Replay protection must key on a value the circuit binds, such as a nullifier among the public inputs, never on the proof itself.
Missing input binding
The verifier confirms the proof for the supplied public inputs, and the contract then acts on values it never passed to the verifier. A withdrawal contract that sends funds to a recipient argument that was not a public input lets anyone front-run an honest proof with their own recipient. Every value the contract acts on must be a public input the proof binds, or derived deterministically from one.
Verifying keys and trusted-setup artefacts
If the stored verifying key can be changed by an unprivileged path, or does not match the circuit the team believes was deployed, every proof check is meaningless. We confirm the on-chain key against one regenerated from the published circuit and setup, not against the team's documentation.
Pairing check edge cases
Verifier contracts that hand-roll the pairing precompile call must check the precompile's return status and must reject points not on the curve or in the wrong subgroup. A verifier that treats a failed precompile call as a successful check has been seen in the wild.
Trusted setup and randomness
Proof systems with a trusted setup produce a reference string whose toxic waste, if retained by anyone, allows forging proofs. Multi-party ceremonies reduce this to "at least one participant was honest". The audit questions are whether the ceremony happened for this exact circuit, whether the final artefacts match the published transcript, and whether a circuit-specific phase was run after the universal one. A circuit modified after its setup, even trivially, has a setup that no longer corresponds to it.
Randomness also enters at proving time. Blinding factors must come from a secure source; a prover that reuses blinding across proofs or derives it from the witness leaks the witness through the proofs it publishes. For privacy applications that is a complete break even when soundness holds. Transparent systems remove the ceremony but not the randomness requirement, and shift trust to the hash function and the Fiat-Shamir transform.
Soundness is binary
There is no mostly sound circuit. One free signal, one unconstrained edge case, one omitted transcript element, and an attacker can prove any statement the public interface allows. Because forged proofs are indistinguishable from honest ones, the first sign of exploitation is usually the consequence: unbacked withdrawals, a double spend, an invalid state root accepted by a rollup.
A protocol with a Solidity bug can often pause, patch and resume. A protocol with a soundness bug may already have false proofs in its canonical state, and unwinding them may be impossible. The engineering response is defence in depth around the circuit: rate limits on what one proof can authorise, delays on high-value actions, independent re-execution where feasible, and monitoring for transitions that are valid by proof but implausible by any other measure.
Circuit upgrade governance
Each circuit change needs a new verifying key and, for some systems, a new setup. Whoever can change the verifying key can replace it with one for a circuit that accepts anything, so the upgrade path has the same blast radius as the proof system itself.
We review it like any admin path: who holds the key, what delay applies, whether the new circuit's source and setup transcript are published before activation, and whether watchers can verify that the on-chain key matches the published circuit. A rollup whose verifying key can be changed by one externally owned account with no delay has the trust model of that account.
How we test circuits
- Constraint-by-signal review: we build a table of every signal, the constraints that bind it, and the assumptions each gadget makes about its inputs. Empty rows and unmet assumptions are findings.
- Witness fuzzing: we take the witness for valid inputs, mutate individual signals, and attempt to prove. Any mutated witness that proves is an under-constraint. This is our most productive automated technique for Circom and similar frameworks.
- Constraint counting: a circuit whose constraint count does not change when a supposedly constrained operation is added, or is suspiciously low for its gadgets, has operations happening only in the witness layer.
- Formal and static tools: we run the available under-constraint detectors on every engagement. They find a useful subset of missing constraints and do not find composition bugs or specification mismatches.
- Transcript review: every Fiat-Shamir challenge, every absorbed value, on both prover and verifier.
- Verifier contract review: public input ordering and reduction, replay keys, input binding, verifying key provenance, precompile return handling.
- Setup verification: we regenerate the verifying key from the published circuit and ceremony output and compare it to what is on-chain.
High-severity findings come with a forged proof against the deployed verifier on a fork, because for soundness bugs there is no other credible evidence.
Next steps
If you ship a circuit, start with the signal table: it takes a day for a medium circuit and finds more than any tool. Then confirm your transcript absorbs every public input, check every public input is reduced before verification, and regenerate your verifying key from source. If you build a bridge on ZK proofs, read how cross-chain bridges get drained for the surrounding controls, because a sound circuit behind an unlimited mint is still a single point of failure.
Our ZK audit scope is on the services page, and you can reach the team on Telegram.