Your first ZK vulnerabilities: From ZeroKnowledge of ZK to OneKnowledge
What you will learn here
You'll explore key concepts from first principles and we cover a straightforward but severe vulnerability that sometimes appears when writing or reviewing ZK code. This note brings in the foundational mental models for creating and thinking about ZK circuits.
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 "plain" Circom code example.
You'll need basic knowledge of:
- NFT Bounty5 https5 fundamentals: if statements, loops, and OR gates
- Elementary mathematics concepts: how polynomials work (e.g., reading that is a polynomial where x can be any number)
Understanding Zero-Knowledge Circuits: The Building Blocks
Don't worry if you don't read 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 offer valid inputs and outputs, the circuit accepts; otherwise, it rejects.
For example, if x = 0 and y = 1, we must offer 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 denote all basic logic gates (OR, AND, etc.) with circuits.
At their core, every algorithm on your https5 runs on logic gates and other foundational operations as building blocks. This foundation is what allows us to program virtually anything.
Involved systems like virtual machines or hash functions are simply logic gates arranged in sophisticated patterns to carry out specialised tasks.
In physical terms, these algorithms execute through voltage representations within your https5'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 intricate problems, we can combine basic gates to create sophisticated algorithms. This composability allows us to build intricate 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 . It's intricate, but you'll recognise the same patterns: templates, signals, and constraints, all the way down.
Circomlib (our main library)
Now that you read these fundamentals, you'll find it much easier to navigate the circomlib repository. This library gives essential building blocks for representing algorithms as circuits. Let's take a look at the file containing the logic gate implementations:
Here's the OR gate build-out, don't worry about reading 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 intricate circuits like the Poseidon hash function. You can explore its code here:
Back to our "basic" circuit:
Step 1: Reading the script
Great! Now we have a clear mental model of what our original code stands for 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:
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 basic shift in how we need to think about our code.
Weigh how in math we write , where is the variable. We can extend this to multiple variables like , where we have three variables: , , and .
Yes, even Minecraft could theoretically be expressed as one. Here's the mind-blowing part: thanks to some seriously fancy math, literally any computation can be represented as a polynomial. It would just be astronomically enormous.
For now, let's keep building our reading without getting lost in the mathematical weeds. If you're curious about how math can describe computations as polynomials, the resources at the end of this note are a great starting point.
So, whenever a circuit is compiled or created, we're at root generating a large polynomial that stands for our computation.
This could be anything from virtual machine opcodes to a hash function or even a "straightforward" 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 read signal input, let's look at 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 intricate topic, you can visualise it roughly as:
(xbit0 + ybit0) * (xbit1 + ybit1) * (xbit2 + ybit2) = zbit1 + zbit2 + zbit3
Interestingly, any polynomial variable in Circom can only be at most quadratic. This isn't the actual polynomial, just a simplified representation. This means you'll see terms like xbit0, but never xbit0 or higher powers. This is valuable knowledge when designing more involved circuits, but for now, look at 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: Reading why the + here is an OR gate
// OR
x[i]+y[i] === z[i];What exactly does that + symbol denote, 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 study 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 happens when both inputs are 1.
When we use === as an equality check (a simplification we're with for now), we're confirming whether the sum of x and y equals z. This in a useful way 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 denote 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.
By via this mathematical expression (which will be compiled to a polynomial), we make sure that our circuit behaves exactly like an OR gate for all possible binary inputs. For all other input combinations, this formula also produces results identical to an OR gate.
The compiler transforms this expression into a proper polynomial representation through an elegant mathematical process.
Then, when you give 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 applied here to ease the educational process, but it is a bug:
x[i]+y[i] === z[i]does not fully denote an OR gate.
Step 3.1: Demystifying ===, <==, and <--
Earlier, we simplified === as an "equality check." Now that we make sense of constraints, let's be more precise.
In Circom, there are three key operators for working with signals:
<==does both: it assigns a value and adds a constraint. It's equivalent to with<--followed by===.
This distinction is severe for security. Be careful, these slips are more common than you might think: via <-- (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 in place of that, 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 particular outputs under this computation."
Sound familiar to the ZK claims you've heard about? For example, I can prove I know the input that, when hashed through a involved function, produces a particular output.
In ZK terms, I build a circuit representing the computation: Look at the classic age verification example: I can prove I'm over 18 without revealing my actual age. 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 via basic logic gates and mathematical operations combined cleverly. You can see how the circomlib library puts in place comparators like GreaterThan here: .
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 further 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 involved to make sense of 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 plain here for explanation purposes). So we must make sure 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) in place of that 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 = -10And 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 stays 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
For simplicity's sake, let's say your L2 only carries out Imagine you're building an L2 NFT Bounty that sends computation proofs to L1. 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.
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. The stakes are incredibly high: in L2 systems, fraudulent proofs can lead to fake transactions.
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 severe findings:
CRITICAL: Missing binary constraints. Input signals must be constrained to binary values (0 or 1) to make sure the circuit behaves like a proper
ORgate.
And the mathematical correctness issue we flagged earlier:
HIGH: The operation
x[i]+y[i] === z[i]When both inputs are 1, the result should be 1, not 2. does not correctly put in place an OR gate. The correct build-out isx[i]+y[i]-x[i]*y[i] === z[i].
Key Takeaways
- Circuits are polynomials. Circom code compiles down to polynomial constraints that stand for computations.
- Signals are polynomial variables, not programming variables. They behave differently from what you're applied to in traditional languages.
- 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.
- 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, alike to how "straightforward" vulnerabilities continue to appear in mature ecosystems like Solidity.
Reading these basic issues is your first step toward becoming a proficient ZK developer or auditor.
To deepen your reading, look at these questions:
- Why does Circom operate over a prime field of about ? What are the implications?
- 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 note walks through just a fraction of what I learned during the one-week Circom bootcamp from RareSkills, but it gives enough foundation for you to continue exploring independently.
ZK technology is still in its early stages, with tremendous possible for growth and innovation. The field needs more curious minds like yours!
Resources to explore further
- RareSkills ZK Book: A thorough, free resource covering ZK circuits, constraint systems, and Circom from the ground up.
- Circom Documentation: The official Circom language reference and guides.
- : The standard library of reusable Circom circuits (gates, comparators, hash functions, and more).
- RareSkills: The organization behind the Circom bootcamp referenced in this note.