此前,CertiK 团队于 Sui 区块链发现了一系列拒绝服务漏洞。在这些漏洞中,一种新型且具有严重影响力的漏洞格外引人注目。该漏洞可导致 Sui 网络节点无法处理新的交易,效果等同于整个网络完全关闭。
就在上周一,CertiK 因发现该重大安全漏洞,获得了 SUI 50 万美元漏洞赏金。美国业内权威媒体 CoinDesk 对该事件进行了报道,随后各大媒体也紧随其报道发布了相关新闻。
该安全漏洞被形象地称为“仓鼠轮”:其独特的攻击方式与目前已知的攻击不同,攻击者只需提交一个大约 100 字节的载荷,就能触发 Sui 验证节点中的一个无限循环,使其不能响应新的交易。
此外,攻击带来的损害在网络重启后仍能持续,并且能在 Sui 网络中自动传播,让所有节点如仓鼠在轮上无休止地奔跑一样无法处理新的交易。因此我们将这种独特的攻击类型称为“仓鼠轮”攻击。
发现该漏洞后,CertiK 通过 Sui 的漏洞赏金计划向 Sui 进行了报告。Sui 也第一时间进行了有效回应,确认了该漏洞的严重性,并在主网启动前积极采取了相应措施对问题进行了修复。除了修复此特定的漏洞外,Sui 还实施了预防性的缓解措施,以减少该漏洞可能造成的潜在损害。
为了感谢 CertiK 团队负责地披露,Sui 向 CertiK 团队颁发了 50 万美元奖金。
下文中将从技术层面披露此关键漏洞的细节,阐明该漏洞的根本原因和潜在影响。
Move 链上的恶意载荷威胁
Sui 链在原始 Move 虚拟机之上提供了一套新的存储模型与接口,因此 Sui 有一个定制版的 Move 虚拟机。为了支持新的存储原语,Sui 进一步针对不可信载荷的安全验证引入了一系列额外的、定制的检查手段,如对象安全及全局存储访问等功能。这些定制检查手段契合了 Sui 的独特功能,因此我们将这些定制检查称为 Sui 验证器。
Sui 对载荷的检查顺序
如上图所示,验证器中的大多数检查会针对 CompiledModule(表示用户提供的合约载荷运行)进行结构层面的安全验证。例如,通过“重复检查器”确保运行时载荷中没有重复的条目;通过“限制检查器”确保运行时载荷中每个字段的长度都在允许的条目上限之内。
除了结构层面的检查之外,验证器的静态检查仍需要更复杂的分析手段,以确保不可信载荷在语义层面的强健性。
由 Move 提供的抽象解释器,是一个专门为通过抽象解释在字节码上执行复杂安全分析而设计的框架。这种机制使得验证过程更加精细和准确,每个验证者都被允许定义他们独特的抽象状态从而进行分析。
在开始运行时,抽象解释器从编译的模块中构建控制流图(CFG)。这些 CFG 中的每个基本块都会维护一组状态,即“前序状态”和“后序状态”。“前序状态”提供了一个基本块执行前的程序状态快照,而“后序状态”则提供了基本块执行后的程序状态描述。
当抽象解释器在控制流图中没有遇到回跳(或循环)时,它则遵循一个简单的线性执行原则:每个基本块都被依次分析,并根据块中每个指令的语义计算出前序状态和后序状态。其结果就是一个程序在执行过程中每个基本块级别状态的精准快照,帮助验证程序的安全属性。
Move 抽象解释器的工作流程
然而,当控制流中存在循环时,这个过程则变得更加复杂。循环的出现意味着控制流图中包含一条回跳的边,回跳边的源头对应着当前基本块的后序状态,而回跳边的目标基本块(循环头部)是一个之前已经分析过的基本块的前序状态,因此抽象解释器需要对回跳相关的两个基本块的状态进行仔细合并。
如果发现合并后状态与循环头部基本块现有的前序状态不同,抽象解释器就会更新循环头部基本块的状态,并从这个基本块开始重新启动分析。这个迭代分析过程将一直持续到循环预状态稳定。换句话说,这个过程不断重复,直到循环头部基本块的前序状态在迭代之间不再变化。达到一个固定点,则表明循环分析已经完成。
与原来的 Move 设计不同,Sui 的区块链平台引入了一个独特的以“目标”为中心的全局存储模型。这个模型的一个显著特点是:任何具有 key 属性(作为索引上链存储)的数据结构必须以 ID 类型作为该结构的第一个字段。ID 字段不可改变,且不能转移到其他目标上,因为每个对象必须有一个全局唯一的 ID。为了确保这些特性,Sui 在抽象解释器上建立了一套自定义分析逻辑。
IDLeak 验证器,也被称为 id_leak_verifier,与抽象解释器协同工作进行分析。它有着自己独特的 AbstractDomain,被称为 AbstractState。每个 AbstractState 由多个局部变量对应的 AbstractValue 组成。通过 AbstractValue 来监督每个局部变量的状态,以此来追踪一个 ID 变量是否是全新的。
在结构体打包的过程中,IDLeak 验证器只允许将一个全新的 ID 打包到一个结构体中。通过抽象解释分析,IDLeak 验证器可以详尽地跟踪本地数据流状态,以确保没有现有的 ID 被转移到其他结构体对象。
在 AbstractState::join 中,该函数将另一个 AbstractState 作为输入,并试图将其本地状态与当前对象的本地状态合并。对于输入状态中的每个局部变量,它将该变量的值与它在局部状态中的当前值进行比较(如果没有找到,默认值为 AbstractValue::Other)。如果这两个值不相等,它将设置一个“changed”的标志,作为最终状态合并结果是否变化的依据,并通过调用 AbstractValue::join 来更新本地状态中的本地变量值。
在 AbstractValue::join 中,该函数将其值与另一个 AbstractValue 进行比较。如果它们相等,它将返回传入的值。如果不相等,则返回 AbstractValue::Other。
然而,这个状态维护逻辑包含一个隐藏的不一致性问题。尽管 AbstractState::join 会基于新旧值的不同而返回一个表示合并状态发生变化(JoinResult::Changed)的结果,但合并更新后的状态值仍然可能是不变的。
这种不一致的问题是由操作顺序导致的:在 AbstractState::join 中对改变状态的判定发生在状态更新(AbstractValue::join)之前,这种判定并不反映真正的状态更新结果。
此外,在 AbstractValue::join 中,AbstractValue::Other 对合并的结果起着决定性作用。例如,如果旧值是 AbstractValue::Other,而新值是 AbstractValue::Fresh,则更新的状态值仍然是 AbstractValue::Other,即便新旧值不同,更新后状态本身没有变化。
示例:状态连接的不连贯性
这就引入了一个不一致:即合并基本块状态的结果被判定为“改变”,但合并后的状态值本身并没有发生变化。在抽象解释分析的过程中,出现这种不一致问题有可能产生严重的后果。我们回顾抽象解释器在控制流图(CFG)中出现循环时的行为:
当遇到一个循环时,抽象解释器采用一种迭代的分析方法来合并回跳目标基本块和当前基本块的状态。如果合并后的状态发生变化,抽象解释器则会从跳转目标开始重新分析。
然而,如果抽象解释分析的合并操作错误地将状态合并结果标记为“变化”,而实际上状态内部变量的值没有发生变化,就会导致无休止的重新分析,产生无限循环。
利用这种不一致性,攻击者可以构造一个恶意的控制流图,诱使 IDLeak 验证器进入一个无限循环。这个精心构造的控制流图由三个基本块组成:BB 1 和 BB 2 ,BB 3 。值得注意的是,我们有意引入了一条从 BB 3 到 BB 2 的回跳边来构造一个循环。
恶意 CFG 状态,可导致 IDLeak 验证器内部死循环
这个过程从 BB 2 开始,其中一个特定局部变量的 AbstractValue 被设置为::Other。在执行 BB 2 之后,流程转移到 BB 3 ,在那里同一变量被设置为::Fresh。在 BB 3 的结尾处,有一条回跳边,跳转到 BB 2 。
在抽象解释分析这个例子的过程中,前文提到的不一致性起到了关键作用。当回跳边被处理时,抽象解释器试图将 BB 3 的后序状态(变量为“::Fresh”)与 BB 2 的前序状态(变量为“::Other”)连接起来。AbstractState::join 函数注意到了这个新旧值不同的差异并设置了“变化”的标志,以此表示需要对 BB 2 的进行重新分析。
然而,AbstractValue::join 中 “::Other”的主导行为意味着 AbstractValue 合并后,BB 2 状态变量的实际值仍然是“::Other”,状态合并的结果并没有发生变化。
因此这个循环过程一旦开始,即当验证器继续重新分析 BB 2 以及它的所有后继基本块节点(本例中为 BB 3),它就会无限期地持续下去。无限循环消耗了所有可用的 CPU 周期,使其无法处理响应新的交易,这种情况在验证器重新启动后仍然存在。
通过利用这个漏洞,验证节点如仓鼠在轮上无休止地奔跑一样无限循环,无法处理新的交易。因此我们将这种独特的攻击类型称为“仓鼠轮”攻击。
“仓鼠轮”攻击可以有效地使 Sui 验证器陷入停顿,进而导致整个 Sui 网络瘫痪。
理解了漏洞成因与触发过程之后,我们通过使用以下 Move 字节码模拟构建了一个具体例子,成功地在真实环境中的模拟中触发了该漏洞:
这个例子通过精心构造的字节码,展示了如何在真实环境中触发漏洞。具体来说,攻击者可以在 IDLeak 验证器中触发一个无限循环,利用仅仅约 100 字节的载荷即可消耗 Sui 节点的所有 CPU 周期,有效阻止新交易处理,并导致 Sui 网络拒绝服务。
Sui 的漏洞赏金计划对漏洞等级的评定有着严格的规定,主要依据对整个网络的危害程度进行评定。满足“严重(critical)”评级的漏洞必须使整个网络关停并有效阻碍新交易确认,同时需要硬分叉来修复问题;如果漏洞只能使部分网络节点拒绝服务,至多被评定为 “中危(medium)”或“高危(high)”漏洞。
CertiK Skyfall 团队发现的“仓鼠轮”漏洞可以使整个 Sui 网络关停,同时需要官方发布新版本进行升级修复。基于对该漏洞的危害程度,Sui 最终被将其评定为“严重”等级。为了进一步理解“仓鼠轮”攻击造成的严重性影响原因,我们有必要了解 Sui 后端系统的复杂架构,特别是链上交易发布或升级的整个过程。
在 Sui 中提交交易的交互概述
最初,用户交易通过前端 RPC 提交,经基本验证后传递到后端服务。Sui 后端服务负责进一步验证传入的交易载荷。在成功验证了用户的签名后,交易被转化为交易证书(包含交易信息以及 Sui 的签名)。
这些交易证书是 Sui 网络运作的基本组成部分,可以在在网络中的各个验证节点之间传播。对于合约创建/升级交易,在其可以上链之前,验证节点会调用 Sui 验证器检查并验证这些证书的合约结构/语义的有效性。正是在这个关键的验证阶段,“死循环”漏洞可以被触发利用。
当该漏洞被触发时,它会导致验证过程无限期中断,有效阻碍系统处理新交易的能力,并导致网络完全关闭。雪上加霜的是,节点重启后该情况仍然存在,这也就意味着传统的缓解措施远远不够。该漏洞一旦被触发,则会出现“持续破坏”的情况从而对整个 Sui 网络留下持久影响。
这样一来,状态合并的结果与真实更新的结果将保持一致,分析过程中不会发生死循环。
除了修复这个特定的漏洞外,Sui 还部署了缓解措施,以减少未来验证器漏洞的影响。根据 Sui 在 bug 报告中的回复,缓解措施涉及一个叫做 Denylist 的功能。
然而,验证器有一个节点配置文件,允许他们暂时拒绝某些类别的交易。这个配置可以用来暂时禁止处理发布和软件包升级。由于这个 bug 是在签署发布或软件包升级 tx 之前运行 Sui 验证器时发生的,而拒绝列表将停止验证器的运行并将恶意 tx 丢弃,暂时拒绝列表这些 tx 类型是一个 100% 有效的缓解措施(尽管它将暂时中断试图发布或升级代码的人的服务)。
顺便提一下,我们有这个 TX 拒绝列表配置文件已经有一段时间了,但我们也为证书添加了一个类似的机制,作为你之前报告的 “验证器死循环 ”漏洞的后续缓解手段。有了这个机制,我们将对这种攻击有更大的灵活性:我们将使用证书拒绝名单配置来使验证器忘记坏的证书(打破死循环),并使用 TX 拒绝名单配置来禁止发布/升级,从而防止创建新的恶意攻击交易。谢谢你让我们思考这个问题!
验证器在签署交易之前有有限的 "ticks"(与 gas 不同)用于字节码验证,如果在交易中发布的所有字节码不能在这么多 ticks 中得到验证,验证器将拒绝签署该交易,防止它在网络上执行。以前,计量只适用于一组选定的复杂验证器通过。为了应对这个问题,我们将计量扩展到每个验证器,以保证验证器在每个 tick 的验证过程中所执行的工作有一个约束。我们还修复了 ID 泄漏验证器中的潜在无限循环错误。
——来自 Sui 开发者关于漏洞修复的说明
总而言之,Denylist 使验证者能够通过禁用发布或升级流程来暂时规避针对验证器中的漏洞利用并有效地防止一些恶意交易带来的的潜在破坏。当 Denylist 的缓解措施生效时,节点通过牺牲自身的发布/更新合约功能,来确保自己能够继续工作。