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
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

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

Sigma dance: commit, challenge, respond

Learn the fundamentals of Σ-protocols through the classic Schnorr protocol, exploring the three-step dance of commit, challenge, and respond. This post walks through knowledge soundness and witness extraction, then shows how to compose Sigma proofs with AND/OR logic and Pedersen commitments. See working SageMath implementations, discover how Fiat-Shamir transforms interactive proofs into non-interactive signatures, and understand the deeper mathematical structure as proofs of knowledge of homomorphism pre-images.

teddav · November 18, 2025

Nine Years to Halve a Hash Function: RFC 9861 Is Out

After nearly a decade in the making, RFC 9861: KangarooTwelve and TurboSHAKE was officially published in October 2025, with zkSecurity among its editors. This post explains why these hash functions deserve to be far more widely used than they are today. Built on the same Keccak permutation as SHA-3 and SHAKE but with the round count halved, they do the same job at the same security level roughly twice as fast. We cover why halving the rounds is safe, what the RFC actually defines, the caveats around FIPS compliance and workload-dependent speedups, and why a standard like this took nine years to land.

David Wong · July 21, 2026

Powers-of-Funbenius

A one-character repeated-squaring bug in Inferno's Limbo implementation turns the intended random polynomial check into a linearized Frobenius check over a binary extension field. This post walks through why the usual Schwartz-Zippel argument disappears, how periodicity of the Frobenius map makes collisions immediate after 64 multiplication gates, and how even smaller circuits can cheat with noticeably higher probability.

Mathias Hall-Andersen · May 05, 2026