2026-10-14
This monthUnder-constrained circuits: the most common bug class in ZK circuits
Produced by the ZK Security research pipeline. Published only after the automated quality gate. The gate is described in the editorial policy.
A zero-knowledge circuit is two programs that must agree: the witness generator that computes values, and the constraint system the verifier actually checks. An under-constrained circuit is one where…
Under-constrained circuits: the most common bug class in ZK circuits
A zero-knowledge circuit is two programs that must agree: the witness generator that computes values, and the constraint system the verifier actually checks. An under-constrained circuit is one where the constraints admit more witnesses than the developer intended, so a prover can substitute values the honest computation would never produce and still get a valid proof. The QED² paper describes such circuits as ones that "admit multiple witnesses for a given input" (eprint 2023/512).
How common it is
The SoK What don't we know? Understanding Security Vulnerabilities in SNARKs (Chaliasos et al.) classified 141 vulnerabilities in real SNARK deployments. 99 were in the circuit layer, and 95 of those were under-constrained; only 1 was over-constrained (arXiv 2402.15293, Table 4). The authors conclude that under-constrained bugs "emerge as the most prevalent vulnerability class within ZK circuits." Their root-cause breakdown of the 95 is useful for reviewers: wrong translation of logic into constraints (32), missing input constraints (25), assigned but unconstrained (14), unsafe reuse of a sub-circuit (9), and arithmetic field errors (8). The same paper notes that it found no publicly disclosed blackhat attacks related to SNARKs, so most of these were caught by audits, researchers or the teams themselves.
The circom patterns
Assignment without constraint. Circom has two assignment operators. <== assigns and adds a constraint; <-- only assigns. The circom documentation says the safe options are <== and ==>, and that <-- and --> are "in general, dangerous" (Signals). When a non-quadratic expression forces <--, the docs say it should be paired with ===, giving a <-- b/c; a*c === b; as the pattern (Constraint Generation). The pairing is necessary but not always sufficient: Circomspect's documentation points out that c * b === a can hold when b is zero, so the divisor also needs a non-zero constraint (analysis passes).
The canonical case is circomlib's MiMC sponge. Before PR #22, merged on September 17, 2019, the template set its output with outs[0] = S[nInputs - 1].xL_out;; the fix changed = to <==. Trail of Bits explains that the older compiler treated that = like <--, so the output was never constrained, and that in Tornado.cash this meant "anyone could forge a proof of deposit and withdraw funds" (Trail of Bits, 2022-09-15). Per the same post, the Tornado.cash team drained the mixer themselves to protect users and migrated funds to a patched contract. Trail of Bits notes that the current compiler would reject that code.
Hints that are checked, but not checked enough. circomlib's Num2Bits(n) computes each bit with <-- and then constrains each bit to be 0 or 1 and their weighted sum to equal the input (bitify.circom). That sum is taken modulo the field prime. If n reaches the field size, more than one bit string can satisfy it. Circomspect's example: with Num2Bits(254) over BN254, both 1 and p + 1 decompose validly; it recommends Num2Bits_strict instead.
zkSecurity found this exact issue in Mysten Labs' zkLogin circuits (report dated September 11, 2023). NonceChecker decomposed a nonce with Num2BitsBE(256), so the bits of n + kp also passed, for k = 0 to 4 and possibly 5 (zkLogin report). It was rated Low because, in protocol context, it was not a security risk. The recommended fix was 254 bits plus a check that the value is below p.
Range assumptions on comparators. circomlib's LessThan(n) computes Num2Bits(n+1) of in[0] + (1<<n) - in[1] and reads the top bit (comparators.circom). It is only meaningful if both inputs already fit in n bits, and it does not enforce that. Circomspect gives the example that p − 254 satisfies a LessThan(8)-based "less than two" check.
Three published cases follow this pattern:
- Dark Forest v0.3. Its
RangeProofusedLessThan(bits)on unchecked inputs. The Dark Forest team's own write-up credits Daira Hopwood for noting that "LessThan itself does not check the bit length" (Dark Forest blog). The fix commit addsNum2Bitsrange checks on both inputs before the comparisons. - zk-email (zkSecurity, May 27, 2024). This High finding involved
ItemAtIndex, which bounded its index withLessThanbut never range-checked the index. Small "negative" field elements could make the SHA-256 templates output all zeros for any input (zk-email report). The report judged meaningful exploitation inEmailVerifierinfeasible because of other checks, but warned that other consumers of the SHA-256 templates may be vulnerable. The team fixed it in PR #197. A separate Medium finding listed templates with undocumented assumptions about their inputs. - Self Aadhaar circuits (zkSecurity, September 9, 2025).
PackBytesdid not constrain inputs to bytes, andGreaterEqThan(7)passes on non-7-bit inputs. Together these allowed disclosure of incorrect state and phone-number values. The fix added a range check onminimumAge(Self report).
Constraints the compiler removes. The same Self report flagged a "dummy" constraint, user_identifier + user_identifier. Because it is linear, circom's O2 optimisation removes it; the report says a constraint meant to survive must be nonlinear.
The same bug outside circom. Noir makes the hint/constraint split explicit. Calling an unconstrained function requires an unsafe block, and the compiler warns unless the block has a // Safety: comment. Two default passes, independent-subgraph detection and Brillig constraint coverage, flag results that are never constrained against inputs (Noir docs).
Detection tools
- Circomspect (repo) is Trail of Bits' static analyser for circom. Its passes flag
<--assignments that could be<==, intermediate signals appearing in only one constraint, unconstrained template outputs, non-strictNum2Bits/Bits2Num,LessThaninputs without aNum2Bitsbound, and<--division without a non-zero divisor check (passes). - Picus (repo) implements QED², which uses SMT solving to check whether every signal is uniquely determined by the inputs. It supports Circom, R1CS and gnark, reports
safeorunsafe, and usually outputs an attack vector when unsafe. The paper reports solving 70% of 163 Circom benchmarks and finding 8 previously unknown vulnerabilities (eprint 2023/512). - zkFuzz mutates witness computation to find under- and over-constrained bugs. Its authors report 85 bugs across 452 real-world Circom circuits, including 59 zero-days (arXiv 2504.11961).
Review checklist
- For every
<--or hint, write down the constraint that pins the value uniquely, then check that it exists. For division, also check that the divisor is non-zero. - For every
LessThan,LessEqThan,GreaterThanorGreaterEqThan, find theNum2Bitsthat bounds both inputs to the declared width. - Any decomposition near the field size must use the strict variants or an explicit less-than-p check.
- Treat a template's input assumptions as part of its API. Document them, and check every call site against them (the SoK's "missing input constraints" and "unsafe reuse" causes).
- Check that every template output is used or deliberately ignored.
- Do not rely on linear "dummy" constraints after optimisation.
- Run Circomspect on every change. Run Picus or a fuzzer on components small enough to finish. Treat
unknownas unreviewed, not as safe.
Sources
- https://arxiv.org/html/2402.15293v3
- https://docs.circom.io/circom-language/signals/
- https://docs.circom.io/circom-language/constraint-generation/
- https://raw.githubusercontent.com/iden3/circomlib/master/circuits/bitify.circom
- https://raw.githubusercontent.com/iden3/circomlib/master/circuits/comparators.circom
- https://github.com/iden3/circomlib/pull/22
- https://blog.trailofbits.com/2022/09/15/it-pays-to-be-circomspect/
- https://blog.zkga.me/df-init-circuit
- https://github.com/darkforest-eth/circuits/commit/1b5c8440a487614d4a3e6ed523df0aee71a05b6e
- https://reports.zksecurity.xyz/reports/zkemail/
- https://reports.zksecurity.xyz/reports/mysten-labs-zklogin/
- https://reports.zksecurity.xyz/reports/self-aadhaar-circuits/
- https://github.com/trailofbits/circomspect
- https://raw.githubusercontent.com/trailofbits/circomspect/main/doc/analysis_passes.md
- https://github.com/Veridise/Picus
- https://eprint.iacr.org/2023/512
- https://arxiv.org/abs/2504.11961v2
- https://noir-lang.org/docs/noir/concepts/unconstrained
Related guides: under-constrained circuits, auditing circom, choosing a proving system, trusted setup risk, the checklist.