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