ZK/SEC Research notes from zkSecurity
All posts
Common Circom Pitfalls · Part 2 of 2

Common Circom Pitfalls and How to Dodge Them, Part 2

Circom pitfalls

In Part 1, we discussed how misusing hints and assertions can leave circuits under-constrained. We also saw how aliasing bugs can bite you when using circomlib's Num2Bits and Bits2Num. In this post, we'll dig into three more footguns to watch out for when writing Circom. Without further ado, let's dive right in!

Output Constraints Are Easily Forgotten

In Circom, many templates assume that their callers will explicitly constrain their outputs. If a component is used without any constraints on its outputs, that’s often a red flag and can lead to serious security vulnerabilities!

For example, consider circomlib’s IsEqual() template:

template IsEqual() {
    signal input in[2];
    signal output out;

    component isz = IsZero();

    in[1] - in[0] ==> isz.in;

    isz.out ==> out;
}

The code below instantiates this template and appears to enforce x == y. In truth, though, both x and y can still take any value.

pragma circom 2.1.6;

include "circomlib/comparators.circom";

template AssertEquality() {
    signal input x;
    signal input y;

    component eq = IsEqual();
    eq.in[0] <== x;
    eq.in[1] <== y;

    // BUG: eq.out is not constrained to be 1!
}

component main = AssertEquality();

// This witness will satisfy the circuit, 
// although x is not equal to y.
/* INPUT = {
    "x": "42",
    "y": "24"
} */

Remember, IsEqual() outputs 1 if and only if x and y are equal. However, in the example above, its output isn’t constrained to be 1! As a result, the output can take any value, i.e., the two inputs are never actually enforced to be equal. You can test this in zkREPL: the code above runs without errors, even though the witness values for x and y are clearly unequal.

The fix is straightforward: add eq.out === 1; to the end of the AssertEquality() template. With this constraint in place, the circuit will correctly throw an error whenever x and y differ. In particular, the witness x = 42, y = 24 will no longer satisfy the circuit.

As a second example, the code below appears to enforce that both x and y equal 1, but for the same reason as before, it doesn’t.

include "circomlib/gates.circom";

template AssertAndIsTrue() {
    signal input x;
    signal input y;

    component and = AND();
    and.a <== x;
    and.b <== y;

    // BUG: and.out is not constrained to be 1!
}

Note: Even if the output were constrained to 1, the template above would still be insecure. Can you spot the mistake? If not, don’t worry! We’ll cover it in the next section.

For a more realistic example, consider the Circom-Pairing library. It relies on big-integer circuits to represent values larger than the field size. To enforce that certain signals stay within the expected range, the library uses a BigLessThan template:

template BigLessThan(n, k){
    signal input a[k];
    signal input b[k];
    signal output out;
    ...
}

a[k] and b[k] are input arrays of signals that represent the limbs of two big integers a and b, respectively. The BigLessThan component takes these limbs as inputs and outputs 1 if a < b, and 0 otherwise.

Note: If you’re new to big integers, writing a simple BigInt library yourself is a great way to get started. And if the term “limb” sounds odd, here’s a short read on where the term comes from.

The Circom-Pairing library used several BigLessThan components inside its CoreVerifyPubkeyG1 template to verify that each limb of the provided public key was within range:

template CoreVerifyPubkeyG1(n, k){
  ...
  var q[50] = get_BLS12_381_prime(n, k);

  component lt[10];

  for(var i=0; i<10; i++){
    lt[i] = BigLessThan(n, k);
    for(var idx=0; idx<k; idx++)
      lt[i].b[idx] <== q[idx];
  }

  for(var idx=0; idx<k; idx++){
    lt[0].a[idx] <== pubkey[0][idx];
    lt[1].a[idx] <== pubkey[1][idx];
    ... 
    lt[9].a[idx] <== pubkey[9][idx];
  }
  ...    

The intent was to constrain pubkey < q to ensure that the public keys are correctly formatted big integers. However, the outputs lt[i].out were never constrained! As in our earlier toy examples, this means the circuit did not actually enforce pubkey < q. A malicious prover could supply an out-of-range public key and still produce a valid proof, because nothing checked that lt[i].out was equal to 1.

Once again, the fix was simple: ensure that every lt[i].out is equal to 1. An efficient way to do this is to add a constraint after the second for-loop that requires the sum of all ten outputs to equal 10:

...

for(var idx=0; idx<k; idx++){
  lt[0].a[idx] <== pubkey[0][idx];
  lt[1].a[idx] <== pubkey[1][idx];
  ... 
  lt[9].a[idx] <== pubkey[9][idx];
}   

// Contrain each lt[i].out to equal 1.
for(var i=0; i<10; i++){
    r += lt[i].out;
}
r === 10;

...

For a more detailed explanation of the issue, I recommend reading this blog post.

Lastly, I’d like to emphasize that not every unused output is automatically a bug. For instance, circomlib’s Num2Bits(n) is often used to efficiently range-check a given signal to be in $[0, 2^n)$. When used for that purpose, it’s perfectly fine to leave its outputs unconstrained. See here for an example of such usage. (If you’re still relatively new to Circom, this might not make sense right now. That’s okay! We’ll soon get you up to speed with range checks in an upcoming post of this series.)

Bottomline: Component outputs are not automatically constrained. You must explicitly add a constraint. Without one, outputs can take arbitrary values. This pitfall commonly arises with comparators and Boolean gates, though it’s not limited to them. There are some templates, e.g., circomlib’s Num2Bits, where unconstrained outputs can be safe in certain use cases. But most of the time, an unconstrained output should ring your alarm bells.

Input Constraints Are Easily Forgotten, Too

Just as unconstrained outputs are a common source of serious bugs, unconstrained component inputs are equally suspicious since many templates assume you’ll handle input constraints yourself at the call site.

As a simple example, consider circomlib’s AND gate one more time:

template AND() {
    signal input a;
    signal input b;
    signal output out;

    out <== a*b;
}

The standard use case for this gate is to indicate whether two Boolean flags are true. For instance, it could naively be used as follows:

include "circomlib/gates.circom";

template RequireBothTrue() {
    signal input flagA;
    signal input flagB;

    component andGate = AND();
    andGate.a <== flagA;
    andGate.b <== flagB;

    // We intend to enforce:
    // "both flags must be 1"
    andGate.out === 1;
}

This template behaves correctly for Boolean values, but here’s the catch: circomlib’s AND gate doesn’t enforce its own input assumptions, i.e., it never checks that its inputs are actually Boolean. It just assumes they are. In other words, we can trick our AND gate into outputting “true” on completely garbage inputs:

pragma circom 2.1.6;

include "circomlib/gates.circom";

template RequireBothTrue() {
    signal input flagA;
    signal input flagB;

    component andGate = AND();
    andGate.a <== flagA;
    andGate.b <== flagB;

    // We intend to enforce:
    // "both flags must be 1"
    andGate.out === 1;
}

component main = RequireBothTrue();

// We can trick our AND gate into outputting "true" despite the fact that we
// use completely nonsensical inputs.

// Note: Recall that circomlib's AND gate is just a multiplication of its 
// two inputs. To ensure flagA * flagB = 1 (and therefore andGate.out = 1), 
// we set flagB to the modular inverse of flagA. 

/* INPUT = {
    "flagA": "42",
    "flagB": "15113310554365213843932042062201451846854823038382499903982093366921391580307"
} */

Clearly, that’s not the semantics we intended when we wrote “both flags must be 1.” The fix is simple, though: we must constrain the inputs before using them!

include "circomlib/gates.circom";

template RequireBothTrueFixed() {
    signal input flagA;
    signal input flagB;

    // Enforce both flags are Boolean before
    // using them as inputs for the AND gate.
    flagA * (flagA - 1) === 0;
    flagB * (flagB - 1) === 0;

    component andGate = AND();
    andGate.a <== flagA;
    andGate.b <== flagB;

    // Now this *really* means "both flags must be 1".
    andGate.out === 1;
}

Bottomline: Templates often assume their inputs already satisfy certain properties, but don’t enforce those input assumptions through constraints. If you forget to enforce these preconditions at the call site, attackers can satisfy downstream constraints with nonsensical inputs.

Comparisons Operate Over Signed Integers

It’s easy to assume that, because Circom works over a finite field, signals and variables are always treated as unsigned integers in $[0, p)$. However, this assumption is wrong!

As a counterexample, let’s consider Circom’s definition of the comparison operators <, >, <=, and >=. Upon closer inspection, you’ll realize that these operators depend on an internal function, $\text{val}: [0,p)\rightarrow (-\frac{p}{2},\frac{p}{2}]$, which is defined as follows:

$$ \text{val}(z)= \begin{cases} z-p, \hspace{5pt}\text{ if }\hspace{2pt} p/2+1 \leq z < p\\ z,\hspace{22pt} \text{ otherwise} \end{cases} $$

In other words, before comparing two numbers, Circom maps them from $[0,p)$ to $(-\frac{p}{2},\frac{p}{2}]$, as shown in the figure below.

Diagram of val-mapping

More formally, Circom defines relational operators via

$$ x \hspace{3pt}\square\hspace{3pt} y \hspace{10pt}\text{iff}\hspace{10pt}\text{val}(x\text{ mod }p) \hspace{3pt}\square\hspace{3pt} \text{val}(y\text{ mod }p) $$

where $\square$ represents either $<$, $\leq$, $>$, or $\geq$, and where “$\text{mod }p$” accounts for the fact that, before applying $\text{val}$, Circom will first map the numbers to the interval $[0, p)$.

If you’re not careful, this behavior can lead to surprising results during witness generation, as the following example illustrates:

pragma circom 2.1.6;

template SurprisingWitGen() {
    signal input x;
    signal input y;
    signal isGreater;

    // We intend: isGreater = 1 if x > y, else 0.
    isGreater <-- x > y;

    log(isGreater);
}

component main = SurprisingWitGen();

// Note: In this particular example, we show p/2 > p/2 + 1. 
// Using Circom's default prime, we have:
// p/2 = 10944121435919637611123202872628637544274182200208017171849102093287904247808

/* INPUT = {
    "x": "10944121435919637611123202872628637544274182200208017171849102093287904247808",
    "y": "10944121435919637611123202872628637544274182200208017171849102093287904247809"
} */

This shows that $p/2>p/2+1$ is indeed a true statement in Circom. To convince yourself, I encourage you to run the code above in zkREPL.

Now you might be wondering: “Why on earth would Circom deliberately apply such a confusing remapping before comparing two values?!”

Good question! Let's consider what would happen otherwise: without applying $\text{val}$, the field elements would just be mapped to $[0,p)$ before comparison. In particular, this means the representation of $-1$ would be $p-1$ so that Circom would treat the statement $0<-1\hspace{3pt}(\equiv p-1)$ as true.

So Circom's rationale is the following: “There’s no way around having a weird wrap-around point in our comparisons because finite fields are inherently circular. However, we’d rather place that discontinuity ‘far away’ at an enormous number such as $p/2+1$, instead of right in the middle of the most commonly used range, namely the range around zero. This way, comparisons in the ‘normal’ range behave intuitively, and the weird edge case only appears for very large numbers.”

Notice, though, that this choice is purely a matter of definition. Finite fields do not have a canonical ordering of elements that "makes sense" inherently, so there is no "wrong" or "right" choice for this. Some people might prefer Circom's signed arithmetics while others might find unsigned arithmetics more natural. It just happens that Circom's developers decided to go with the former. Thus, we have to work with that, whether we like it or not.

Bottomline: Circom’s <, >, <=, and >= operate over signed field representatives. If your witness-generation logic assumes unsigned semantics, ensure the “cutoff” at $p/2+1$ is taken into account.

As a last comment, recall that Circom’s built-in comparisons are never part of the actual circuit logic! Operators like <, >, or even == don’t “run” at runtime as they would in a traditional programming language. Circom circuits are just sets of equations (composed only of additions and multiplications over field elements) that must hold true when a proof is generated and verified. Therefore, comparison operators are used only during witness generation, not within the circuit itself.

That said, it is possible to implement comparison logic directly at the circuit level! That’s precisely the purpose of circomlib’s comparator templates, such as LessThan.

We’ll take a closer look at these and their common footguns in an upcoming post of this series. Stay tuned!

Special thanks to Giorgio Dell'Immagine, 0xAlexSR, 0xStrapontin, 0xteddav, and Georgios Raikos for spending their valuable time helping me polish this post.

Keep reading
Latest

Variants of KZG: Part III, Multilinear Commitments with Zeromorph

In this blog post, we extend univariate KZG commitments to multilinear polynomials through Zeromorph. We introduce the univariatization map, encode the multilinear quotient identity as a univariate identity, and explain why the quotient encodings require degree checks. We then show how Zeromorph batches these checks into a single degree-bounded KZG opening and walk through its end-to-end opening protocol. We conclude by examining its proof size, prover cost, and verifier cost.

Varun Thakore · August 04, 2026

Variants of KZG: Part II, Multilinear Commitments with PST

In this blog post, we extend the ideas behind univariate KZG commitments to multilinear polynomials through the PST commitment scheme. We derive the multilinear quotient identity, explain how PST commits to and opens multilinear polynomials using a specialized multilinear setup and walk through its opening protocol. We conclude by examining the proof size, prover and verifier costs, and the limitations that motivate other multilinear polynomial commitment schemes.

Varun Thakore · July 28, 2026

Threshold ECDSA: Building CGGMP from scratch

A step-by-step build of CGGMP, an n-of-n threshold ECDSA scheme where parties jointly produce a signature without ever reconstructing the private key. We start from the multiplicative-to-additive (MtA) protocol and a semi-honest version of the signing protocol, then harden it into a maliciously secure one by layering in zero-knowledge proofs at each step. Along the way we cover the auxiliary Ring-Pedersen and Paillier parameters and the proofs that make them safe to use.

Samuel Tang · July 27, 2026
Recommended

Common Circom Pitfalls and How to Dodge Them, Part 1

Programming in Circom comes with its fair share of challenges. After reviewing numerous Circom codebases, we’ve identified certain anti-patterns that occur frequently. In this series, we’ll provide a comprehensive overview of these issues to help you avoid the most common pitfalls. Of course, this won’t be a complete list of every mistake possible (Circom has plenty of ways to trip you up). But the footguns we’ll cover are the ones that tend to catch developers off guard the most.

Marco Besier · June 24, 2025

You like Circom but you find it confusing? Introducing Circomscribe

Dive into our exploration of Circomscribe, a nifty tool designed to illuminate the mysterious process of how your Circom code gets translated into constraints. We share insights from our experience with Circom circuit audits, highlighting common pitfalls developers face when their high-level intentions meet low-level reality. By showcasing how Circomscribe can help visualize this transition, we aim to empower developers to craft more bug-free, secure ZK applications. If you're keen on understanding the inner workings of Circom and enhancing your coding prowess, this post is your guide.

ZK/SEC · August 26, 2023

Introducing clean, a formal verification DSL for ZK circuits in Lean4

We're diving into our new project called **clean**, aimed at creating an embedded DSL and formal verification framework for Zero Knowledge (ZK) circuits using Lean4. Imagine being able to not only define ZK circuits but also formally prove their correctness. Sounds like a game-changer, right? We'll walk you through our process of building a robust library of reusable, verified circuit gadgets, focusing on the importance of soundness and completeness. Plus, you'll get a peek at some cool examples like 8-bit addition and how we're tackling ZKVM design with techniques borrowed from Fibonacci sequences. It's exciting stuff, and if you're curious about how we're paving the way for bug-free ZK circuits, this is a read you won't want to miss!

Giorgio Dell'Immagine · March 27, 2025
More to explore

KZG vs IPA vs FRI: Picking the Right Polynomial Commitment Scheme

A practical guide to the trade-offs between KZG, IPA/Halo, and FRI, the three major polynomial commitment scheme families powering modern zero-knowledge proof systems. We compare proof sizes, verification costs, trust assumptions, benchmarks, and on-chain gas costs.

ZK/SEC, University of Padua · March 23, 2026

Comparison of formal verification frameworks for arithmetic circuits

A hands-on comparison of formal verification frameworks for arithmetic circuits, evaluating those in the ACL2 Book (r1cs, PFCS), acl2-jolt, Garden (Rocq), zk-lean, sp1-lean, and Clean. Each framework is tested on reproducibility, available examples (from basic field elements to RISC-V VM instructions), and practical verification tasks including the IsZero and weighted-sum circuits. The evaluation includes both human and Claude Code's ability to work with each framework, revealing insights about installation difficulty, proof automation capabilities, and the maturity of publicly available examples. This post maps the current landscape of formally verified ZK circuits and discusses what's coming next in this rapidly evolving field.

Yoichi Hirai · November 19, 2025

Introducing bugs.zksecurity.xyz a knowledge base for ZK bugs

We're thrilled to introduce our new site, [bugs.zksecurity.xyz](https://bugs.zksecurity.xyz/), a hub for exploring past vulnerabilities in ZK circuits. Dive into our growing catalog of documented bugs and learn how we've reproduced some with comprehensive scripts. Discover evaluations of prominent security tools like Circomspect and Picus, and see where they shine or stumble. We're calling on the community to join us in expanding this invaluable resource, whether by adding bugs, reproducing them, or improving our platform. Let's collaborate to elevate ZK security together!

Stefanos Chaliasos · February 17, 2025