零知识证明验证器:虚假证明为何被误接受?审计排查要点 开年第一周就接到一个让人头疼的单子。链上合约做了一个零知识证明验证用来处理提款请求结果测试网阶段发现某笔提款在proof明显不合规的情况下验证函数竟然返回了true。顺着日志往下挖问题并不在证明本身而是合约验证逻辑里把一个本不该放过的“虚假证明”当成了有效输入。这就是我常说的“一个虚假证明的错误”——注意这里的措辞它不代表攻击者伪造得像更不是我们常说的证明系统被破解而是系统在接受证明的那一端对“什么才是有效证明”的判断标准出了偏差。这个系列第一篇我梳理了零知识证明系统的整体安全模型比如验证方程、公开输入、可信设置这些基础概念。这篇是第二篇重点完全放在实操上我会用一个最小可复现的案例把“虚假证明为什么会被错误地当成有效”这条链路拆给你们看同时整理一份审计视角的排查清单。适合正在写链上验证器、做跨链桥安全评审、或者刚进零知识证明应用开发这一行的人。如果你只是听过Groth16、Plonk这些名词这篇也能帮你建立“验证到底验的是什么”的直觉。1. 先搞清楚“虚假证明”到底假在哪1.1 证明本身不假假的是“陈述”我在审计和排障过程中接触到的“假证明”绝大多数都不是密码学层面的伪造而是证明内容与系统业务状态不匹配。举一个很简单的例子某个隐私转账合约要求用户提交一段proof声明“我知道某个merkle leaf对应的nullifier还没有被使用”。用户确实能生成一段数学上完全合法的证明——他知道叶子、知道路径、知道nullifier验证方程全部通过。但如果这个nullifier实际上已经在前一笔交易里被消耗过了呢合约如果只验证证明有效却没有强制校验链上保存的“已使用集合”这个合法生成的证明就会变成业务上的“虚假证明”并被系统当作存款凭证执行。所以我在跟开发者沟通时习惯把“虚假证明”分成三个层次密码学无效的证明比如Groth16验证方程不成立曲线点不在群内这类证明一眼就该拒绝。密码学有效但对当前业务无效的证明证明本身成立但public inputs对不上当前链上状态比如声明的老根、已经被花过的nullifier、错误的操作码。实现缺陷导致的“伪通过”验证合约写得不对把一个本来无效的证明误判为有效这才是最危险的。很多人讨论虚假证明时只盯着第一层实际上我在实际项目里遇到的更多是第二层和第三层。“一个虚假证明的错误”这个标题里的“错误”我理解为两层一是证明内容与业务规则相悖二是验证实现里隐藏的逻辑错误。这两种情况往往还会叠加出现排查的时候尤其痛苦。1.2 最容易踩坑的系统类型从场景上看三类系统最常出现“虚假证明被接受”的问题。第一类是资产跨链桥。桥合约验证source chain传过来的header或receipt证明验证通过后铸造目标链资产。问题往往出在“验证范围不够”。比如只验证了收据存在却没有验证收据对应的链 ID、合约地址、事件签名是否在允许列表里。攻击者可以搬来一条测试网的合法收据证明放到主网桥合约里如果验证逻辑只检查“收据哈希在某个区块里”桥就会傻傻地接受。这里的证明本身是真的但“源链、源合约、事件类型”这些隐含断言没有进入证明最终等价于接受了一份虚假证明。第二类是 Layer2 提款桥或应用内的状态迁移逻辑。合约调用了验证库传入proof和public inputs验证库返回true随后合约立刻解锁资产或修改状态。如果public inputs的编码顺序在合约与前端生成端不一致就会发生“证明验证成功但对应的业务参数完全错位”的灾难。比如前端把amount放在public inputs[0]合约却把public inputs[0]当成recipient。证明的密码学有效性没问题业务语义全乱了一次完美的“虚假证明错误”。第三类是隐私支付协议。这类协议极度依赖nullifier、commitment tree、merkle root的状态一致性任何一环没有跟链上状态强绑定都可能让“重放”、“双花”以合法证明的形式发生。我在给某个隐私协议做代码评审时第一件事永远是找nullifier是否真正被写入存储。如果你还没遇到过这类问题说明项目可能还没真正跑到风险边界。一旦涉及资产转移、状态根更新、跨链消息这句“proof 合法”和“请求合法”之间往往隔着一条巨大的业务鸿沟。2. 验证器实现里最致命的几个错误点2.1 验证方程不是摆设以Groth16为例一个验证者要做两件事一是检查所有群元素是否在正确的曲线上二是跑通配对等式e(A, B) e(alpha, beta) * e(public_inputs, gamma) * e(C, delta)。这个等式对密码学有效证明来说只是必要条件不是充分条件。但在实际合约代码里很多验证器连这个等式都没写全。我能想到的几个高频实现错误只做了三对配对漏掉了e(alpha, beta)或者把public inputs处理成Fp元素时没有检查元素是否小于BN254的标量域阶再比如某些极简验证器基于EIP-197直接调用bn128_pairing预编译却没有人工检查“无限远点”的编码而预编译在某些实现版本中对畸形输入的处理并不严格。攻击者如果传入一个不在曲线上的点或刻意把某个元素编码成零点可能让配对结果发生异常。这就是典型的“虚假证明被错误接受”原因之一验证者没有在数学边界上防守住曲线点合法性。很多开发者觉得反正合约用的是标准预编译不会出问题。但实际上预编译函数只负责椭圆曲线配对运算它不关心你传入的A点是不是真的属于正确的群。更严谨的做法是在调用预编译前对G1、G2的每个元素都做一次曲线方程检查。这项工作不能被验证库“隐式完成”四个字带过。我在自己写的验证组件里只要涉及BN254一律加一个validate_curve_point的包装函数即便这样会让 gas 涨几百也值得。2.2 公开输入的“可信边界”被忽略还有一个比配对更隐蔽的问题公开输入的绑定关系。证明系统能保证的是“当且仅当知道秘密 witness 且公开输入满足电路约束时证明通过”。但电路约束只能覆盖电路内部写明的规则它无法约束合约其他部分如何使用public inputs。假设一条提款电路的public inputs包括merkle_root、my_index、my_amount。合约在验证后直接用my_amount给调用者转账但忘了检查msg.sender是不是这笔提款真正的主人。攻击者只要自己生成一笔合法的提款证明把recipient设成自己再把my_amount设成受害者金额就可能利用合约逻辑缺陷盗取资产。证明不是伪造的但业务语义是错的。这种情况我归纳为“公开输入的使用错误”。验证器只负责保证公开输入被证明满足了某个关系但链上逻辑必须自己定义“哪些公开输入是可信的、由谁提供、是否与调用上下文绑定”。在做审计时一个标准动作就是顺着合约代码把所有public inputs的消费点逐一标记凡是出现“来自证明但未被业务绑定”的可疑字段都要提出来讨论。2.3 状态根、签名和nullifier混用的“有效但错误证明”这类错误最难发现因为它没有一个“非法输入”的直观手感。系统会表现成证明验证通过记录也写进链上但逻辑上这些记录互相矛盾。我在评审某个跨链消息合约时见过一个把三个维度混在一起的例子消息里既有源链的block_number也有一个rollup root还有一个signature。验证器只检查了证明里确实存在一个root却把root和消息体的绑定关系交给了“前端保证”。结果就是攻击者可以把一条消息的历史root直接作为新消息的root协议分不清新旧。这一类“有效但错误证明”最麻烦的点在于你没法通过单元测试发现问题必须从状态转换的角度系统性审查。如果你在设计这类系统我强烈建议把每个证明需要绑定的业务数据列成一张“公开输入清单”清单里每一项都要明确来源是谁、更新频率、是否含随机数、与调用者身份的关系。只有清单完整才能对“证明到底陈述了什么”有确切认识。3. 实盘演练我如何复现并定位一个虚假证明错误3.1 搭一个最小复现环境这里我会做一个基于常见实践的模拟实验不从真实案例中引数据目的是演示排查过程。先准备一个本地环境需要Node.js、snarkjs、circom2.x再加一个测试链框架比如hardhat。步骤并不复杂写一个超小的电路fake_proof_check.circom它只做一件事验证输入val是否等于 42 的哈希结果。这个电路小到几秒钟就能跑完信任设置。用snarkjs groth16 setup生成zkey和verification_key.json。正常生成一份witness和proof得到一份合法证明。此时我们手上有一份合法证明、一份验证密钥和一套验证合约模板。接下来要做的就是“毁掉”验证者制造一个虚假证明被接受的机会。3.2 制造曲线点畸形输入第一个实验对proof.pi_a做改动把它替换成不在曲线上的一个坐标点。如果验证合约或者本地验证脚本只调用了配对预编译没有检查曲线方程我们就能看到非常离奇的现象某些点可能直接让预编译返回异常结果甚至true。我在hardhat里直接调用bn128_pairing预编译做实验时对畸形G1点传入不同的坐标结果分布很随机一部分 revert一部分返回 false还有极少部分返回 true。这类“极少数返回 true”的畸形输入如果不能被上层逻辑捕获就是典型的“虚假证明被验证通过”。一个更简单的攻击方式是把alpha或beta的位置换成零点。某些版本的配对预编译并不严格检查零点编码它会把e(0, beta)当成配对单位元处理。这就会导致验证等式退化成更容易满足的形式。修复方案也很直接在构造 pairing 参数前对每个点执行一次“是否为合法曲线点”和“是否为无穷远点”的判断并选择跳过或回滚。3.3 手写一个验证函数做对比为了让问题更明显我写了一个对比脚本分别用“验证库”和“手写验证函数”去处理同一份被篡改过的证明。脚本核心思路如下verifyWithLibrary直接调用项目正在用的零知识证明验证库verifyWithManual先把所有点做曲线检查然后严格按Groth16配对等式计算再对一份经过“业务逻辑篡改”的合法证明做同样的比较合法证明但public inputs里的root被换成另一个随机值。对比结果经常是二者不一致。验证库返回true手写验证函数返回false。这种不一致就是最直接的定位信号验证库或合约实现里缺少了某个必要检查点。3.4 从哈希拼接错误入手另一个值得做的实验是检查public inputs的序列化与哈希拼接。很多实现把多个公开输入先用 ABI 编码再做keccak256。如果前端与合约使用的编码顺序不同或者某个字段没有参与哈希就会产生“同一个证明配不同数据”的应用漏洞。我在复现时常用的手法把public inputs数组里两个相邻字段交换位置再次调用验证库看返回结果。如果返回true证明系统完全没有把字段之间的顺序当成约束条件后续业务逻辑就有机会拿到错位的字段。这类问题必须要靠业务层的msg.sender绑定、resourceID绑定去补救。4. 审计视角的完整排查流程4.1 第一步核对公开输入与业务数据的映射关系打开合约代码把所有public inputs写在一张纸上然后逐个问三个问题。第一这个字段由谁填写第二它在哪里被消费第三如果攻击者换一个值会对结果产生什么影响比如一个跨链转账证明里有recipient、amount、source_chain_id、destination_chain_id。那么至少应该验证source_chain_id等于合约记录的桥 IDdestination_chain_id等于当前链 IDrecipient只能是msg.sender或由其签名派生。如果某一个字段没有进入验证函数和业务逻辑的任何条件分支它即使没被“使用”也会影响证明的语义绑定。这一步做下来通常能发现一半以上的“虚假证明被接受”风险。因为这些风险的本质不是密码学被攻破而是证明所声称的陈述没有被完整执行。4.2 第二步检查配对预编译前的参数卫生这一步针对BN254和BLS12-381等基于配对的系统。核心检查点有三个所有G1点是否满足y² x³ b所有G2点是否满足对应的FP2曲线方程所有点是否编码为合法的仿射坐标不是无穷远点的特殊编码也不是大于模数的坐标值。如果用的是现成的合约库比如snarkjs的验证合约模板通常已经内置这些检查。但如果是手写的极简验证器或者直接调预编译组装逻辑就要格外注意。很多项目为了省 gas 把检查精简掉这在我看来是极其危险的行为。省下的那几千 gas对比资产损失风险来说完全不成比例。最稳妥的方案是使用经过审计的Groth16验证器源码哪怕多花点部署成本。4.3 第三步构造最小伪造样例做“负向测试”很多人做测试只做正向测试生成合法证明验证必须返回true。这远远不够。必须同时做三类负向测试篡改witness任意一个字节验证必须返回false篡改public inputs任意一个字段验证必须返回false把某个曲线点替换成随机坐标验证必须返回false。我把这套负向测试直接固化到 CI 流程里。每个涉及证明验证的合约都会在测试目录下放一个attack-vectors测试文件里面备好各种类型的失效证明样本。每次改合约逻辑先跑一遍负向测试确保验证器没有越修越松。4.4 整理成一张可落地的审计清单检查项风险等级示例手段公开输入与业务字段逐一映射高调用点追踪 状态假想攻击所有曲线点合法性检查高曲线方程验证 无穷远点拒绝scalar field范围检查中所有标量字段小于rnullifier与状态根强绑定高存储标记 重放测试证明生成端与验证端编码一致中跨语言测试 固定长字节序验证与业务更新在同一原子操作内高代码审查 并发测试库版本与部署地址一致性中校验bytecodeHash 多签发布这套清单看起来朴素但它能挡住绝大多数“一个虚假证明的错误”。我之前遇到的严重问题几乎都可以对应到清单里的某一项。反过来讲只要清单上的每项都过一遍项目基本能达到“即使未来出现新漏洞也大概率不会出现在这些基础边界上”的状态。5. 常见问题与排查技巧实录5.1 现象、根因、排查手段速查表错误现象根本原因排查手段合法证明偶尔验证失败公开输入编码顺序不一致记录前端/合约两个版本的public_inputs哈希并对比畸形曲线点返回 true缺曲线检查或预编译边界差异手写曲线校验替换预编译调用调换字段后仍返回 true未在电路内约束字段间关系在电路中新增字段间确定性计算验证通过但资产错误业务逻辑与证明字段解绑审计public inputs消费点重放攻击成功nullifier未持久化检查存储位与状态回滚机制跨链消息伪造源链标识未参与证明把链 ID 与源合约地址加入电路公开输入这张表我建议直接打印出来做代码评审的时候放在旁边对照。它不是万能药但能把“凭感觉找问题”变成“按清单找问题”。5.2 双端验证法服务端与合约配合链上验证一旦失败就不可逆所以我强烈推荐在服务端增加一层独立验证。服务端可以用同一个验证库或不同的实现对proof与public inputs做一致性校验再把校验结果和原始数据一起提交到合约。这样即使合约侧存在某个已知边界问题服务端也能拦截大多数恶意请求。我做过一个项目合约验证是通过EIP-197实现的但测试时发现某些极端输入会导致预编译返回值不符合预期。后来我们直接在服务端前置了一个同样的验证节点任何不满足严格曲线检查的输入都在进入链上前被拦掉。实际运行半年没有出现一例被篡改的证明进入合约。这种双端思路不是性能最优但安全性上“冗余”永远不过分。尤其是新型验证库层出不穷各版本之间行为有差异双端验证能有效减少单点失效风险。5.3 调试时的日志插桩与离线重建排查“虚假证明错误”时最怕的就是链上状态已经过去日志不完整。我通常会在验证合约里加事件ProofVerified(address sender, bytes32 publicInputHash, bool result)。不要记录整个proof那会伴随大量 gas 和噪音只记录公开输入的哈希和验证结果。另一个实用技巧是离线重建。把链上验证函数用本地脚本复现一份输入历史交易的数据逐步对比每个中间变量的值。很多“验证通过但逻辑异常”的问题在这种离线重建下会原型毕露。比如你会发现合约拿到了amount[index]而不是amount[sender]或者hash拼接时把admin和user搞反了。5.4 库与工具的选择建议电路层circomsnarkjs仍然是目前门槛最低、生态最完整的组合。合约层优先用经过大量项目验证的模板比如snarkjs里导出的验证器如果要手写预编译调用务必先跑完负向测试集。审计层面多配合gnark或zokrates做交叉验证避免单一实现自带隐性假设。测试层面hardhat的evm_mine和snapshots能帮你在同一笔交易里复现状态根错位问题。工具并不是越多越好关键是让每一层验证逻辑都独立可审计。只要存在一个“只有一份代码且没有替代实现可对比”的黑盒那这个黑盒就是风险源。6. 延伸方向与个人经验6.1 这个系列还能往哪些方向深挖聊完验证器侧的“虚假证明错误”后续可以继续做几个主题。一是“递归证明组合中的错误边界”当多个子证明被聚合进一个父证明时任何一个子证明的公开输入如果被忽略整棵树的安全性都会坍塌。二是“不同证明系统之间的语义迁移”同一个业务用Groth16改成Plonk公开输入编码和验证方程都变化常见迁移错误往往会反复出现。三是“安全升级方案”在新旧验证器切换时如何保证旧状态不会被新验证器错误放行。这些方向都比单个漏洞挖得更深也更贴近真实工程需要。6.2 我踩过几次坑之后总结出的心得先说结论零知识证明系统并不会因密码学被攻破而倒下绝大多数事故都倒在验证器实现和业务逻辑的接缝处。我最早做这类系统时也一度把所有注意力放在配对等式上后来被一个“公开输入长度与编码顺序不一致”的问题死死按住才意识到密码学边界只是入场券业务语义一致性才是最终安全线。从那之后我对每一个涉证明项目都保持同样的检查节奏先列公开输入清单再检查曲线点卫生再做负向测试最后用双端验证。这套流程听起来繁琐但每次它都能在真正上线前拦住至少一个足以导致资产损失的错误。希望这篇文章也能帮你少走几个弯路。如果你们项目里也遇到过类似“验证通过但心里发毛”的证明场景欢迎按这篇文章的思路把参数、编码和绑定关系彻底过一遍——你会发现几乎所有“虚假证明的错误”都写在最容易被忽略的细节里。