崩溃的RPC:内存安全区块链RPC节点新型漏洞解析
时间:2023-07-18 来源:区块链网络 作者:CertiK中文社区
CertiK的Skyfall团队最近在Aptos、StarCoin和Sui等多个区块链中发现了基于Rust的RPC节点的多个漏洞。由于RPC节点是连接dApp和底层区块链的关键基础设施组件,其稳健性对于无缝操作至关重要。区块链设计者都知道稳定RPC服务的重要性,因此他们采用Rust等内存安全语言来规避可能破坏RPC节点的常见漏洞。 采用内存安全语言(如Rust)有助于RPC节点避免许多基于内存破坏漏洞的攻击。然而,通过最近的审计,我们发现即使是内存安全的Rust实现,如果没有经过精心设计和审查,也很容易受到某些安全威胁的影响,从而破坏RPC服务的可用性。 本文我们将通过实际案例介绍我们对一系列漏洞的发现。 区块链RPC节点作用 区块链的远程过程调用(RPC)服务是Layer 1区块链的核心基础设施组件。它为用户提供重要的API前端,并作为通向后端区块链网络的网关。然而,区块链RPC服务与传统的RPC服务不同,它方便用户交互无需身份验证。服务的持续可用性至关重要,任何服务中断都会严重影响底层区块链的可用性。 审计角度:传统RPC服务器 VS 区块链RPC服务器 对传统RPC服务器的审计主要集中在输入验证、授权/认证、跨站请求伪造/服务器端请求伪造(CSRF/SSRF)、注入漏洞(如SQL注入、命令注入)和信息泄露等方面进检查。 然而,区块链RPC服务器的情况有所不同。只要交易是签名的,就不需要在RPC层对发起请求的客户端进行身份验证。作为区块链的前端,RPC服务的一个主要目标是保证其可用性。如果它失效,用户就无法与区块链交互,从而阻碍查询链上数据、提交交易或发布合约功能。 因此,区块链RPC服务器最脆弱的方面是“可用性”。如果服务器宕机,用户就失去了与区块链交互的能力。更严重的是,一些攻击会在链上扩散,影响大量节点,甚至导致整个网络瘫痪。 为何新区块链会采用内存安全的RPC 一些著名的Layer 1区块链,如Aptos和Sui,使用内存安全编程语言Rust实现其RPC服务。得益于其强大的安全性和编译时严格的检查,Rust几乎可以使程序免受内存破坏漏洞的影响,如堆栈溢出、和空指针解引用和释放后重引用等漏洞。 为了进一步确保代码库的安全,开发人员需严格遵循最佳实践,例如不引入不安全代码。在源代码中使用#![forbid(unsafe_code)]确保阻拦过滤不安全的代码。 区块链开发者执行Rust编程实践的例子 为了防止整数溢出,开发人员通常使用checked_add、checked_sub、saturating_add、saturating_sub等函数,而不是简单的加法和减法(+、-)。通过设置适当的超时、请求大小限制和请求项数限制来缓解资源耗尽。 Layer 1区块链中内存安全RPC威胁 尽管不存在传统意义上内存不安全的漏洞,但RPC节点会暴露在攻击者容易操纵的输入中。在内存安全RPC实现中,有几种情况会导致拒绝服务。例如,内存放大可能会耗尽服务的内存,而逻辑问题可能会引入无限循环。此外,竞态条件也可能构成威胁,并发操作可能会出现意外的事件序列,从而使系统处于未定义的状态。此外,管理不当的依赖关系和第三方库可能会给系统带来未知漏洞。 在这篇文章中,我们的目的是让人们关注可以触发 Rust 运行时保护的更直接的方式,从而导致服务自行中止。 显式的Rust Panic:一种直接终止RPC 服务的方法 开发人员可以有意或无意地引入显式panic代码。这些代码主要用于处理意外或异常情况。一些常见的例子包括: assert!():当必须满足一个条件时使用该macro。如果断言的条件失败,程序将 panic,表明代码中存在严重错误。 panic!():当程序遇到无法恢复的错误且无法继续执行时调用该函数。 unreachable!():当一段代码不应该被执行时使用该macro。如果该macro被调用,则表示存在严重的逻辑错误。 unimplemented!()和todo!():这些宏是尚未实现功能的占位符。如果达到该值,程序将崩溃。 unwrap():该方法用于Option或Result类型,当遇到Err变量或None时会导致程序宕机。 漏洞一:触发Move Verifier中的assert! Aptos区块链采用Move字节码验证器,通过对字节码的抽象解释进行引用安全分析。execute()函数是TransferFunctions trait实现的一部分,模拟基本块中字节码指令的执行。 函数execute_inner()的任务是解释当前字节码指令并相应地更新状态。如果我们已经执行到基本块中的最后一条指令,如index == last_index所示,函数将调用assert!(self.stack.is_empty())以确保栈为空。此行为背后的意图是保证所有操作都是平衡的,这也意味着每次入栈都有相应的出栈。 在正常的执行流程中,栈在抽象解释过程中始终是平衡的。堆栈平衡检查器(Stack Balance Checker)保证了这一点,它在解释之前对字节码进行了验证。然而,一旦我们将视角扩展到抽象解释器的范围,就会发现堆栈平衡假设并不总是有效的。 AbstractInterpreter中analyze_function漏洞的补丁程序 抽象解释器的核心是在基本块级别中模拟字节码。在其最初的实现中,在execute_block过程中,遇到错误会提示分析过程记录错误,并继续执行控制流图中的下一个块。这可能会造成一种情况:执行块中的错误会导致堆栈不平衡。如果在这种情况下继续执行,就会在堆栈不为空的情况下进行assert!检查,从而引发panic。 这就使得攻击者有机可趁。攻击者可通过在execute_block()中设计特定的字节码来触发错误,随后execute()有可能在堆栈不为空的情况下执行assert,从而导致assert检查失败。这将进一步导致panic并终止RPC服务,从而影响其可用性。 为防止出现这种情况,已实施的修复中,确保了在execute_block函数首次出现错误时会停止整个分析过程,进而避免了因错误导致堆栈不平衡而继续分析时可能发生的后续崩溃风险。这一修改消除了可能引起panic的情况,并有助于提高抽象解释器的健壮性和安全性。 漏洞二:触发StarCoin中的panic! Starcoin区块链有自己的Move实现分叉。在这个Move repo中,Struct类型的构造函数中存在一个panic! 如果提供的StructDefinition拥有 Native 字段信息,就会显式触发这个panic!。 规范化例程中初始化结构体的显式panic 这种潜在风险存在于重新发布模块的过程中。如果被发布的模块已经存在于数据存储中,则需要对现有模块和攻击者控制的输入模块进行模块规范化处理。在这个过程中,"normalized::Module::new "函数会从攻击者控制的输入模块中构建模块结构,从而触发 "panic!"。 规范化例程的前提条件 通过从客户端提交特制的有效载荷,可以触发该panic。因此,恶意行为者可以破坏RPC服务的可用性。 结构初始化panic补丁 Starcoin的补丁引入了一个新的行为来处理Native情况。现在,它不会引起panic,而是返回一个空的ec。这减少了用户提交数据引起panic的可能性。 隐式 Rust Panic: 一种容易被忽视的终止RPC服务的方法 显式 panic 在源代码中很容易识别,而隐式panic则更可能被开发人员忽略。隐式panic通常发生在使用标准或第三方库提供的API时。开发人员需要彻底阅读和理解API文档,否则他们的Rust程序可能会意外停止。 BTreeMap 中的隐式 panic 让我们以Rust STD中的BTreeMap为例。BTreeMap是一种常用的数据结构,它以排序的二叉树形式组织键值对。BTreeMap提供了两种通过键值检索值的方法:get(&self, key: &Q)和index(&self, key: &Q)。 方法get(&self, key: &Q)使用键检索值并返回一个Option。Option可以是Some(&V),如果key存在,则返回值的引用,如果在BTreeMap中没有找到key,则返回None。 另一方面,index(&self, key: &Q)直接返回键对应的值的引用。然而,它有一个很大的风险:如果键不存在于BTreeMap中,它会触发隐式panic。如果处理不当,程序可能会意外崩溃,从而成为一个潜在漏洞。 事实上,index(&self, key: &Q)方法是std::ops::Index trait的底层实现。该特质为不可变上下文中的索引操作(即 container[index])提供了方便的语法糖。开发者可以直接使用 btree_map[key],调用 index(&self, key: &Q) 方法。然而,他们可能会忽略这样一个事实:如果找不到key,这种用法可能会触发panic,从而对程序的稳定性造成隐性威胁。 漏洞三:在Sui RPC中触发隐式panic Sui模块发布例程允许用户通过RPC提交模块有效载荷。在将请求转发给后端验证网络进行字节码验证之前,RPC处理程序使用SuiCommand::Publish函数直接反汇编接收到的模块。 在这个反汇编过程中,提交模块中的code_unit部分被用来构建一个VMControlFlowGraph。该构建过程包括创建基本块,这些块存储在一个名为 "'blocks' "的BTreeMap中。该过程包括创建和操作该Map,在某些条件下,隐式panic会在这里触发。 下面是一段简化的代码: 创建VMControlFlowGraph时的隐式panic 在该代码中,通过遍历代码并为每个代码单元创建一个新的基本块来创建一个新的VMControlFlowGraph。基本块存储在一个名为block的BTreeMap中。 在对堆栈进行迭代的循环中,使用block[&block]对块图进行索引,堆栈已经用ENTRY_BLOCK_ID进行了初始化。这里的假设是,在block映射中至少存在一个ENTRY_BLOCK_ID。 然而,这一假设并不总是成立的。例如,如果提交的代码是空的,那么在“创建基本块”过程之后,“块映射”仍然是空的。当代码稍后尝试使用&blocks[&block].successors中的for succ遍历块映射时,如果未找到key,可能会引起隐式panic。这是因为blocks[&block]表达式本质上是对index()方法的调用,如前所述,如果键不存在于BTreeMap中,index()方法将导致panic。 拥有远程访问权限的攻击者可以通过提交带有空code_unit字段的畸形模块有效载荷来利用该函数的漏洞。这个简单的RPC请求会导致整个JSON-RPC进程崩溃。如果攻击者以最小的代价持续发送此类畸形有效载荷,就会导致服务持续中断。在区块链网络中,这意味着网络可能无法确认新的交易,从而导致拒绝服务(DoS)情况。网络功能和用户对系统的信任将受到严重影响。 Sui的修复:从RPC发布例程中移除反汇编功能 值得注意的是,Move Bytecode Verifier中的CodeUnitVerifier负责确保code_unit部分绝不为空。然而,操作顺序使RPC处理程序暴露于潜在的漏洞中。这是因为验证过程是在Validator节点上进行的,而该节点是在RPC处理输入模块之后的一个阶段。 针对这一问题,Sui通过移除模块发布RPC例程中的反汇编功能来解决该漏洞。这是防止RPC服务处理潜在危险、未经验证的字节码的有效方法。 此外,值得注意的是,与对象查询相关的其他RPC方法也包含反汇编功能,但它们不容易受到使用空代码单元的攻击。这是因为它们总是在查询和反汇编现有的已发布模块。已发布的模块必须已经过验证,因此,在构建VMControlFlowGraph时,非空代码单元的假设始终成立。 对开发人员的建议 在了解了显式和隐式panic对区块链中RPC服务稳定性的威胁后,开发人员必须掌握预防或降低这些风险的策略。这些策略可以降低服务意外中断的可能性,提高系统的弹性。因此CertiK的专家团队提出以下建议,并作为Rust编程的最佳实践为大家列出。 Rust Panic Abstraction: 尽可能考虑使用Rust的catch_unwind函数来捕获panic,并将其转换为错误信息。这可以防止整个程序崩溃,并允许开发人员以可控的方式处理错误。 谨慎使用API:隐式panic通常是由于滥用标准或第三方库提供的API而发生的。因此,充分理解API并学会适当处理潜在错误至关重要。开发人员要始终假设API可能会失效,并为这种情况做好准备。 适当的错误处理:使用Result和Option类型进行错误处理,而非求助于panic。前者提供了一种更可控的方式来处理错误和特殊情况。 添加文档和注释:确保代码文档齐全,并在关键部分(包括可能发生panic的部分)添加注释。这将帮助其他开发人员了解潜在风险并有效处理。 总结 基于Rust的RPC节点在Aptos、StarCoin和Sui等区块链系统中扮演着重要的角色。由于它们用于连接DApp和底层区块链,因此它们的可靠性对于区块链系统的平稳运行至关重要。尽管这些系统使用的是内存安全语言Rust,但仍然存在设计不当的风险。CertiK的研究团队通过现实世界中的例子探讨了这些风险,也足以证明了内存安全编程中需要谨慎和细致的设计。 查看更多 |