常见 Circom 陷阱及其规避方法——第 2 部分

本文继续讨论 Circom 常见陷阱:组件输出若未被显式约束,比较器和布尔门可能被“空跑”;组件输入若未在调用处先做范围或布尔约束,下游逻辑可被非预期取值绕过;同时 Circom 的比较运算 <><=>= 实际基于有符号字段代表元,接近 p/2 的值会出现反直觉结果。作者通过 IsEqual、AND、BigLessThan 等例子说明了漏洞成因与修复方式,并强调这些比较只影响见证生成,不是电路内约束。

缩略图

第 1 部分 中,我们讨论了误用 hints 和 assertions 如何导致电路约束不足。我们还看到了在使用 circomlib 的 Num2BitsBits2Num 时,别名(aliasing)bug 可能带来的问题。在这篇文章中,我们将深入探讨编写 Circom 时需要注意的另外三个“陷阱”。话不多说,让我们直接开始!

输出约束很容易被遗忘

在 Circom 中,许多模板(template)都假定其调用者会显式约束它们的输出。如果一个组件(component)在使用时没有对其输出施加任何约束,这通常是一个危险信号,并可能导致严重的安全漏洞!

例如,考虑 circomlib 的 IsEqual() 模板:

template IsEqual() {
    signal input in[2];
    signal output out;

    component isz = IsZero();

    in[1] - in[0] ==> isz.in;

    isz.out ==> out;
}

下面的代码实例化了这个模板,看起来是在强制 x == y。但实际上,xy 仍然可以取任意值。

pragma circom 2.1.6;

include "circomlib/comparators.circom";

template AssertEquality() {
    signal input x;
    signal input y;

    component eq = IsEqual();
    eq.in[0] <== x;
    eq.in[1] <== y;

    // BUG: 没有约束 eq.out 必须为 1!
}

component main = AssertEquality();

// 这个见证(witness)会满足电路,
// 尽管 x 并不等于 y。
/* INPUT = {
    "x": "42",
    "y": "24"
} */

请记住,IsEqual() 仅在 xy 相等时输出 1。然而,在上面的例子中,其输出并未被约束为 1!结果,输出可以取任意值,也就是说,这两个输入实际上并未被强制相等。你可以在 zkREPL 中测试这一点:上面的代码运行不会报错,尽管 xy 的见证值明显不相等。

修复方法很简单:在 AssertEquality() 模板的末尾添加 eq.out === 1;。加上这个约束后,只要 xy 不同,电路就会正确抛出错误。特别是,见证 x = 42y = 24 将不再满足该电路。

再举一个例子,下面的代码看起来是在强制 xy 都等于 1,但由于同样的原因,它并没有做到。

include "circomlib/gates.circom";

template AssertAndIsTrue() {
    signal input x;
    signal input y;

    component and = AND();
    and.a <== x;
    and.b <== y;

    // BUG: 没有约束 and.out 必须为 1!
}

注意:即使输出被约束为 1,上述模板仍然不安全。你能看出错误吗?如果不能,也不用担心!我们将在下一节中讨论。

再看一个更实际的例子,考虑 Circom-Pairing 库。它依赖大整数电路来表示大于域大小的值。为了强制某些信号保持在预期范围内,该库使用了 BigLessThan 模板:

template BigLessThan(n, k){
    signal input a[k];
    signal input b[k];
    signal output out;
    ...
}

a[k]b[k] 是输入信号数组,分别表示两个大整数 ab 的“肢体”(limbs)。BigLessThan 组件接收这些肢体作为输入,并在 a < b 时输出 1,否则输出 0

注意:如果你刚接触大整数,自己编写一个简单的 BigInt 库 是一个很好的入门方式。另外,如果术语“肢体”听起来有些奇怪,这里 有一篇短文解释了该术语的来源。

Circom-Pairing 库在其 CoreVerifyPubkeyG1 模板中使用了多个 BigLessThan 组件,以验证所提供公钥的每个肢体都在范围内:

template CoreVerifyPubkeyG1(n, k){
  ...
  var q[50] = get_BLS12_381_prime(n, k);

  component lt[10];

  for(var i=0; i<10; i++){
    lt[i] = BigLessThan(n, k);
    for(var idx=0; idx<k; idx++)
      lt[i].b[idx] <== q[idx];
  }

  for(var idx=0; idx<k; idx++){
    lt[0].a[idx] <== pubkey[0][idx];
    lt[1].a[idx] <== pubkey[1][idx];
    ...
    lt[9].a[idx] <== pubkey[9][idx];
  }
  ...

其意图是约束 pubkey < q,以确保公钥是正确格式化的大整数。然而,输出 lt[i].out 从未被约束!与我们之前的简单示例一样,这意味着_电路实际上并未强制_ pubkey < q。恶意证明者可以提供超出范围的公钥,并且仍然生成有效的证明,因为没有检查 lt[i].out 是否等于 1

同样,修复方法很简单:确保每个 lt[i].out 都等于 1。一种高效的方法是在第二个 for 循环之后添加一个约束,要求所有十个输出之和等于 10

...

for(var idx=0; idx<k; idx++){
  lt[0].a[idx] <== pubkey[0][idx];
  lt[1].a[idx] <== pubkey[1][idx];
  ...
  lt[9].a[idx] <== pubkey[9][idx];
}

// 约束每个 lt[i].out 都等于 1。
for(var i=0; i<10; i++){
    r += lt[i].out;
}
r === 10;

...

关于此问题的更详细解释,我推荐阅读这篇博客文章

最后,我想强调:并非每个未使用的输出都自动是 bug。 例如,circomlib 的 Num2Bits(n) 常用于高效地对给定信号进行范围检查,以确认其是否在 [0,2n) 内。用于此目的时,不约束其输出是完全没问题的。有关此类用法的示例,请参见此处。(如果你对 Circom 还比较陌生,这可能暂时不太好理解。没关系!我们很快会在本系列的后续文章中带你了解范围检查。)

要点:组件的输出不会自动被约束。你必须显式添加约束。没有约束时,输出可以取任意值。这个陷阱常见于比较器和布尔门,但不仅限于它们。有些模板,例如 circomlib 的 Num2Bits,在某些用例中未约束输出是安全的。但大多数时候,未约束的输出都应该引起你的警惕。
\

输入约束也很容易被遗忘\


正如未约束的输出是严重 bug 的常见来源一样,未约束的组件_输入_同样可疑,因为许多模板假定你会在调用处自行处理输入约束。

举一个简单的例子,再看一次 circomlib 的 AND 门:
\

template AND() {\
    signal input a;\
    signal input b;\
    signal output out;\
\
    out <== a*b;\
}\
```\
\
此门的标准用例是表示两个布尔标志是否为真。例如,它可能被天真地如下使用:\
\
```\
include "circomlib/gates.circom";\
\
template RequireBothTrue() {\
    signal input flagA;\
    signal input flagB;\
\
    component andGate = AND();\
    andGate.a <== flagA;\
    andGate.b <== flagB;\
\
    // 我们的意图是强制:\
    // “两个标志都必须为 1”\
    andGate.out === 1;\
}\
```\
\
此模板对于布尔值行为正确,但关键在于:circomlib 的 `AND` 门并不强制其自身的输入假设,即它从不检查其输入是否实际为布尔值。它只是假定它们是布尔值。换句话说,我们可以用完全无意义的输入欺骗 `AND` 门输出“真”:\
\
```\
pragma circom 2.1.6;\
\
include "circomlib/gates.circom";\
\
template RequireBothTrue() {\
    signal input flagA;\
    signal input flagB;\
\
    component andGate = AND();\
    andGate.a <== flagA;\
    andGate.b <== flagB;\
\
    // 我们的意图是强制:\
    // “两个标志都必须为 1”\
    andGate.out === 1;\
}\
\
component main = RequireBothTrue();\
\
/*\
我们可以欺骗 AND 门输出 "true",\
尽管我们使用的是完全无意义的输入。\
\
注意:回想一下,circomlib 的 AND 门只是将其\
两个输入相乘。为了确保 flagA * flagB = 1(从而 andGate.out = 1),\
我们将 flagB 设置为 flagA 的模逆。\
*/\
\
/* INPUT = {\
    "flagA": "42",\
    "flagB": "15113310554365213843932042062201451846854823038382499903982093366921391580307"\
} */\
```\
\
显然,这不是我们写下“两个标志都必须为 1”时想要的语义。不过修复方法很简单:我们必须在输入使用前约束它们!\
\
```\
include "circomlib/gates.circom";\
\
template RequireBothTrueFixed() {\
    signal input flagA;\
    signal input flagB;\
\
    // 在将它们作为 AND 门的输入之前,\
    // 先强制两个标志都是布尔值。\
    flagA * (flagA - 1) === 0;\
    flagB * (flagB - 1) === 0;\
\
    component andGate = AND();\
    andGate.a <== flagA;\
    andGate.b <== flagB;\
\
    // 现在这 *真的* 表示“两个标志都必须为 1”。\
    andGate.out === 1;\
}\
```\
\
**要点:模板通常假定其输入已满足某些属性,但不会通过约束来强制这些输入假设。如果你忘记在调用处强制这些前置条件,攻击者就可以用无意义的输入满足下游约束。**\
\
## 比较运算作用于有符号整数\
\
很容易认为,由于 Circom 在有限域上工作,信号和变量总是被视为 \[0,p) 中的无符号整数。然而,这个假设是错误的!\
\
作为一个反例,让我们考虑 Circom 对比较运算符 `<`、`>`、`<=` 和 `>=` 的定义。仔细查看后你会发现,这些运算符依赖于一个[内部函数](https://github.com/iden3/circom/blob/ad44e915a12bb047b05745c2884aad9cc8326bc6/circom_algebra/src/modular_arithmetic.rs#L154-L161),val:\[0,p)→(−p/2,p/2\],其定义如下:\
\
val(z)={z−p, 若 p/2+1≤z<pz, 否则\
\
换句话说,在比较两个数之前,Circom 会先将它们从 \[0,p) 映射到 (−p/2,p/2\],如下图所示。\
\
![val 映射示意图](https://img.learnblockchain.cn/2026/04/21/val.png)\
\
更正式地说,Circom 通过以下方式定义关系运算符:\
\
x◻y 当且仅当 val(x mod p)◻val(y mod p)\
\
其中 ◻ 表示 `<`、`≤`、`>` 或 `≥` 之一,而 “mod p” 则考虑到了这样一个事实:在应用 val 之前,[Circom 会先将数字映射到区间](https://github.com/iden3/circom/blob/ad44e915a12bb047b05745c2884aad9cc8326bc6/circom_algebra/src/modular_arithmetic.rs#L163)\[0,p)。\
\
如果你不小心,这种行为在见证生成期间会导致令人惊讶的结果,如下例所示:\
\
```\
pragma circom 2.1.6;\
\
template SurprisingWitGen() {\
    signal input x;\
    signal input y;\
    signal isGreater;\
\
    // 我们的意图:如果 x > y,则 isGreater = 1,否则为 0。\
    isGreater <-- x > y;\
\
    log(isGreater);\
}\
\
component main = SurprisingWitGen();\
\
/*\
注意:在这个特定例子中,我们展示 p/2 > p/2 + 1。\
使用 Circom 的默认素数,我们有:\
p/2 = 10944121435919637611123202872628637544274182200208017171849102093287904247808\
*/\
\
/* INPUT = {\
    "x": "10944121435919637611123202872628637544274182200208017171849102093287904247808",\
    "y": "10944121435919637611123202872628637544274182200208017171849102093287904247809"\
} */\
```\
\
这表明在 Circom 中,p/2 > p/2 + 1 确实是一个真命题。为了亲自验证,我建议你在 [zkREPL](https://zkrepl.dev/) 中运行上面的代码。\
\
现在你可能会想:“Circom 究竟为什么要故意在比较两个值之前应用如此令人困惑的重映射?!”\
\
好问题!让我们考虑一下如果不这样做会发生什么:如果不应用 val,域元素在比较前就只是被映射到 \[0,p)。特别地,这意味着 −1 的表示将是 p−1,因此 Circom 会将命题 0 < −1 (≡ p−1) 视为真。\
\
所以 Circom 的理由如下:“我们无法避免在比较中出现一个奇怪的环绕点,因为有限域本质上是循环的。然而,我们宁愿把这个不连续点‘放远一点’,放在像 p/2+1 这样巨大的数字上,而不是放在最常用范围的正中间,即零附近的范围。这样一来,‘正常’范围内的比较行为更符合直觉,而奇怪的边界情况只出现在非常大的数字上。”\
\
但请注意,这个选择纯粹是定义问题。有限域并不存在一个固有的、‘有意义’的元素排序,因此这里没有绝对的‘对’或‘错’。有些人可能更喜欢 Circom 的有符号算术,而另一些人可能觉得无符号算术更自然。只是碰巧 Circom 的开发者选择了前者。因此,无论我们喜欢与否,都必须按此处理。\
\
**要点:Circom 的 `<`、`>`、`<=` 和 `>=` 作用于_有符号_的域表示。如果你的见证生成逻辑假设的是无符号语义,请确保将 p/2+1 这个‘截止点’考虑在内。**\
\
最后补充一点,请记住 Circom 内置的比较从来不是实际电路逻辑的一部分!像 `<`、`>`,甚至 `==` 这样的运算符,并不会像在传统编程语言中那样在运行时‘执行’。Circom 电路只是一组方程(仅由域元素上的加法和乘法组成),在生成和验证证明时必须成立。因此,比较运算符仅在见证生成阶段使用,而不在电路本身中。\
\
话虽如此,_确实_可以直接在电路层面实现比较逻辑!这正是 [circomlib 的比较器模板](https://github.com/iden3/circomlib/blob/master/circuits/comparators.circom)(例如 `LessThan`)的用途。\
\
我们将在本系列的后续文章中更详细地探讨这些模板及其常见陷阱。敬请期待!\
\
_特别感谢 Giorgio Dell'Immagine、[0xAlexSR](https://x.com/0xAlexSR)、[0xStrapontin](https://x.com/0xStrapontin)、[0xteddav](https://x.com/0xteddav) 和 Georgios Raikos 抽出宝贵时间帮助我润色这篇文章。_\
\
**zkSecurity** 提供密码学系统的审计、研究和开发服务,包括零知识证明、MPC、FHE 和共识协议。\
\
\
[了解更多 →](https://zksecurity.xyz/)

>- 原文链接: [blog.zksecurity.xyz/post...](https://blog.zksecurity.xyz/posts/circom-pitfalls-2)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zksecurity
zksecurity
Security audits, development, and research for ZKP, FHE, and MPC applications, and more generally advanced cryptography.