卷 II · 对答CH 09深度 9/23

哈希里少放一样东西

上一章把裁判换成了哈希,于是「往哈希里塞了什么」成了整个系统唯一的防线。这一章把那条防线踩断,用的力气小得可笑:删掉一个参数。然后我拿 Alice 一份完全合法的证明,做一次加法,得到一份针对另一个公钥的合法证明——而我并不知道那个公钥的私钥。本机实测:弱版验证器接受,强版验证器拒绝

★★ 伪造成功Frozen Heart一个参数

◻ 本章先赊三条
第一条。把陈述本身漏出哈希,会让一份证明不再绑定它所证的那句话——于是它可以被搬到别的句子上去。 第二条。这个漏洞不影响诚实用户:所有正常流程全绿,测试全过,跑几年都不会有人发现。 第三条。它在 2022 年同时命中了好几个主流零知识库,而且不同库的犯法方式各不相同——说明这不是某个人手滑,是这一类变换本身容易出的错。

把那个参数删掉

上一章的强版本,挑战是这样算的:

强版:  c = H( P ‖ R ‖ 消息 )        ← 公钥 P 也在里面
弱版:  c = H( R )                    ← 只有承诺

弱版看起来完全合理:条件 (1) 是「证明者交出 R 时不知道 c」,而 c = H(R) 完全满足这一条——他一改 R,c 就变。上一章那条推理在这里一个字都没错

问题出在另一件事上:这个 c 和「在证哪句话」没有任何关系了。

三行伪造

假设 Alice 有公钥 P,她发布了一份合法的弱版证明 (R, s),其中 c = H(R)s = k + c·x

我是攻击者。我不知道 x,也不知道 k。我随手取一个数 t = 31337,然后:

造一个新公钥:   P′ = P + t·G
造一个新响应:   s′ = s + t·c        (c = H(R),我自己算得出来)
R 原样不动。

验证一下这份 (R, s′) 对 P′ 成不成立:

    s′·G = (s + t·c)·G
         = s·G + t·c·G
         = (R + c·P) + c·(t·G)        ← 因为 Alice 的证明是合法的
         = R + c·(P + t·G)
         = R + c·P′                   ✓ 严格成立

而挑战 c = H(R) 完全没变,因为 R 没变,而 P′ 根本不在哈希里。

本机在真的 secp256k1 上跑了一遍:

Alice 的诚实证明(弱版)通过            True

★ 伪造:P′ = P + 31337·G              239cb0a478f4376d78597acacdeffc18…
★ 伪造:s′ = s + 31337·c              021445480eabcd60a2fdc63c565ad77d…
★ 弱版验证器接受这个伪造证明吗           True
★ 伪造者知道 P′ 的私钥吗                不知道(那是 x + 31337,而他不知道 x)

★ 强版(把 P 也塞进哈希)验证器接受吗    False
  强版下 Alice 自己的证明还通过吗        True
  在强版上照搬同一手法                   False

这份伪造的证明说的是:「我知道 P′ 的私钥。」而我不知道。P′ 的私钥是 x + 31337,其中的 x 只有 Alice 有。我造出了一份关于我不掌握的秘密的「知识证明」——知识可靠性(第 6 章)被彻底打破了。

为什么这在实际系统里是灾难

「证明一个我不知道私钥的公钥」听起来像个学术玩具。它不是。这个结构在真实系统里到处都是:

场景那句陈述伪造之后
注册新账户时证明「我持有这把公钥」P 是我的攻击者用一个自己控制不了、但和别人相关的公钥注册,造成密钥托管混乱或抢注
多方协议里证明「我知道我那份秘密份额」我的份额是合法的攻击者塞进一个和别人份额相关的假份额,最终把整个协议的输出偏移到他想要的值
隐私交易里证明「这笔金额是正的」金额 ≥ 0能伪造这类证明就能凭空造币——这正是 Bulletproofs 那次事故的实际风险
◆ 2022 年,Frozen Heart

2022 年 4 月,安全公司 Trail of Bits 公布了一组漏洞,统称 Frozen Heart(取自《权力的游戏》里「凛冬之心」的双关:这类漏洞让证明系统的「心脏」——Fiat–Shamir 变换——被冻住)。

它们的共同点全都是这一章这件事:Fiat–Shamir 的哈希输入不完整。受影响的包括多个广泛使用的零知识库里的 PlonK 实现、Bulletproofs 实现、以及若干秘密分享的可验证版本。不同项目漏掉的参数各不相同——有的漏了公开输入,有的漏了承诺,有的漏了协议里的中间值。

三个特别值得记住的点:

  • 这些代码都通过了各自的测试。因为诚实路径完全正常——完备性一点问题都没有。缺的是可靠性,而可靠性没法用「跑一遍看对不对」来测。
  • 论文里通常写着「把所有公开值哈希进去」,但这句话太容易被当成套话。实现者需要一份确切的清单,而论文很少给。
  • 这不是一次手滑,是一类错误。同一个星期,多个独立团队被同一个模式命中。

那到底该塞什么进去

正确的规则可以写成一句话,而且值得背下来:

◆ Fiat–Shamir 的塞料规则

在挑战被抛出的那一刻,协议里所有「已经确定下来」的东西,全部都要进哈希。一样都不能少。

具体清单:

  • 陈述:公钥、公开输入、要证的那句话本身。(Frozen Heart 漏的通常是这一项。)
  • 所有承诺:这一轮和之前每一轮的。
  • 之前所有的挑战和响应:多轮协议里,第 n 个挑战必须依赖前 n−1 轮的全部内容。
  • 协议标识与版本:域分隔符(domain separator)。防止同一份证明被搬到另一个协议里去。
  • 上下文:链 ID、合约地址、有效期、随机数——凡是「这份证明只应该在这里有效」的限定条件。

反过来的检查方法更好用:问「如果我改动 X,挑战会不会变?」如果不会变,那 X 就是可以被攻击者自由替换的。上面那个伪造,替换的正是 P。

顺便说,这条规则解释了一个你可能见过、但没想过为什么的细节:JWT、TLS、签名邮件里那些「把算法名字也一起签进去」的做法。不签算法名,攻击者就能把 alg 改成别的(第 2 章提过的 alg: none)。同一个道理,同一个错误家族。

⌨ 自己跑一遍

这个伪造非常短,值得亲手跑一次——尤其是最后那两行对照:

import hashlib, random

p, q, g = 2039, 1019, pow(3, 2, 2039)
x = random.randrange(1, q)                    # Alice 的 ⟦ 私钥 ⟧
P = pow(g, x, p)

def H(*parts):
    s = '|'.join(str(v) for v in parts).encode()
    return int.from_bytes(hashlib.sha256(s).digest(), 'big') % q

weak   = lambda Pk, R: H(R)                   # ✗ 少放了 Pk
strong = lambda Pk, R: H(Pk, R)               # ✓ 完整

def prove(chal):
    k = random.randrange(1, q)
    R = pow(g, k, p)
    c = chal(P, R)
    return R, (k + c * x) % q

def verify(chal, Pk, R, s):
    c = chal(Pk, R)
    return pow(g, s, p) == R * pow(Pk, c, p) % p

# --- Alice 发布一份弱版证明 ---
R, s = prove(weak)
print('Alice 的证明合法:', verify(weak, P, R, s))

# --- 攻击者:不知道 x,随手取一个 t ---
t = 31337
P2 = P * pow(g, t, p) % p                     # P′ = P + t·G(乘法写法)
c  = weak(P, R)
s2 = (s + t * c) % q                          # s′ = s + t·c

print('★ 弱版验证器接受伪造:', verify(weak, P2, R, s2))     # True  ← 灾难
print('★ 强版验证器接受伪造:', verify(strong, P2, R, s2))   # False ← 修好了

R3, s3 = prove(strong)                        # 强版下诚实证明照常
print('  强版下 Alice 自己:', verify(strong, P, R3, s3))    # True
Alice 的证明合法: True
★ 弱版验证器接受伪造: True
★ 强版验证器接受伪造: False
  强版下 Alice 自己: True

试着把 t 换成任何数——伪造永远成功。也就是说攻击者可以批量生产无穷多个「我知道私钥」的假证明,每一个对应一个不同的公钥。

python3 -c " import hashlib,random p,q,g=2039,1019,pow(3,2,2039); x=random.randrange(1,q); P=pow(g,x,p) H=lambda R:int.from_bytes(hashlib.sha256(str(R).encode()).digest(),'big')%q k=random.randrange(1,q); R=pow(g,k,p); c=H(R); s=(k+c*x)%q P2=P*pow(g,31337,p)%p; s2=(s+31337*c)%q print(pow(g,s2,p)==R*pow(P2,c,p)%p)"

在线跑:python.org/shell,输出 True——那一行就是一次成功的伪造。

▸ 在现实里
  • 这一类错误的通用形状:签名/哈希没有覆盖全部安全相关的输入。你在别的地方一定见过它:alg: none(算法名没被签)、支付回调只签金额不签订单号(订单可被张冠李戴)、跨链重放(链 ID 没进签名)、HTTP 签名只签 body 不签 URL。全是同一个 bug,换了个行业。
  • 以太坊的 EIP-712 就是为了解决这件事。它规定结构化数据签名必须带上一个「域分隔符」,里面包含合约地址、链 ID、协议名和版本号。这条标准的存在,本身就是「哈希里少放东西」这类事故的墓碑。
  • 为什么这类漏洞特别难被测试发现。诚实路径的测试全绿——完备性没坏。要发现它,必须主动去写攻击代码,也就是「负向测试」。而绝大多数团队的测试套件里没有「攻击者视角」这一类。这本书里提到的所有真实事故,几乎都是被外部审计或研究者发现的,不是被 CI 发现的。
  • 对照第 2 章。这个漏洞打破的是三条性质里的可靠性,而完备性和零知识毫发无损。第 2 章那张「缺哪条会怎样」的表,在这里第一次派上了实际用场:知道自己坏的是哪一条,就知道该往哪里写测试。
✗ 这个直觉是错的

「我用的是审计过的知名密码学库,这种事跟我没关系。」

Frozen Heart 命中的就是知名库。而且更麻烦的是:这类错误经常出现在库和你的代码之间的接缝上——库提供 prove(statement, witness),而「statement 里到底包含哪些字段」是决定的。你少传一个公开输入,库不会报错,因为它不知道你少了。

真实的例子形状是这样的:某个电路有 5 个公开输入,你在调用验证时只传了 4 个(第 5 个你「觉得反正是常量」)。于是那个字段就成了攻击者的自由变量。这在 2022–2024 年的多份审计报告里反复出现,通常归类为「公开输入未绑定」。

正确的做法有两条:一是永远把「这份证明只应该在什么情况下有效」写成一份显式清单,逐条检查它们是否都进了哈希或公开输入;二是为每个证明系统写一个「负向测试」——拿一份合法证明,尝试把它搬到另一个陈述上,断言验证器拒绝这本书里所有的验证都可以这么测:不是「它接受对的吗」,而是「它拒绝错的吗」。

✓ 结账

这一章的一句话

Fiat–Shamir 把安全边界从「验证者是否诚实」搬到了「哈希的输入列表是否完整」;这条边界没有类型系统守着、没有测试能覆盖,只能靠一份显式清单,而 2022 年的 Frozen Heart 证明了整个行业都低估了它。

卷 II 到此结束。你现在手里有一台完整的、非交互的、零知识的证明机器——但它只会证一件事:「我知道某个数」。这离「我跑对了一整段程序」还差得很远。

下一卷补上这一段,而它的第一步出人意料地朴素:把程序摊成一张表,再把表上每一步的正确性写成加法和乘法的等式。一道 9×9 的数独,摊出来是 1620 条约束——而下一章末尾你会看到,其中 648 条只是在说「每个格子里的数得在 1 到 9 之间」。这 648 条约束会在第 22 章变成一个惊人的漏洞。