卷 VI · 余账CH 22深度 22/23

证明成立,结论仍然可能是错的

这是这本书最重要的一章。前二十一章的每一样东西都可以完美运转——承诺没问题、编码没问题、抽查没问题、证明验过了——而你想知道的那件事仍然可能是假的。中间有两处断裂,两处都不在密码学里。本机实测第一处:数独电路漏写 8 条约束,一份正中间填着 12 的「数独解」通过了全部 1612 条检查,验证者看不出任何异常。

★★ 含 12 的数独漏 8 条垃圾进垃圾出

◻ 本章先赊三条
第一条。一份合法的证明保证的是:「存在一个见证,满足这堆约束。它没有保证「这堆约束表达的就是你想要的那件事」。 第二条。它也没有保证输入是真的。全世界最完美的证明系统,也管不了有人往里面喂假数据。 第三条。这门技术至今几乎没有一起事故出在密码学上。它们全都出在这两处断裂,或者出在更外围的地方。

断裂一:那 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 或常规审计发现的。

正确的问法是一张清单,而不是一个标签:「这个系统一共有几条必须成立的假设?每一条如果不成立,会发生什么?谁能验证它?」把这张清单列出来,你就会发现零知识那部分往往是最不需要担心的一项。这是这本书最想留给你的一个习惯。

✓ 结账

这一章的一句话

一份合法的证明只说明「存在一个见证满足那堆约束」——规则是人写的,输入是人给的,而密码学只负责中间那一段;正因为中间这一段被做得如此可靠,两头的脆弱才变得格外显眼。

下一章结账。这本书一共赊了六十多条账,绝大多数已经还清了。最后一章会把它们收成一张二十三条错误直觉的自查表,把还不清的那几笔明确标出来——而那几笔恰好就是这个领域今天还在吵的地方——然后按方向给出继续往下走的路。