Ravencoin 的 KAWPOW 共识漏洞
8 月第二周,Ravencoin 将自己的链回滚了大约三天。原因不是挖矿卡特尔,不是钱包漏洞,也不是哈希函数被攻破,而是区块头中的一个字段:节点信任它来决定运行两种工作量证明检查中的哪一种。下文引用的每一段代码都读自 Ravencoin 自己的共识源码,而不是来自公告;凡是公告中提到、源码却没有显示的内容,本文都会明确说明,而不是去猜测。
KAWPOW 区块头中带有什么
KAWPOW 是 Ravencoin 的工作量证明算法,是 ProgPoW 的一个变体,该链于 2020 年 5 月切换到它,以便让这个币继续使用普通消费级 GPU 挖矿。这次切换改变了区块头。在激活时间之前,区块头以每条 Bitcoin 衍生链都使用的普通 32 位 nonce 结尾。激活之后,序列化程序改为在这个位置写入三个字段,顺序为:nHeight,然后是 nNonce64,然后是 mix_hash。
其中两个字段并不特别。nNonce64 只是一个更宽的 nonce。mix_hash 是算法中内存密集部分的中间结果,放在区块头中,让验证者有一个可以核对的值,而不必从零开始重复一次计算。第三个字段才是关键。nHeight 是区块在自己的区块头里声明它认为自己所处的高度。
一个区块在链中的真实位置并不是可以随意主张的。它由 hashPrevBlock 决定:您在哪个区块之上构建,就决定了您落在哪里,您无法在这一点上撒谎同时还能接到链的末端。nHeight 是同一事实的第二份可写副本,而一个事实的副本,其价值永远只取决于把它与原始事实进行比对的那道检查。
两个哈希函数,其中一个什么都不验证
Ravencoin 的 hash.cpp 定义了两个 KAWPOW 入口,在您读到任何一个函数体之前,它们的签名就已经道出了大部分故事。第一个是 uint256 KAWPOWHash(const CBlockHeader& blockHeader, uint256& mix_hash)。第二个是 uint256 KAWPOWHash_OnlyMix(const CBlockHeader& blockHeader)。
第一个函数把 mix_hash 作为输出参数,因为它会产生一个 mix。它用 ethash::get_epoch_number(blockHeader.nHeight) 选择数据集,如果尚未持有该纪元的上下文就先构建它,然后在其上运行 progpow::hash。它返回的 mix 是完成这项工作的副产品。这就是内存密集路径,而内存密集正是 KAWPOW 存在、而不是采用普通哈希的全部理由。
第二个函数没有这样的参数,因为它什么也不产生。它直接使用区块头中已有的 mix,调用 progpow::hash_no_verify(blockHeader.nHeight, header_hash, to_hash256(blockHeader.mix_hash.GetHex()), blockHeader.nNonce64)。上游库把这个函数命名为 hash_no_verify,这个名字非常准确。它对传入的值执行最后的混合步骤并返回结果。它从不构建数据集,因此也从不确认传入的值只能通过完成这项工作才能得到。
这两个函数都不是错误。便宜的那个有正当的存在理由:一个正在同步数百万个旧区块的节点,如果这些区块已经由硬编码的检查点确定,就不应该为了重新确认无人争议的历史,而为每个纪元重建一个数 GB 的数据集。这样的设计唯一需要回答的问题是:哪些区块可以走这条路。
关口,以及它信任了什么
这个问题由 validation.cpp 中的 CheckBlockHeader 回答,答案上方的注释清楚地说明了意图:“If we are checking a KAWPOW block below a know checkpoint height. We can validate the proof of work using the mix_hash”。意图是正确的。实现却以 block.nHeight <= (uint32_t)pcheckpoint->nHeight 作为判断条件,而 block.nHeight 正是区块自己写下的那个数字。
下一行调用通用的 CheckProofOfWork(uint256 hash, unsigned int nBits, const Consensus::Params&)。请注意这个签名里没有什么:没有 mix,没有 nonce,也没有区块头。它接收一个现成的哈希和一个目标值并比较两者,完全无法知道传给它的数字是由两个函数中的哪一个产生的。对于 KAWPOW 区块,CBlockHeader::GetHash() 返回 KAWPOWHash_OnlyMix(*this),所以传给它的就是便宜的那个数字。
把这些串起来看:一个声明了足够低的 nHeight 的区块,其验证方式只是检查由它自己的 mix hash 推导出的值是否低于目标。在这条路径上,没有任何地方问过 mix 从何而来,也没有任何地方把声明的高度与区块实际所处的位置进行比较。
“足够低”到底有多低,其实一点也不微妙。已发布的 mainnet 链参数中最高的检查点是 2,383,550。第一个伪造区块位于 4,487,776。它的区块头必须声称自己比实际位置落后大约 210 万个区块,而由此选中的分支从未检查过这个差异。
这条捷径带来了什么,又没有带来什么
这里值得说得精确一些,因为大部分报道对此都比较含糊,而含糊的说法会让这个漏洞听起来既比实际更简单,又比实际更离奇。
这条捷径并没有降低难度。nBits 会根据真实的前一个区块的要求进行验证,而真实的前一个区块由 hashPrevBlock 选定,攻击者无法伪造它还能落在链的末端。伪造区块必须达到的目标,与同一分钟内诚实挖出的区块完全相同。
改变的是单次猜测的成本。诚实矿工的每一次尝试都要完整遍历一个以 GB 计的数据集,这正是内存密集型算法的全部意义,也是 KAWPOW 六年来让 ASIC 无法进入 Ravencoin 的原因。而这条捷径的成本只是对区块头做一次哈希,再对攻击者随意选取的值做一次最后的混合步骤。同样的彩票,每张票的中奖概率相同,但票价便宜了几个数量级。2Miners 把这些结果描述为几乎零工作量的区块,与 KAWPOW 本应付出的成本相比,这个描述是公允的。
网络上发生了什么
已知的第一个无效区块高度为 4,487,776,时间戳为 2026-08-07 15:44:01 UTC。漏洞利用一直持续到 8 月 10 日的紧急版本发布。在一段被检查的 2,089 个区块中,2Miners 统计到 96 个受影响的区块,因此伪造区块是分散在整个分支中,而不是集中在一段连续区间里,这也是为什么补救措施只能是链重组,而不能精确地将其剔除。
对于节点运营者而言,可见的症状比无效区块被悄悄接受更糟。节点在这些区块上崩溃,重启后停止同步。Tron Black 之后解释了这一机制,这是一个颇为典型的连带损害。区块索引存储的是区块实际所处的高度,而不是其区块头声明的高度。重启时,节点会根据索引重建每个区块头并重新计算其哈希,因此对于伪造区块,它用真实高度去哈希,而区块头里原本写的是一个假高度。哈希不再匹配,工作量证明检查失败,LoadBlockIndexGuts 中止。区块曾被接受一次,随后却无法加载,这就是为什么第二个症状比第一个持续得更久。
在该分支尚未解决期间,多家交易所暂停了 RVN 的充值和提现。对于一条近期历史即将被改写的链,这是正常且正确的应对,应当预料到它,而不应把它解读为对这个币的定论。
补丁,以及它的来源
第一个修复于 8 月 10 日以 4.6.1.1-hf1 版本发布,它来自矿池 2Miners,而不是 Ravencoin 项目本身。对任何安装了它的人来说,它止住了损失。一天之后它被新版本取代。
8 月 11 日通过 Ravencoin 仓库发布的 4.8.0 版本才是应该运行的版本。不要满足于任何更早的版本。它从 4,487,776 起拒绝声明的区块头高度与真实位置不符的区块,把 4,487,775 设为最后一个干净区块的检查点,在链状态损坏时自动重建而不是中止,并且合入了紧急热修复中没有的资产转账溢出修复,以及其他上游改进。
其中只有第一项才是真正的修复。拒绝声明高度与实际位置不符的区块,这个关口就关上了。检查点和自动重建属于恢复措施,而检查点尤其是一种粗暴的手段:它是一个硬编码的断言,声明某个特定区块是正确的区块,而这正是一条链希望尽量少用的东西。之所以用到它,是因为诚实分支必须比仅靠算力更快地稳固下来。
恢复点本身的选择是一个判断,而不是显而易见的事。Tron Black 写道,他曾请各矿池考虑一个更近的切点,让重组更浅,并把伪造区块作为例外单独处理。矿池们拒绝了,选择了最干净的切线。在工作量证明系统中,这种争论会自行了结:当大多数诚实算力拒绝一段历史时,这段历史就会被淘汰,而最大的两个矿池合起来就占了多数。
三天的重组究竟意味着什么
由于存在漏洞的节点接受了伪造区块,包含这些区块的分支在其上又累积了真实的链。丢弃它就意味着一次重组,而按照 Ravencoin 一分钟的出块目标,从 4,487,776 到补丁发布的这段时间大约相当于三天的区块。
如果您持有 RVN,实际影响是:在被丢弃分支上已确认的交易将不再处于确认状态。其中大多数会回到 mempool,并在存活的链上被重新打包,但这并无保证,而且有一类交易根本无法恢复。花费了被丢弃区块 coinbase 的交易,花的是在存活链上已不存在的币,建立在它之上的任何交易也是如此。这些交易就是无效的,而不是待处理。这就是为什么交易所暂停业务是正确的决定而不是反应过度,也是为什么在单一链端确定之前,4,487,775 之后的活动都值得谨慎对待。
如果您在那一周看到某个节点迟迟不动,有一个细节值得了解。Ravencoin 节点带有一条“禁止长距离重组”规则,用来防止突如其来的多数攻击改写深层历史。大多数持续运行的旧节点最终降到了该规则所依赖的对等节点数量以下,然后按设计接受了更长的链。少数节点没有,因为它们从未失去足够多的对等节点,而且按照现有规则,并没有任何规则被违反。这是一项保护机制在它从未针对的问题上完全按规范运行。这次分裂源于验证缺陷,而不是针对多数的攻击,而这条规则无法区分两者。
如果您挖 RVN,应该怎么做
如果您运行自己的 Ravencoin 节点,请运行 4.8.0,特别注意不要使用只解决了部分问题的早期版本。停止节点,替换二进制文件,再重新启动。无需重建索引,也没有手动步骤:软件会自行重建所需的内容。从紧急热修复升级的节点重启很快。从未打补丁的软件升级的节点需要重放和重建,在普通硬件上需要数小时,在此期间出现的 bad-blk-height 错误说明补丁正在起作用,而不是补丁出了问题。
如果您通过矿池挖矿,您这边无需做任何更改。没有新地址,没有新端口,也不需要更新挖矿软件。KAWPOW 本身从来都不是问题所在:算法完全按设计运行,缺陷在于决定何时运行它的代码。您投向旧分支的算力是真实的工作,只是它落在了随同其中散布的伪造区块一起被丢弃的区块上。
有一个只需一行的方法可以确认您最终位于正确的链上,值得实际去做而不是想当然。检查区块 4,494,143 的哈希。在规范链上,它以 4c67d6a4 结尾;在项目宣布网络恢复时,各个独立的 Ravencoin 区块浏览器显示的都是同一条链。
这个漏洞的形态
抛开细节,剩下的是一种在链内外都反复出现的形态:一个来自外部的值,被用来决定这个值本身要接受多严格的检查。区块头声明了自己的高度,而节点用这个声明来选择该区块头接下来必须通过哪一种工作量证明检查。
正确的做法并不复杂,补丁本质上就是其中的一行:在让声明的高度决定任何事情之前,先把它与区块实际所处的位置进行比较。它为什么会缺失,才是更值得关注的一半。这条捷径是为已经可信的区块编写的,在这个假设之下,这个关口读起来像是在陈述事实,而不是一个可能有人会不诚实回答的问题。一旦允许不可信的区块来填写这个字段,它就不再是这样了,而代码中没有任何地方标出这一变化发生的时刻。
关于它是如何被发现的,有两点值得记录。Ravencoin 的代码曾接受过专业的安全审计,但审计既没有发现这个问题,也没有发现与之一并修复的资产溢出问题。Tron Black 写道,他强烈怀疑两者都是由前沿模型 AI 安全审查发现的。无论使用了什么工具,这都符合这个漏洞的形态:如果按捷径自身的逻辑去读,缺陷是看不见的,只有当您问“谁能填写捷径所信任的那个字段”时,它才会显现。
最后值得注意的是谁先行动,以及顺序如何。一个矿池读懂了验证路径,并在三天内发布了紧急二进制文件。随后最大的两个矿池同意挖一条丢弃整个攻击窗口的链,而不是采用方便的切点;有人请它们采用更浅的切点,它们拒绝了。一天后项目发布了取代热修复、修复内容也更多的版本。谁先谁后并不重要,重要的是两者都发生了,而真正了结此事的并不是任何一份公告,而是分裂一侧累积的真实工作量。这就是去中心化在必须承担重任、而不是停留在白皮书里的那一天所呈现的样子。
这些数字从何而来
以便任何人都能自行核查,而不必只听我们的说法。区块头布局、两个哈希函数及其签名、纪元选择以及带注释的验证分支,均读自 Ravencoin 自己的源码,位于 src/primitives/block.h、src/primitives/block.cpp、src/hash.cpp、src/hash.h、src/pow.h 和 src/validation.cpp。检查点列表(包括 mainnet 最高的 2,383,550)读自 src/chainparams.cpp。第一个无效高度及其时间戳来自 Ravencoin 项目自己的网络公告。区块索引崩溃路径、无法恢复的依赖 coinbase 的交易、禁止长距离重组的行为、被拒绝的更浅恢复点、关于审计和 AI 审查的说法、4.8.0 包含的内容以及 4,494,143 的核对方法,来自 Tron Black 代表 Ravencoin 项目所作的说明。紧急热修复的版本号以及 2,089 个区块中 96 个受影响的统计,来自 2Miners 的发布说明及相关报道。来源中没有明确说明的内容,本文一律略去,而不是自行补充。