4. Fiat-Shamir 变换漏洞:启发式转换中的随机预言机模型滥用、签名伪造攻击

Fiat-Shamir 变换,这个名字听起来挺唬人的。说白了,它就是把一个交互式的零知识证明,变成非交互式的。你想想看,如果每次证明都要来回对话好几轮,那在实际系统里根本跑不动。所以这个变换,几乎是所有实用零知识证明系统的基石。

但问题来了——这个变换本身,其实是个「启发式」方法。它假设存在一个随机预言机,也就是一个完美的哈希函数。嗯,现实世界里哪有这种东西?于是漏洞就来了。

4.1 随机预言机模型:理想很丰满

随机预言机模型,你可以把它想象成一个「神谕」。你问它一个问题,它给你一个完全随机的答案。而且同样的输入,永远得到同样的输出。这个模型在理论上非常漂亮,因为它能保证证明者无法预测哈希结果,也就没法作弊。

但实际代码里,我们用的是 SHA256、Keccak 这些具体的哈希函数。它们不是随机的,它们是确定性的算法。我个人习惯把这种差距叫做「理想与现实的鸿沟」。很多攻击,就是利用这条鸿沟打进来的。

核心问题: Fiat-Shamir 变换的安全性,完全依赖于「挑战值」必须是证明者无法控制的。如果证明者能预测或操纵挑战值,那整个证明就废了。

4.2 签名伪造攻击:一个经典案例

我记得有一次审计一个 Schnorr 签名方案。代码看起来没什么问题,但仔细一看,发现挑战值的计算方式有猫腻。具体来说,签名者把公钥、消息、还有随机点都哈希到一起,生成挑战。这看起来没问题对吧?

但问题在于,这个哈希的输入顺序和格式,被攻击者利用了。攻击者可以构造一个特殊的公钥,使得哈希结果恰好等于他想要的值。这样一来,他就能伪造签名。

为什么会这样?因为哈希函数的输入空间太大了,攻击者可以暴力搜索一个「坏」的公钥。你想想看,如果挑战值不是真正随机的,而是可以被反向工程出来的,那签名还有什么安全性可言?

4.2.1 攻击流程

  1. 攻击者选择任意消息 m 和任意随机数 r
  2. 计算 R = g^r
  3. 攻击者想要伪造挑战 e,于是反向计算公钥 PK,使得 Hash(PK, m, R) = e
  4. 攻击者现在可以构造签名 (R, s),其中 s = r + e * sk(但 sk 是伪造的)
  5. 验证者收到 (R, s) 和伪造的 PK,计算 e' = Hash(PK, m, R),发现 e' = e,验证通过

警告: 这个攻击成立的前提是,挑战值的计算没有绑定到「证明者身份」或「公钥的合法性」。如果哈希输入里包含了公钥,但公钥本身可以被攻击者任意选择,那这个绑定就是无效的。

4.3 随机预言机模型滥用:常见的坑

在实际项目中,我见过太多滥用随机预言机模型的案例。这里列几个最常见的:

  • 挑战值重用: 同一个挑战值被用在多个证明里。攻击者可以通过交叉计算,恢复出秘密信息。
  • 挑战值可预测: 哈希函数的输入只包含公开信息,但攻击者可以提前计算所有可能的挑战值,然后选择对自己有利的。
  • 缺少域分离: 不同的协议阶段使用了相同的哈希函数,没有加区分标签。攻击者可以把一个阶段的输出,当作另一个阶段的输入来利用。
  • 哈希输入顺序不一致: 证明者和验证者计算挑战值时,输入的顺序不同。这会导致挑战值不一致,验证失败,或者更糟——被攻击者利用。

避坑指南: 我曾经在一个 zk-SNARKs 项目里,发现开发者把多个公开输入直接拼接后哈希。结果攻击者可以通过调整输入顺序,构造出相同的哈希值,从而伪造证明。从那以后,我建议所有项目都使用「域分离标签 + 固定编码格式」来计算挑战值。

4.4 如何正确实现 Fiat-Shamir 变换

嗯,这里要注意几个关键点。我总结了一个 checklist,每次做代码审查时都会过一遍:

检查项 说明 常见错误
挑战值唯一性 每个证明会话必须使用不同的挑战值 使用固定种子或时间戳作为挑战
输入完整性 哈希输入必须包含所有公开参数和证明者的承诺 遗漏了公钥或公共参考串
域分离 不同协议阶段使用不同的哈希标签 所有阶段共用同一个哈希函数
编码一致性 证明者和验证者使用完全相同的编码方式 一个用 JSON,一个用 Protobuf
抗量子安全 如果考虑量子攻击,哈希输出长度要足够 使用 128 位输出,容易被碰撞攻击

4.5 代码示例:安全的挑战值生成

下面是一个我常用的挑战值生成函数。它使用了域分离标签,并且强制要求输入按固定顺序编码:

// 安全的挑战值生成函数
function computeChallenge(
    domainTag: string,      // 域分离标签,例如 "schnorr-signature-v1"
    publicKey: Uint8Array,  // 公钥
    message: Uint8Array,    // 消息
    commitment: Uint8Array  // 证明者的承诺(随机点)
): Uint8Array {
    // 1. 使用域分离标签,防止跨协议攻击
    const tagBytes = new TextEncoder().encode(domainTag);
    
    // 2. 固定编码顺序:tag || publicKey || message || commitment
    const input = concat(tagBytes, publicKey, message, commitment);
    
    // 3. 使用 SHA256,输出 256 位挑战值
    return sha256(input);
}

关键点: 域分离标签必须包含协议名称和版本号。这样即使两个协议使用了相同的哈希函数,也不会互相干扰。我习惯在标签里加上「v1」「v2」这样的版本号,方便未来升级。

4.6 知识体系图

下面这张 SVG 图,展示了 Fiat-Shamir 变换漏洞的核心知识结构。你可以看到,从交互式证明到非交互式证明,中间的关键就是随机预言机模型。而滥用这个模型,会导致签名伪造、证明伪造等一系列问题。

Fiat-Shamir 变换漏洞知识体系 交互式零知识证明 Fiat-Shamir 变换 (启发式方法) 非交互式零知识证明 核心假设:存在随机预言机 挑战值重用 挑战值可预测 缺少域分离 攻击后果:签名伪造 / 证明伪造 / 秘密信息泄露

4.7 总结

Fiat-Shamir 变换的漏洞,本质上是对「随机预言机模型」的滥用。你想想看,一个理论上完美的假设,放到现实世界里,到处都是妥协。哈希函数不是随机的,挑战值可能被预测,输入编码可能不一致——每一个细节都可能成为攻击面。

我个人习惯在每次实现零知识证明系统时,都专门写一个「挑战值生成模块」,单独测试、单独审计。这个模块的代码量通常不到 50 行,但它的安全性直接决定了整个系统的生死。嗯,别嫌麻烦,值得的。