Back to all posts

Common Circom Pitfalls and How to Dodge Them — Part 2

Thumbnail image

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.

zkSecurity offers auditing, research, and development services for cryptographic systems including zero-knowledge proofs, MPCs, FHE, and consensus protocols.

Learn more →

Share This Article