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

Common Circom Pitfalls and How to Dodge Them, Part 1

Header Image

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. So, let’s break them down and, more importantly, learn how to dodge them.

Assertions Do Not Add Constraints

Circom’s assert does not add constraints to the circuit’s underlying R1CS. Therefore, you should never use assert as a replacement for ===!

The sole purpose of Circom assertions is to add safety checks to ensure templates are not instantiated with undesirable compile-time parameters. For example, circomlib uses assert to ensure that developers can only use the LessThan(n) template with n <= 252 as instantiating LessThan(n) with n = 253 could lead to alias bugs. (If you’ve never heard of alias bugs before, don’t worry! We’ll cover these in a bit.)

template LessThan(n) {
    // Ensure developers can't compile this template with n > 252
    assert(n <= 252);
    signal input in[2];
    signal output out;

    component n2b = Num2Bits(n+1);

    n2b.in <== in[0]+ (1<<n) - in[1];

    out <== 1-n2b.out[n];
}

Part of the reason people use assert incorrectly might be that even relatively new AI models still get it wrong, too:

ChatGPT Output

o3-mini-high when prompted “What is a practical use case for using assert in Circom?”

This advice is snake oil. Circom’s assert should only be applied to template parameters, not to signals.

Confusingly enough, however, you actually can apply assert to signals in Circom. And to make matters even worse, the outcome of applying assert to signals looks very much like the outcome of applying ===! For example, consider the following circuit in zkrepl.dev:

pragma circom 2.1.6;

template TwoBools () {
    signal input a;
    signal input b;

    (a - 1) * a === 0;
    (b - 1) * b === 0;
}

component main = TwoBools();

/* INPUT = {
    // A valid boolean
    "a": "1",
    // Not a valid boolean
    "b": "42"
} */

Running this code will produce the following error, as setting the input for b to 42 violates the second constraint.

Screenshot of how assertion fails

Okay, no surprises thus far. However, what happens if we replace (b - 1) * b === 0 with an assertion?

pragma circom 2.1.6;

template TwoBools () {
    signal input a;
    signal input b;

    (a - 1) * a === 0;
    // Use `assert` instead of `===`
    assert((b - 1) * b == 0);
}

component main = TwoBools();

/* INPUT = {
    "a": "1",
    "b": "42"
} */

Running this code will actually produce the same error!

So, at first sight, it looks like there’s no harm in using assert and === interchangeably. There’s a subtle difference, though: our circuit’s R1CS will now only have one constraint instead of two!

Second screenshot of how assertion fails

So, while using assert ”feels the same” as === during development, you should always assume that a malicious prover can eventually just copy the circuit code, delete the assert, and create a proof. Notice that this malicious proof will satisfy all of your circuit’s constraints as your assert didn’t add a proper constraint in the first place!

Bottomline: Only use assert on template parameters to ensure your templates are not created with undesirable parameter values. Do not use assert on signals and, in particular, do not use assert as a replacement for ===.

Hints Are Unconstrained

Circom’s <-- operator goes by many names:

  • hint
  • out-of-circuit computation
  • assignment
  • witness generation

Regardless of what you call it, if you use this operator in your code, that should ring alarm bells! The reason?

Circom’s <-- does not add any constraints to the circuit!

Think of the <-- operator as “accepting untrusted user input,” which must always be sanitized by adding constraints.

A popular example is unconstrained division:

template Inverse() {
  signal input in;
  signal output out;

  out <-- 1 / in;
}

Arguably, this is a very simple example, and even junior developers should be able to quickly identify that the above circuit lacks a constraint:

template Inverse() {
  signal input in;
  signal output out;

  out <-- 1 / in;

  // Don't forget to constrain your out-of-circuit computations!
  out * in === 1;
}

Now you might go “Well, duh… why would anyone fall prey to this?!” But rest assured: real-world circuits are rarely that simple.

Let’s take things up a notch and consider the following circuit:

template IsZero() {
  signal input in;
  signal output out;
  signal inv;

  inv <-- in!=0 ? 1/in : 0;
  out <-- -in*inv +1;
  out === -in*inv +1;
}

Compared to our Inverse example, identifying the issue here is already a bit more challenging, isn’t it? After all, our three signals are all appearing in the constraint, so we should be good, right? RIGHT?

Well… nope.

We must add a second constraint, in * out === 0. Otherwise, an attacker can easily craft a malicious witness, e.g., in = 42, inv = 0, out = 1, which the verifier will happily accept since it satisfies the circuit’s only constraint out === -in*inv +1. However, 42 is obviously non-zero. In other words, the verifier will think that the user has a zero value when they actually don’t!

Using <-- instead of <== for your Circom assignments can be tempting, especially as this will silence any “Non quadratic constraints are not allowed!” errors that the compiler might throw at you. Moreover, separating computation and constraints by using hints can often be more performant than modeling your computations with pure constraints alone. In particular, you want to use <-- in cases where computations are difficult to perform, but easy to check, with addition and multiplication. One example of such a circuit is the Inverse template mentioned above.

Bottomline: If you use <-- in your Circom code, make absolutely sure that all required constraints are explicitly enforced elsewhere in the circuit!

Finite Field Arithmetic Enables Aliasing Attacks

Encoding a number via a bit array can lead to alias bugs if the number in question is greater than $p-1$, where $p$ denotes Circom’s field prime.

More specifically, an alias is when a binary array encodes a value $\ge p$ so that, under modulo $p$ arithmetic, it “aliases” to a different, smaller field element instead of its true integer value.

Example.* Suppose we’re encoding field elements using 3-bit arrays, i.e., the maximum unsigned integer we can encode is $M=2^3-1=7$. Furthermore, suppose $p=5$. In that case, the (little-endian) array $[0,1,1]$ is an encoding of $a=6$ which, in the field $\mathbb{F}_5$, is an alias for $x=1$ (since $6 \text{ mod }5 = 1$). Notice that $0$ and $2$ also have exactly one alias (namely $5$ and $7$) while the remaining field elements, $3$ and $4$, do not* have an alias. This is because $3+p \hspace{3pt}(= 8)$ and $4+p \hspace{3pt}(= 9)$ exceed our 3-bit encoding maximum $M=7$.

More generally, suppose we use an $n$-bit array to encode an input signal $x$. Furthermore, let $p \le M$ and $m := M \text{ mod } p$, where $M=2^{n}-1$ is the maximum unsigned integer representable with $n$ bits. Moreover, let’s assume $x\in [0,m]$ for now. You can think of $m$ as “the threshold above which the number of aliases decreases by one.” (Notice how this is consistent with the above example, where we had $m = 7 \text{ mod }5 = 2$. The elements $0$, $1$, $2$ had one alias each, whereas $3$ and $4$ had zero aliases.)

In this setup, $x$ will have

$$ N := \lfloor(M - x)/p\rfloor $$

aliases $a_i$. Given this number $N$, a field element $y\in (m, p-1]$ will then have $N-1$ aliases $\tilde{a}_j$.

Diagram of aliases

For $x \in [0, m]$, the aliases can be computed via:

$$ a_1 = x + 1 \cdot p $$ $$ a_2 = x + 2 \cdot p $$ $$ \vdots $$ $$ a_N = x+N\cdot p $$

Given the number $N$, the aliases for the remaining field elements $y\in (m, p-1]$ can then be computed via:

$$ \tilde{a}_{1} = y + 1 \cdot p $$ $$ \tilde{a}_{2} = y + 2 \cdot p $$ $$ \vdots $$ $$ \tilde{a}_{N-1} = y+(N-1)\cdot p $$

You should think of the interval $[p,M]$ as the “danger zone” in the sense that each element in $[p,M]$ will alias an element in $[0,p-1]$.

In other words, whenever we’re encoding binary arrays with $M\geq p$, we’re in danger of introducing alias bugs.

In practice, you’ll often find that $M$ is strictly between $p$ and $2p$ so that some, but not all, field elements may have two valid representations.

To make things more concrete, let’s examine an attack in actual Circom code. Suppose we want to prove that we know the 254-bit representation of the decimal number $42$. To do this, we could naively use the following circuit:

pragma circom 2.1.6;

template Bits2Num(n) {
    signal input in[n];
    signal output out;
    var lc1=0;

    var e2 = 1;
    for (var i = 0; i<n; i++) {
        lc1 += in[i] * e2;
        e2 = e2 + e2;
    }

    lc1 ==> out;
}

template Alias (n) {
    signal input in[n];
    signal output out;

    component b2n = Bits2Num(n);

    for (var i=0; i<n; i++) {
        b2n.in[i] <== in[i];
    }

    out <== b2n.out;
}

component main = Alias(254);

However, this circuit is not sound! To see this, first notice that

  • $n=254$,
  • $p$ is Circom’s default prime,
  • and $x = 42$ is the field element for which we want to fake a binary encoding.

Note that this implies:

  • $M=2^{254}-1$ and
  • $m = M \text{ mod }p \approx 7.05 \cdot 10^{75}$

Thus, we have $x\in [0,m]$ and can therefore compute:

$$ N = \lfloor(M - x)/p\rfloor = 1 $$

In other words: In default Circom, the number $x = 42\in [0,p-1]$ has exactly one alias, namely $a = 42 + p\in [p,M]$. In particular, we can convince a verifier that the binary representation of $a$ is the binary representation of $x$, which, in reality, it is not!

To see that the circuit is indeed unsound, you can run the following example in zkREPL:

pragma circom 2.1.6;

template Bits2Num(n) {
    signal input in[n];
    signal output out;
    var lc1=0;

    var e2 = 1;
    for (var i = 0; i<n; i++) {
        lc1 += in[i] * e2;
        e2 = e2 + e2;
    }

    lc1 ==> out;
}

template Alias (n) {
    signal input in[n];
    signal output out;

    component b2n = Bits2Num(n);

    for (var i=0; i<n; i++) {
        b2n.in[i] <== in[i];
    }

    out <== b2n.out;
}

component main = Alias(254);

// Use the (little-endian) binary representation of 42+p as the input 'in'

/* INPUT = {
    "in": [1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,1,0,0,1,1,0,1,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,1,0,0,0,1,0,0,1,0,0,0,0,1,1,1,0,1,0,0,1,1,1,0,1,1,0,0,1,1,1,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,0,0,0,0,1,0,1,0,0,1,0,1,1,1,0,1,0,0,0,0,1,1,0,1,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,0,1,1,0,1,1,0,1,1,0,1,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,1,1,1,0,1,1,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,1,0,0,0,1,1,0,0,1,0,0,0,0,1,1,1,0,1,0,0,1,1,1,0,0,1,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,0,0,0,1,1]
} */

// 'out' will evaluate to 42 although 'in' is NOT the correct binary
// representation of 42 (which would be [0,1,0,1,0,1,0,0,0,...])

Okay, now that we have a better understanding of aliasing attacks, how do we prevent them? Elementary, my dear Watson…

For the default case where $p$ is Circom’s default prime and $n=254$, circomlib provides “strict” versions of Bits2Num and Num2Bits, which implement an AliasCheck to “cut off” the danger zone $[p, M]$ from the interval $[0, M]$. In other words, these templates will ensure that the input is strictly smaller than the field prime so that alias inputs will always be rejected.

template AliasCheck() {

    signal input in[254];

    component  compConstant = CompConstant(-1);

    for (var i=0; i<254; i++) in[i] ==> compConstant.in[i];

    compConstant.out === 0;
}

template Bits2Num_strict() {
    signal input in[254];
    signal output out;

    component aliasCheck = AliasCheck();
    component b2n = Bits2Num(254);

    for (var i=0; i<254; i++) {
        in[i] ==> b2n.in[i];
        in[i] ==> aliasCheck.in[i];
    }

    b2n.out ==> out;
}

template Num2Bits_strict() {
    signal input in;
    signal output out[254];

    component aliasCheck = AliasCheck();
    component n2b = Num2Bits(254);
    in ==> n2b.in;

    for (var i=0; i<254; i++) {
        n2b.out[i] ==> out[i];
        n2b.out[i] ==> aliasCheck.in[i];
    }
}

Note that the AliasCheck template uses CompConstant(-1) to enforce that the binary input array encodes a value less than or equal to $p-1\hspace{5pt}(= -1 \text{ mod }p)$.

Before wrapping things up, we would like to emphasize one more time that aliasing attacks can only occur if $M\ge p$. In other words, if the input’s bit-size allows for binary encodings of numbers greater than the field prime. This is precisely the reason why circomlib’s LessThan(n) template enforces n <= 252: a value greater than that would lead to an instantiation of LessThan's internal Num2Bits that would be vulnerable to aliasing attacks.

template LessThan(n) {
    assert(n <= 252);
    signal input in[2];
    signal output out;

    // n > 252 would make Num2Bits(n+1) vulnerable to 
    // aliasing attacks
    component n2b = Num2Bits(n+1);

    n2b.in <== in[0]+ (1<<n) - in[1];

    out <== 1-n2b.out[n];
}

As a last comment, notice that circomlib’s AliasCheck, CompConstant, Bits2Num_strict, and Num2Bits_strict all hardcode the bit-size to 254! This means that, should you ever decide to work with a field prime other than Circom’s default 254-bit prime, you may have to manually change these templates accordingly to prevent aliasing attacks.

For a real-world example of an alias vulnerability, check out the following (fixed) double-spending issue in Semaphore.

Bottomline: Using circomlib’s Bits2Num(n) and Num2Bits(n) templates can make your circuits vulnerable to aliasing attacks if n = 254 and $p$ is Circom’s default prime. To mitigate this issue, you can use circomlib’s _strict versions of these templates instead.

Special thanks to Gregor Mitscha-Baude, Alex Babits, Giorgio Dell'Immagine, Nadir Khan, Jaehun Kim, and 0xAlexSR for spending their valuable time helping me polish this post.

Keep reading
Recommended

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

The First ZK Exploits Happened, and They Weren't What We Expected

The first two known exploits against live ZK circuits happened in the past week. Both stem from the same root cause. They were not subtle underconstrained bugs, but rather Groth16 verifiers (generated by snarkjs) with an incorrect setup (just missing the last step). One was exploited by white-hat hackers for ~$1.5M, the other was drained for 5 ETH.

Stefanos Chaliasos, Hao Pham · February 27, 2026
More to explore

Private delegated computation is here, and there will be bugs!

Dive into the intriguing world of zero-knowledge cryptography with us as we unravel its connection to trusted and confidential computing. Learn why this tech is making us rethink security like never before. From the evolution of private and delegated computing to the revolutionary power of zero-knowledge proofs, we'll explore how these advancements are shaping industries beyond cryptocurrencies. With big players like Microsoft jumping on board and the potential for new applications, zero-knowledge cryptography is poised to redefine digital trust. Join us on this journey as we launch our new security consultancy, designed to navigate and secure this cutting-edge landscape.

ZK/SEC · May 30, 2023

zkSecurity x Bain Capital (Whiteboard Session): Unveiling the Power of Multi-Party Computation

We're thrilled to share that we're featured in Bain Capital Crypto's Whiteboards series! In the first episode, David and Guillermo Angeris dive deep into the intriguing world of Multi-Party Computation (MPC). Join them as they break down complex concepts like additively-shared secrets and Shamir Secret Sharing, making the cutting-edge tech behind MPC accessible and engaging. Check out the full episode for some engaging insights and reach out to us if you're looking for MPC stack auditors!

ZK/SEC · June 04, 2024

Public report of Reclaim protocol's ChaCha20 circuit

We audited Reclaim protocol's ChaCha20 circuits, diving deep into bit-level operations for a secure and efficient design. After a few iterations, we switched from a word-based to a bit-focused circuit approach, achieving a 10% enhancement in performance and size. We used Circom for implementation, with a focus on Groth16 system constraints. Our findings led Reclaim to revamp their strategy, honing in on bitwise logic for an effective flow without costly re-encodings. Curious about the technical journey and the final audit insights? We’ve got the details covered!

ZK/SEC · October 02, 2023