Your first ZK vulnerabilities: From ZeroKnowledge of ZK to OneKnowledge

Category Security

Your first ZK vulnerabilities: From ZeroKnowledge of ZK to OneKnowledge

What you will learn here

This article introduces the fundamental mental models for creating and thinking about ZK circuits. You'll explore key concepts from first principles and learn about a simple but critical vulnerability that sometimes appears when writing or reviewing ZK code.

By the end, you'll have enough knowledge to ask meaningful questions during your ZK learning journey or when conducting ZK security reviews, even if you're starting with zero knowledge of zero-knowledge.

At the end of the article, you'll find links to resources for continued learning about this powerful cryptographic technology.

Structure and prerequisites

We'll explore ZK through a "simple" Circom code example.

You'll need basic knowledge of:

  • Computer science fundamentals: if statements, loops, and OR gates
  • Elementary mathematics concepts: how polynomials work (e.g., understanding that \(x^2 + 3x = 3\) is a polynomial where x can be any number)

Understanding Zero-Knowledge Circuits: The Building Blocks

Don't worry if you don't understand the code right away. That's expected.

Let's start with the following code. It generates a circuit in Circom that computes a bit-wise OR operation on n-bit numbers.

Here's what the code looks like (intentionally buggy):

pragma circom 2.0.0;

// n == Num of bits
template BitwiseOr(n) {
    signal input x[n];
    signal input y[n];
    signal input z[n];

    var i;
    for(i = 0; i < n; i++){
        // OR
        x[i]+y[i] === z[i];

        // Binary Constraints
        x[i]*(x[i]-1) === 0;
        y[i]*(y[i]-1) === 0;
        z[i]*(z[i]-1) === 0;
    }
}

component main = BitwiseOr(4);

The computation we're representing is a bit-wise OR operation. The OR logic gate follows this truth table:

x y x OR y
0 0 0
0 1 1
1 0 1
1 1 1

We're implementing x OR y == z. Since we're working with bits, values can only be 0 or 1.

When we provide valid inputs and outputs, the circuit accepts; otherwise, it rejects.

For example, if x = 0 and y = 1, we must provide z = 1 for the circuit to accept.

But what exactly is a circuit? Think of it, for now, as a function structured like this:

// Pseudocode - not any specific language
function computation(inputs[], outputs[]) -> bool {

    // Operations that are the computation
    result = _computation(inputs[])

    // Some check to see if inputs after computation match outputs
    if (result == outputs[]) {
        return true   // VALID
    } else {
        return false  // REJECT
    }
}

Here's a simplified "circuit" representing a 1-bit OR operation:

// Pseudocode - not any specific language
function computationORGate(x, y, z) -> bool {

    // COMPUTATION (not just math operations, checks count too)

    // If statement for when X is 0 and Y is 0
    // imagine that code here...

    // OUR EXAMPLE OF X=0 AND Y=1
    if (x == 0 && y == 1) {
        if (z == 1) {
            return true   // VALID
        }
    } else {
        return false      // REJECT
    }

    // Imagine code for the other possible combinations...
    // Checks for when X is 1 and Y is 0 etc etc...
}

We can represent all basic logic gates (OR, AND, etc.) with circuits.

At their core, every algorithm on your computer runs on logic gates and other fundamental operations as building blocks. This foundation is what allows us to program virtually anything.

Complex systems like virtual machines or hash functions are simply logic gates arranged in sophisticated patterns to perform specialised tasks.

In physical terms, these algorithms execute through voltage representations within your computer's binary transistors. These function as logic gates that manipulate information through basic operations and ultimately execute any algorithm.

Just as developers reuse code modules to solve increasingly complex problems, we can combine basic gates to create sophisticated algorithms. This composability allows us to build complex circuits, such as those representing hash functions, from simpler building blocks. If you want to see what that looks like in practice, take a look at the SHA-256 circuit implementation in circomlib. It's complex, but you'll recognise the same patterns: templates, signals, and constraints, all the way down.

Circomlib (our main library)

Now that you understand these fundamentals, you'll find it much easier to navigate the circomlib GitHub repository. This library provides essential building blocks for representing algorithms as circuits. Let's take a look at the file containing the logic gate implementations:

https://github.com/iden3/circomlib/blob/35e54ea21da3e8762557234298dbb553c175ea8d/circuits/gates.circom#L37-L43

Here's the OR gate implementation, don't worry about understanding it fully yet, just observe its structure:

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

    out <== a + b - a*b;
}

With these building blocks and some dedicated effort, and perhaps 60 cups of coffee, developers can eventually create complex circuits like the Poseidon hash function. You can explore its code here:

https://github.com/iden3/circomlib/blob/35e54ea21da3e8762557234298dbb553c175ea8d/circuits/poseidon.circom

Back to our "basic" circuit:

Step 1: Reading the script

Great! Now we have a clear mental model of what our original code represents and how it can be useful.

Let's break it down step by step. Read the @note comments as you go through it:

// @note This just tells the compiler what version of Circom to use.
pragma circom 2.0.0;

// @note `template` is the "function", which is followed by its name.
// @note In this case I named it `BitwiseOr`. But it can be anything, even SHA256.
// @note Think of "n" as an argument, an input of the function, for now.
// @note This n is a natural number: n == Num of bits
template BitwiseOr(n) {

    //@note Act like `signal` doesn't exist.
    //@note Input just means input, easy. The inputs of the "function".
    //@note x --> names of the input variables. [] --> array.
    //@note If you just want a value you can just not make it an array with
    //@note --> `signal input x;`.
    //@note Notice that z is also declared as `signal input` here.
    //@note Normally the result would be a `signal output`, but we
    //@note deliberately make it an input so the caller provides the
    //@note expected output. This lets us demonstrate how a malicious
    //@note caller can pass invalid values later on.
    signal input x[n];
    signal input y[n];
    signal input z[n];

    //@note Classic scripting of a for loop, nothing fancy here.
    var i;
    for(i = 0; i < n; i++){

        // @note Adding the value of each member of the array
        // @note and constraining that to be `===` to that element in z.
        // @note but what is `===`?
        // @note Think of it, for now, as an equality constraint.
        // @note We'll clarify the exact meaning in Step 3.1.

        // OR
        x[i]+y[i] === z[i];

        // @note More weird operations. Multiply X by itself minus 1?
        // @note and then that has to be `===` to 0?
        // @note Don't worry, you will understand if you keep reading.

        // Binary Constraints
        x[i]*(x[i]-1) === 0;
        y[i]*(y[i]-1) === 0;
        z[i]*(z[i]-1) === 0;
    }
}

// @note Component is like the final circuit.
// @note 4 is the value passed to `n`.
// @note This circuit is called `main` and it is equal to
// @note "a circuit" named `BitwiseOr()`.
// @note This is how you reuse circuits and get composability.
component main = BitwiseOr(4);

Step 2: The polynomial plot-twist - what is a signal

Now let's add another layer to our mental model. You might be wondering: why doesn't Circom use regular function arguments like we'd expect in traditional programming?

The answer lies in polynomials. Yes, those mathematical expressions you learned about in school, like:

\(x^2 + 3x + 69 = 3\)

Circom is actually a language for describing polynomials. Each signal input isn't a programming variable, it's a variable in a polynomial equation. This is a fundamental shift in how we need to think about our code.

Consider how in math we write \(f(x) = x^2\), where \(x\) is the variable. We can extend this to multiple variables like \(f(x, y, Doge)\), where we have three variables: \(x\), \(y\), and \(Doge\).

Here's the mind-blowing part: thanks to some seriously fancy math, literally any computation can be represented as a polynomial. Yes, even Minecraft could theoretically be expressed as one. It would just be astronomically enormous.

If you're curious about how math can describe computations as polynomials, the resources at the end of this article are a great starting point. For now, let's keep building our understanding without getting lost in the mathematical weeds.

So, whenever a circuit is compiled or created, we're essentially generating a large polynomial that represents our computation.

This could be anything from virtual machine opcodes to a hash function or even a "simple" OR gate.

Step 2.1: Small but knowledge-expanding details

This explains why you cannot use traditional scripting logic with a signal. It's not a programming variable in the conventional sense, but rather a variable in the eventual static polynomial.

Now that we understand signal input, let's consider how arrays work. If we have just one variable, it's straightforward, but with arrays, how does the polynomial look?

For a 3-bit number, the compiler creates 3 separate variables: xbit0, xbit1, xbit2. The same happens for y and z.

What about our OR gate polynomial? While this is a complex topic, you can visualise it roughly as:

(xbit0 + ybit0) * (xbit1 + ybit1) * (xbit2 + ybit2) = zbit1 + zbit2 + zbit3

This isn't the actual polynomial, just a simplified representation. Interestingly, any polynomial variable in Circom can only be at most quadratic. This means you'll see terms like xbit0\(^2\), but never xbit0\(^3\) or higher powers. This is valuable knowledge when designing more complex circuits, but for now, consider it a helpful fact to keep in mind.

Now that we are equipped with more accurate mental models of different concepts, let's go back to one of the parts in our code.

Step 3: Understanding why the + here is an OR gate

// OR
x[i]+y[i] === z[i];

What exactly does that + symbol represent, and why is it functioning as an OR operation as the comment suggests?

Remember that in our circuit, x and y are binary values – they can only be 0 or 1.

Let's analyze all possible combinations of binary addition (+) in this truth table:

x y x + y x OR y result
0 0 0 0
0 1 1 1
1 0 1 1
1 1 2 1

Look now at the last column compared to the addition column. The + operation with binary inputs behaves almost exactly like an OR logic gate! The only difference occurs when both inputs are 1.

When we use === as an equality check (a simplification we're using for now), we're confirming whether the sum of x and y equals z. This effectively checks if x OR y = z, with that one caveat when both inputs are 1.

To make our addition perfectly mimic an OR gate in all cases, we need some mathematical cleverness. That's exactly what we saw in the circomlib code:

out <== a + b - a*b;

Here, a and b represent our x and y inputs, while out is our z output. When both inputs are 1, the computation becomes: 1 + 1 - 1 * 1 = 1, giving us the correct OR result.

For all other input combinations, this formula also produces results identical to an OR gate. By using this mathematical expression (which will be compiled to a polynomial), we ensure that our circuit behaves exactly like an OR gate for all possible binary inputs.

The compiler transforms this expression into a proper polynomial representation through an elegant mathematical process.

Then, when you provide inputs to the polynomial's variables where x OR y equals z, the circuit validates the computation and returns VALID. If the inputs don't satisfy this relationship, the circuit rejects them, maintaining the integrity of our OR gate implementation.

Technically, the + was used here to ease the educational process, but it is a bug:

x[i]+y[i] === z[i] does not fully represent an OR gate.

Step 3.1: Demystifying ===, <==, and <--

Earlier, we simplified === as an "equality check." Now that we understand constraints, let's be more precise.

In Circom, there are three key operators for working with signals:

  • === adds a constraint to the circuit. It does not assign a value. It tells the prover "this equation must hold." If it doesn't, the proof is invalid.
  • <-- assigns a value to a signal, but adds no constraint. The signal gets a value, but nothing enforces that the value is correct.
  • <== does both: it assigns a value and adds a constraint. It's equivalent to using <-- followed by ===.

This distinction is critical for security. Be careful, these slips are more common than you might think: using <-- (assign only) when <== (assign + constrain) was intended. The signal gets a value, so the code "works" during testing, but there's no constraint enforcing correctness, and a malicious prover can substitute any value they want.

In our circomlib OR gate example, out <== a + b - a*b both assigns the result to out and constrains it. If the author had written out <-- a + b - a*b instead, the output would look correct during honest execution, but a malicious prover could set out to anything and the circuit would accept it.

Step 4: The usefulness of the polynomial

This polynomial is what enables powerful ZK claims like: "I know inputs that produce these specific outputs under this computation."

For example, I can prove I know the input that, when hashed through a complex function, produces a particular output. Sound familiar to the ZK claims you've heard about?

Consider the classic age verification example: I can prove I'm over 18 without revealing my actual age. In ZK terms, I build a circuit representing the computation: myAge > 18, and generate a proof that this statement is true without exposing the value of myAge.

The circuit (or polynomial) takes two arguments: your age and the threshold (18). If they satisfy the computation (greater than), it returns VALID; otherwise, INVALID.

This > computation, as we saw with the OR gate, can be represented using basic logic gates and mathematical operations combined cleverly. You can see how the circomlib library implements comparators like GreaterThan here: circomlib/circuits/comparators.circom.

In this age verification circuit, thanks to advanced mathematical principles, you don't need to expose the inputs and outputs of the computation (i.e., the variables of the polynomial) if you don't want to. This is what actually makes it "zero knowledge."

Technically, ZK isn't achieved just by compiling Circom code. You must convert the compilation result into a ZK-SNARK or ZK-STARK (this is where fancier math comes in). Whatever ZK math model you use, this additional step is what actually achieves zero-knowledge.

By default, any signal you declare in Circom is private. This means no one verifying your computation will know its value. The exceptions are the main component's output signals, which are always public, and input signals that you explicitly mark as public.

Step 5: Understanding the other math - binary constraints

And, by the way, those other weird operations in our code are actually enforcing something crucial: ensuring all inputs are either 1 or 0. It's not very complex to understand how they do it.

If x[i] is anything other than 0 or 1, then the constraint === 0 won't be satisfied. You can verify this by plugging different values into x[i]. The constraint will only hold when x[i] is 0 or 1.

x x - 1 x * (x - 1) === 0?
0 -1 0 Yes
1 0 0 Yes
2 1 2 No
5 4 20 No

This is why x(x-1) = 0 elegantly enforces (constrains) all those values to be either 0 or 1, or the circuit will reject the proof:

// Binary Constraints
x[i]*(x[i]-1) === 0;
y[i]*(y[i]-1) === 0;
z[i]*(z[i]-1) === 0;

These constraints are not optional, they are needed because, as you can see, anyone can input any value into the polynomial's variables.

In our case, as seen in the table, the simplified + only behaves as an OR when all input variables are binary (recall that this + is intentionally buggy, the correct formula is x+y - x*y, but we're keeping it simple here for explanation purposes). So we must ensure they are binary, otherwise the circuit would behave in unexpected ways. And that is another vulnerability! Commonly known as an under-constrained variable vulnerability.

Imagine we omitted those 3 lines ensuring our circuit only accepts binary values.

Then we could pass x=5, y=3, z=8 and the circuit would accept it as valid, since 5 + 3 === 8 satisfies the constraint. But this is nonsensical: an OR gate only operates on binary values (0 or 1), and 5, 3, and 8 are not valid inputs or outputs for a boolean operation. Without binary constraints, nothing stops a user from passing any value they want, and the circuit has no way to reject it.

The same problem applies even if we use the correct OR formula (x+y - x*y === z) instead of our simplified +. An attacker can get clever, solve the math, and find inputs that satisfy the equation with nonsensical values. For example, let's say we want to force z = 100. As we control all inputs, we can just choose a random x, let's say 10, and solve for y:

10 + y - 10*y = 100
y = -10

And there you have it. We can pass x=10, y=-10, z=100 and our circuit will happily validate this proof, even though it makes absolutely no sense for a binary OR gate. This is why we must always constrain our variables to their expected domains.

Note: in practice, Circom operates over a finite prime field, so there are no actual "negative" numbers. The value -10 would be represented as a large field element (p - 10, where p is the field prime). The vulnerability principle remains the same: the attacker can find field elements that satisfy the unconstrained equation without representing a valid OR computation.

Step 6: Real-world impact of missing constraints

Imagine you're building an L2 blockchain that sends computation proofs to L1. For simplicity's sake, let's say your L2 only performs OR operations.

Without proper constraints, an attacker could craft a malicious proof with values like x=10, y=-10, z=100. The L1 contract's verify() function would accept this nonsensical proof as valid, a severe security failure.

The stakes are incredibly high: in L2 systems, fraudulent proofs can lead to fake transactions. An attacker could claim "Alice sent me 4 ETH on this L2 and then bridged it to L1", a complete lie that can result in real financial theft.

Now, taking everything we've learned, let's go back to our buggy code and see how it would look with the binary constraints removed:

pragma circom 2.0.0;

// n == Num of bits
template BitwiseOr(n) {
    signal input x[n];
    signal input y[n];
    signal input z[n];

    var i;
    for(i = 0; i < n; i++){
        // OR
        x[i]+y[i] === z[i];
    }
}

component main = BitwiseOr(4);

If you encountered this during a security review, you'd raise two critical findings:

CRITICAL: Missing binary constraints. Input signals must be constrained to binary values (0 or 1) to ensure the circuit behaves like a proper OR gate.

And the mathematical correctness issue we identified earlier:

HIGH: The operation x[i]+y[i] === z[i] does not correctly implement an OR gate. When both inputs are 1, the result should be 1, not 2. The correct implementation is x[i]+y[i]-x[i]*y[i] === z[i].

Key Takeaways

  1. Circuits are polynomials. Circom code compiles down to polynomial constraints that represent computations.
  2. Signals are polynomial variables, not programming variables. They behave differently from what you're used to in traditional languages.
  3. Every signal must be properly constrained. If a signal's domain isn't enforced (e.g., binary values for bits), the circuit will accept inputs that violate the intended computation.
  4. Under-constrained variables are the vulnerability class to watch for. Missing constraints, wrong operators (<-- vs <==), and incomplete domain checks are where real bugs hide.

Conclusion

You now have the foundational mental models needed to continue your ZK journey independently.

The vulnerabilities we've explored may seem straightforward, but they can appear in production code, similar to how "simple" vulnerabilities continue to appear in mature ecosystems like Solidity.

Understanding these fundamental issues is your first step toward becoming a proficient ZK developer or auditor.

To deepen your understanding, consider these questions:

  • Why does Circom operate over a prime field of approximately \(2^{254}\)? What are the implications?
  • How can you represent other logic gates like AND, XOR, or NOT?
  • On a high level, what is a zkSNARK and how does it relate to Circom circuits?
  • What are the performance implications of different constraint implementations?

This article covers just a fraction of what I learned during the one-week Circom bootcamp from RareSkills, but it provides enough foundation for you to continue exploring independently.

ZK technology is still in its early stages, with tremendous potential for growth and innovation. The field needs more curious minds like yours!

Resources to explore further

  • RareSkills ZK Book: A comprehensive, free resource covering ZK circuits, constraint systems, and Circom from the ground up.
  • Circom Documentation: The official Circom language reference and guides.
  • circomlib: The standard library of reusable Circom circuits (gates, comparators, hash functions, and more).
  • RareSkills: The organization behind the Circom bootcamp referenced in this article.