-
Notifications
You must be signed in to change notification settings - Fork 358
Description
We are developing a mutation-guided differential testing tool for ZK compilers. By comparing Circom against Noir, ZoKrates, Cairo, and Leo on the same circuits with adversarial inputs, we found the following 10 issues in Circom 2.2.3. Each issue includes a minimal reproducer.
Issue 1: Runtime inputs exceeding field prime are silently reduced
Summary
When runtime inputs (via input.json) exceed the field prime p, the witness generator silently reduces them modulo p without any warning or error. A developer passing a = p+5 gets the same result as a = 5 with no indication that the input was modified.
Environment
- Circom: 2.2.3 (latest master)
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a * a + a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
# Normal input — correct
echo '{"a": "5"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # output = 31 ✓
# Input exceeding field prime — silently wrong!
echo '{"a": "21888242871839275222246405745257275088548364400416034343698204186575808495622"}' > input.json
# This is p+5, should warn but silently becomes 5
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # output = 31 (same as a=5!) — no warning!Results
Circuit: out = a² + a + 1
| Input (a) | Expected behavior | Actual output |
|---|---|---|
| 5 | 31 | ✅ 31 |
| p+5 | Warning or error | ❌ 31 (silently treated as a=5) |
| 2p+5 | Warning or error | ❌ 31 (silently treated as a=5) |
| 100p+5 | Warning or error | ❌ 31 (silently treated as a=5) |
| p | Warning or error | ❌ 1 (silently treated as a=0) |
| p² | Warning or error | ❌ 1 (silently treated as a=0) |
| 2²⁵⁶ | Warning or error | ❌ Silent reduction |
| 10¹⁰⁰ | Warning or error | ❌ Silent reduction |
Expected Behavior
The witness generator should either:
- Reject inputs >= p with an error:
Error: input value exceeds field prime - Warn the user:
Warning: input 'a' exceeds field prime, reducing modulo p
Comparison with Noir
Noir (nargo) correctly rejects oversized inputs:
Failed to deserialize inputs: The value exceeds the field prime
Issue 2: Out-of-bounds array access returns arbitrary WASM memory
Summary
Dynamic array access with an out-of-bounds index returns arbitrary WASM memory values instead of failing with an error. This is a soundness issue — a malicious prover could exploit this to read adjacent memory and forge witnesses.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input idx;
signal arr[3];
signal output out;
arr[0] <== 10;
arr[1] <== 20;
arr[2] <== 30;
out <-- arr[idx];
}
component main = Test();circom test.circom --r1cs --wasm --output .
# Valid index — correct
echo '{"idx": "1"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # output = 20 ✓
# Out-of-bounds index — returns garbage!
echo '{"idx": "3"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 6404 instead of error!Results
| idx | Expected | Actual |
|---|---|---|
| 0 | 10 | ✅ 10 |
| 1 | 20 | ✅ 20 |
| 2 | 30 | ✅ 30 |
| 3 | Error | ❌ 6404 (arbitrary memory) |
| 100 | Error | ❌ 0 (arbitrary memory) |
| -1 | Error | ❌ Large field element (arbitrary memory) |
Expected Behavior
The witness generator should fail with: Error: Array index out of bounds
Comparison with Noir
Noir correctly rejects: error: Index out of bounds, array has size 3 but index was 3
Issue 3: Compile-time constants exceeding field prime silently reduced
Summary
Integer constants larger than the field prime in circuit source code are silently reduced modulo p without any compiler warning. The constant p+1 becomes 1 with no indication to the developer.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output c;
// p+1 where p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
c <== a + 21888242871839275222246405745257275088548364400416034343698204186575808495618;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": "5"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 6 (5+1) instead of expected large number!Results
| Constant | Expected | Actual |
|---|---|---|
| p+1 | Warning or error | ❌ Silently becomes 1 |
| 2p | Warning or error | ❌ Silently becomes 0 |
| 100p+42 | Warning or error | ❌ Silently becomes 42 |
Expected Behavior
The compiler should emit a warning: warning: constant exceeds field prime, will be reduced modulo p
Comparison with Noir
Noir correctly rejects: error: Integer literal is too large
Issue 4: Division 0/0 silently returns 0 instead of failing
Summary
When both numerator and denominator are zero (0/0), the witness generator silently returns 0 instead of failing. Division by zero with non-zero numerator correctly fails, but the 0/0 case slips through because 0 * q === 0 is satisfied for any q, and the witness generator picks q = 0.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal input b;
signal output q;
q <-- a / b;
q * b === a;
}
component main = Test();circom test.circom --r1cs --wasm --output .
# Non-zero / zero — correctly fails
echo '{"a": "10", "b": "0"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
# Error: Assert Failed ✓
# Zero / zero — silently returns 0!
echo '{"a": "0", "b": "0"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns q=0, no error!Results
| Input | Expected | Actual |
|---|---|---|
| a=10, b=5 | q=2 | ✅ q=2 |
| a=10, b=0 | Error | ✅ Error |
| a=0, b=0 | Error | ❌ q=0 (no error!) |
Why This Is Dangerous
The constraint q * b === a is satisfied when q=0, b=0, a=0 (since 0*0 === 0). But 0/0 is undefined — any value of q would satisfy the constraint when both a=0 and b=0. The witness generator picks q=0 arbitrarily, masking an under-constrained circuit.
Expected Behavior
The witness generator should fail: Error: Division by zero (0/0 is undefined)
Comparison with Noir
Noir rejects all division by zero: Failed constraint (including 0/0)
Issue 5: Negative inputs silently converted to field elements
Summary
Negative integer values in input.json (e.g., "-5") are silently converted to field elements (p - 5) without any warning. While mathematically correct for field arithmetic, this violates the principle of least surprise.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a * a;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": "-1"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 1 — (-1)² = 1, no warningResults
| Input | Interpreted as | Output (a²) |
|---|---|---|
| a=5 | 5 | ✅ 25 |
| a=-1 | p-1 (no warning) | ❌ 1 |
| a=-5 | p-5 (no warning) | ❌ 25 |
| a=-100 | p-100 (no warning) | ❌ 10000 |
Expected Behavior
Warning: Warning: negative input 'a' will be interpreted as field element p-|a|
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| a=-1 | Accepts | Rejects | Rejects | Accepts | Rejects |
Issue 6: Boolean JSON value true silently coerced to field element 1
Summary
JSON boolean value true in input.json is silently accepted and treated as the field element 1. This is type confusion — the circuit expects a numeric field element, not a boolean.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": true}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 2 — true treated as 1, no errorExpected Behavior
Error: Error: expected numeric value for signal 'a', got boolean
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| a=true | ❌ Accepts (1) | Rejects | Accepts (0) | Rejects | Accepts (0) |
Issue 7: Empty string "" silently coerced to field element 0
Summary
An empty string "" in input.json is silently accepted and treated as the field element 0. This is type confusion — an empty string is not a valid field element representation.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": ""}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 1 — empty string treated as 0, no errorExpected Behavior
Error: Error: empty string is not a valid field element
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| a="" | ❌ Accepts (0) | Rejects | Accepts (0) | Rejects | Accepts (0) |
Issue 8: Octal literal inputs silently accepted
Summary
Octal literals (e.g., "0o77") in input.json are silently parsed as their decimal equivalent (63). This is JavaScript BigInt behavior inherited by the WASM witness generator — BigInt("0o77") returns 63n.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": "0o77"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 64 — 0o77 = 63 in octal, 63+1 = 64Results
| Input | Interpreted as | Output (a+1) |
|---|---|---|
| "0o77" | 63 (octal) | ❌ 64 |
| "0o10" | 8 (octal) | ❌ 9 |
Expected Behavior
Error: Error: '0o77' is not a valid decimal field element
Field element inputs should only accept decimal integers (and optionally hex with 0x prefix).
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| 0o77 | ❌ Accepts (63) | Rejects | Rejects | Rejects | Rejects |
Circom is the only compiler that accepts octal literals.
Issue 9: Binary literal inputs silently accepted
Summary
Binary literals (e.g., "0b1010") in input.json are silently parsed as their decimal equivalent (10). Same root cause as Issue 8 — JavaScript BigInt("0b1010") returns 10n.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": "0b1010"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 11 — 0b1010 = 10 in binary, 10+1 = 11Results
| Input | Interpreted as | Output (a+1) |
|---|---|---|
| "0b1010" | 10 (binary) | ❌ 11 |
| "0b11111111" | 255 (binary) | ❌ 256 |
Expected Behavior
Error: Error: '0b1010' is not a valid decimal field element
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| 0b1010 | ❌ Accepts (10) | Rejects | Rejects | Rejects | Rejects |
Circom is the only compiler that accepts binary literals.
Issue 10: Negative zero (-0) silently accepted
Summary
The string "-0" in input.json is silently accepted and treated as field element 0. While -0 equals 0 in most contexts, accepting it without warning indicates insufficient input validation.
Environment
- Circom: 2.2.3
- Node: v18.17.1
- snarkjs: 0.7.6
- OS: Ubuntu
Reproducer
pragma circom 2.0.0;
template Test() {
signal input a;
signal output out;
out <== a + 1;
}
component main = Test();circom test.circom --r1cs --wasm --output .
echo '{"a": "-0"}' > input.json
node test_js/generate_witness.js test_js/test.wasm input.json w.wtns
snarkjs wtns export json w.wtns w.json
cat w.json # Returns 1 — "-0" treated as 0, no warningExpected Behavior
Either reject: Error: '-0' is not a valid field element
Or warn: Warning: '-0' interpreted as 0
Comparison with Other Compilers
| Input | Circom | Noir | ZoKrates | Cairo | Leo |
|---|---|---|---|---|---|
| -0 | ❌ Accepts (0) | Rejects | Rejects | Accepts (0) | Rejects |
Summary Table
| # | Bug | Category |
|---|---|---|
| 1 | Runtime inputs >= p silently reduced | Input validation |
| 2 | OOB array access returns WASM memory | Memory safety |
| 3 | Compile-time constants >= p silently reduced | Compiler warning |
| 4 | Division 0/0 silently returns 0 | Arithmetic |
| 5 | Negative inputs silently converted | Input validation |
| 6 | Boolean true coerced to 1 |
Type confusion |
| 7 | Empty string "" coerced to 0 |
Type confusion |
| 8 | Octal literals (0o77) accepted |
Input parsing |
| 9 | Binary literals (0b1010) accepted |
Input parsing |
| 10 | Negative zero (-0) accepted |
Input validation |