证明成立,结论仍然可能是错的
这是这本书最重要的一章。前二十一章的每一样东西都可以完美运转——承诺没问题、编码没问题、抽查没问题、证明验过了——而你想知道的那件事仍然可能是假的。中间有两处断裂,两处都不在密码学里。本机实测第一处:数独电路漏写 8 条约束,一份正中间填着 12 的「数独解」通过了全部 1612 条检查,验证者看不出任何异常。
断裂一:那 8 条约束
回到第 10 章那个数独电路。它有 1620 条约束,其中 648 条在说「每格里的数必须在 1 到 9 之间」——具体是每格 8 条,把 (v−1)(v−2)…(v−9) = 0 拆开。
现在假设写电路的人漏掉了一格的那 8 条。可能是循环边界写错了,可能是重构时删多了一行,可能是「这一格反正是给定的,不用查」这种想当然。
少写了第 40 格(正中间)的「∈ 1..9」,约束条数 1612(比正确电路少 8 条) ★ 一份正中间填着 12 的「解」通过全部约束吗 true 正中间那格的值 12 ★ 正确电路会在第几条约束上拒绝它 第 328 条(属于「每格 ∈ 1..9」那一段) 验证者能看出区别吗 看不出——他只验证明,不看电路 把它换成 65530 呢 照样通过
为什么行得通?因为剩下那 1612 条约束里,和第 40 格有关的只剩下「它和同行、同列、同宫的另外 8 个数两两不等」。而 12 和 1…9 里的任何一个都不相等,所以它完美地满足了「不等」。
65530 也一样。任何不在 1…9 里的数都一样。
把「数独」换成任何一个真实场景,就能看清楚:
| 漏掉的约束 | 攻击者能做什么 |
|---|---|
| 没检查「余额 ≥ 转账金额」 | 转出比自己有的更多的钱 |
| 没检查「金额是正数」 | 转一笔负数金额,等于从对方账户偷钱 |
| 没检查「这个标记没被用过」 | 同一笔钱花无数次(第 19 章那个作废标记) |
| 没检查某个中间值是 0 或 1 | 把「选择器」设成 2,让两个分支同时生效 |
| 除法没检查除数非零 | 让 0 × 任意 = 0 成立,凭空造出满足条件的见证 |
而且注意最要命的一点:验证者完全无法察觉。他拿到的证明在数学上百分之百合法——因为它确实证明了「有一个见证满足那 1612 条约束」。问题不在证明,在约束写少了。
这类 bug 有个专门的名字:约束不足(under-constrained)。它是零知识项目审计报告里出现频率最高的一类问题,没有之一。
为什么它特别难被测试发现?因为正向测试全绿——真的数独解永远能通过,改错的解永远被拒。要发现它,你得主动去构造那个「不在 1…9 里的数」,也就是要有攻击者视角。这是第 9 章那句话的第二次出现:可靠性没法用「跑一遍看对不对」来测。
断裂二:输入是谁担保的
第二处断裂更根本,而且没有任何技术能修它。
证明说的是:
「我从输入 x 出发,按照这段程序算了下去,得到输出 y。」
它没有说、也不可能说:
「x 是真的。」
举几个具体的:
- 一份证明「我的年收入低于门槛,够资格领补贴」——那份收入数据是谁给的?如果它来自你自己填的表,那么证明只保证了「你算得没错」。
- 一份证明「这批传感器读数的平均值是 23.5」——传感器可以被人拿打火机烤。
- 一份证明「我按照这个模型算出了这个风险评分」——模型本身可以是垃圾。
- 一份证明「链下这一万笔交易全部合法」——「合法」的定义是那段代码写的,而那段代码是人写的。
这个问题在区块链领域有个名字叫预言机问题(oracle problem):链上的一切都可以被验证,而链上的一切又都来自链外,链外没有任何东西能被验证。零知识把「计算是否正确」这件事推到了极致,也因此把整个系统的薄弱点全部挤到了输入那一端。
证明保证的是「有人按规则算过」,不是「事实为真」。
规则是人写的(断裂一),输入是人给的(断裂二)。密码学只负责中间那一段,而它把这一段做得如此可靠,以至于两头的脆弱变得格外显眼。
这是一个非常普遍的工程现象,值得记住它的形状:当你把系统里某一环做到接近完美时,事故率不会下降到零,它只会整体搬到别的环上去。
事故都出在哪
把这本书提到过的真实事故排一排,会看到一个非常整齐的模式:
| 事故 | 出在哪一层 | 密码学有问题吗 |
|---|---|---|
| PS3 签名私钥泄露(2010) | 随机数复用(第 6 章) | 没有——ECDSA 本身没问题 |
| 安卓钱包被盗(2013) | 随机数发生器没播种 | 没有 |
| Zcash 参数缺陷(2018 修复,2019 披露) | 参数的数学结构(第 15 章) | 接近,但不是协议本身 |
| Frozen Heart(2022) | Fiat–Shamir 哈希输入不完整(第 9 章) | 没有——变换用错了 |
| 大量项目的约束不足漏洞 | 电路写少了(本章) | 没有 |
这一列全是「没有」。密码学原语(哈希、椭圆曲线、多项式承诺)几乎从来不是弱点——它们被几百个研究者盯了几十年。弱点永远在把这些原语拼起来的那些胶水里,以及在拼的人对语义的理解里。
这一条和这个书架上另一本书的结论是同一句话:算法几乎从不是弱点,组合与使用才是。
那能怎么办
| 做法 | 针对哪一处 |
|---|---|
| 负向测试:拿一份合法证明,尝试把它搬到别的陈述上;构造越界的见证,断言验证器拒绝 | 断裂一。这是最便宜也最有效的一招 |
| 形式化验证电路:用工具证明「这个电路 ⟺ 那个规格」 | 断裂一。已有专门工具,但覆盖面还有限 |
| 约束数对账:算出「按规格应该有多少条约束」,和编译器输出的数字比对 | 断裂一。粗糙但抓得住那 8 条 |
| 把输入也变成有签名的凭证 | 断裂二。把信任从「你填的表」推到「发证机构」——信任没消失,只是搬了家(和第 15 章一模一样) |
| 多来源交叉验证 + 经济惩罚 | 断裂二。这是预言机网络的通用做法,也是唯一现实的做法 |
那份含 12 的数独值得你亲手造一次——它比任何说教都有说服力:
P = 65537
SOLUTION = [5,3,4,6,7,8,9,1,2, 6,7,2,1,9,5,3,4,8, 1,9,8,3,4,2,5,6,7,
8,5,9,7,6,1,4,2,3, 4,2,6,8,5,3,7,9,1, 7,1,3,9,2,4,8,5,6,
9,6,1,5,3,7,2,8,4, 2,8,7,4,1,9,6,3,5, 3,4,5,2,8,6,1,7,9]
def groups():
g = []
for i in range(9):
g.append([i*9 + j for j in range(9)]) # 行
g.append([j*9 + i for j in range(9)]) # 列
br, bc = (i // 3) * 3, (i % 3) * 3
g.append([(br + j//3)*9 + bc + j % 3 for j in range(9)]) # 宫
return g
def check(vals, skip_range_at=-1):
"""返回 (约束条数, 第一条不成立的编号或 None)"""
n, bad = 0, None
for i in range(81): # 一、每格 ∈ 1..9
if i == skip_range_at:
continue
prod = 1
for d in range(1, 10):
prod = prod * (vals[i] - d) % P
n += 8
if prod != 0 and bad is None:
bad = n
for g in groups(): # 二、两两不等
for a in range(9):
for b in range(a+1, 9):
n += 1
if vals[g[a]] == vals[g[b]] and bad is None:
bad = n
return n, bad
print('正确电路 + 真解 ', check(SOLUTION))
bugged = SOLUTION[:]; bugged[40] = 12 # 正中间填 12
print('正确电路 + 含 12 的解 ', check(bugged))
print('漏 8 条 + 含 12 的解 ', check(bugged, skip_range_at=40))
wild = SOLUTION[:]; wild[40] = 65530
print('漏 8 条 + 填 65530 ', check(wild, skip_range_at=40))
正确电路 + 真解 (1620, None) 正确电路 + 含 12 的解 (1620, 328) 漏 8 条 + 含 12 的解 (1612, None) ← 通过了 漏 8 条 + 填 65530 (1612, None) ← 也通过了
第三、四行就是这一章的全部内容:约束数从 1620 变成 1612(少了 0.49%),一份根本不是数独解的东西成了合法见证。而基于它生成的证明,在数学上无懈可击。
值得做的实验:把 skip_range_at 换成别的格子,看哪些格子漏掉之后仍然能被「两两不等」兜住(答案是:一个都兜不住,因为域外的数和谁都不相等)。再试试删掉某一组的「两两不等」,看能不能造出一份「有两个 5 在同一行」的解。
python3 sudoku_bug.py
在线跑:python.org/shell。想看真实世界的这类漏洞长什么样,去读几份 zk 项目的公开审计报告,搜 under-constrained——你会发现它们的形状和上面这个一模一样。
- 这个模式在软件工程里到处都是。类型系统保证了「类型对」,不保证「逻辑对」;单元测试保证了「你测的那些情况对」,不保证别的;编译器保证了「语法对」。形式化方法越强,人们越容易忘记「规格本身可能是错的」。
- 「验的是电路,不是意图」——这句话在合约世界有个更出名的版本。The DAO 事件(2016)之后,「Code is Law」这句话就被反复引用和嘲讽:代码百分之百按写的执行了,只是它写的不是人们以为的东西。零知识把这句话推进了一层:证明百分之百成立,只是它证的不是你以为的那句话。
- 预言机问题也不新鲜。财务审计里叫「函证」:审计师不信客户的账,去银行核实;但银行也可能配合造假(历史上真发生过)。任何验证链条最终都要落到某个「你选择相信」的地方,区别只在这个地方离你有多远、有多少人在盯着它。
- 一个正面的例子。正因为这两处断裂被普遍认识到,这个领域近年出现了一批专门的电路验证工具,以及「双实现互测」的做法(同一份规格由两个团队各写一遍电路,互相跑对方的测试)。这和航天软件的多版本冗余是同一个思路。
「这个项目用了零知识证明,还请了知名审计公司做过审计,那它应该是安全的。」
「用了零知识证明」这句话,只保证了一件非常具体的事:存在一个见证,满足那份电路写下的约束。它对以下每一件事都零保证:
- 那份电路表达的是不是你以为的规则(断裂一)
- 输入数据是不是真的(断裂二)
- 那个能升级合约的多签钱包会不会作恶(第 18 章)
- 排序器会不会审查你的交易
- 那份可信设置的仪式有没有人作弊(第 15 章)
审计也不是免罪符。审计报告里通常明确写着范围和时间点,而约束不足这类漏洞的发现率高度依赖审计员是否具备「攻击者视角」——它不是能靠工具扫出来的东西。这本书里提到的每一起真实事故,几乎都是被外部研究者发现的,不是被 CI 或常规审计发现的。
正确的问法是一张清单,而不是一个标签:「这个系统一共有几条必须成立的假设?每一条如果不成立,会发生什么?谁能验证它?」把这张清单列出来,你就会发现零知识那部分往往是最不需要担心的一项。这是这本书最想留给你的一个习惯。
这一章的一句话
一份合法的证明只说明「存在一个见证满足那堆约束」——规则是人写的,输入是人给的,而密码学只负责中间那一段;正因为中间这一段被做得如此可靠,两头的脆弱才变得格外显眼。
下一章结账。这本书一共赊了六十多条账,绝大多数已经还清了。最后一章会把它们收成一张二十三条错误直觉的自查表,把还不清的那几笔明确标出来——而那几笔恰好就是这个领域今天还在吵的地方——然后按方向给出继续往下走的路。