采用内存安全语言有助于 RPC 节点避免许多基于内存破坏漏洞的攻击,但仍需要经过精心设计和审查。
撰文:CertiK
CertiK 的 Skyfall 团队最近在 Aptos、StarCoin 和 Sui 等多个区块链中发现了基于 Rust 的 RPC 节点的多个漏洞。由于 RPC 节点是连接 dApp 和底层区块链的关键基础设施组件,其稳健性对于无缝操作至关重要。区块链设计者都知道稳定 RPC 服务的重要性,因此他们采用 Rust 等内存安全语言来规避可能破坏 RPC 节点的常见漏洞。
采用内存安全语言(如 Rust)有助于 RPC 节点避免许多基于内存破坏漏洞的攻击。然而,通过最近的审计,我们发现即使是内存安全的 Rust 实现,如果没有经过精心设计和审查,也很容易受到某些安全威胁的影响,从而破坏 RPC 服务的可用性。
本文我们将通过实际案例介绍我们对一系列漏洞的发现。
区块链的远程过程调用(RPC)服务是 Layer 1 区块链的核心基础设施组件。它为用户提供重要的 API 前端,并作为通向后端区块链网络的网关。然而,区块链 RPC 服务与传统的 RPC 服务不同,它方便用户交互无需身份验证。服务的持续可用性至关重要,任何服务中断都会严重影响底层区块链的可用性。
对传统 RPC 服务器的审计主要集中在输入验证、授权 / 认证、跨站请求伪造 / 服务器端请求伪造(CSRF/SSRF)、注入漏洞(如 SQL 注入、命令注入)和信息泄露等方面进检查。
然而,区块链 RPC 服务器的情况有所不同。只要交易是签名的,就不需要在 RPC 层对发起请求的客户端进行身份验证。作为区块链的前端,RPC 服务的一个主要目标是保证其可用性。如果它失效,用户就无法与区块链交互,从而阻碍查询链上数据、提交交易或发布合约功能。
因此,区块链 RPC 服务器最脆弱的方面是「可用性」。如果服务器宕机,用户就失去了与区块链交互的能力。更严重的是,一些攻击会在链上扩散,影响大量节点,甚至导致整个网络瘫痪。
一些著名的 Layer 1 区块链,如 Aptos 和 Sui,使用内存安全编程语言 Rust 实现其 RPC 服务。得益于其强大的安全性和编译时严格的检查,Rust 几乎可以使程序免受内存破坏漏洞的影响,如堆栈溢出、和空指针解引用和释放后重引用等漏洞。
为了进一步确保代码库的安全,开发人员需严格遵循最佳实践,例如不引入不安全代码。在源代码中使用#![forbid(unsafe_code)]确保阻拦过滤不安全的代码。
区块链开发者执行 Rust 编程实践的例子
为了防止整数溢出,开发人员通常使用 checked_add、checked_sub、saturating_add、saturating_sub 等函数,而不是简单的加法和减法(+、-)。通过设置适当的超时、请求大小限制和请求项数限制来缓解资源耗尽。
尽管不存在传统意义上内存不安全的漏洞,但 RPC 节点会暴露在攻击者容易操纵的输入中。在内存安全 RPC 实现中,有几种情况会导致拒绝服务。例如,内存放大可能会耗尽服务的内存,而逻辑问题可能会引入无限循环。此外,竞态条件也可能构成威胁,并发操作可能会出现意外的事件序列,从而使系统处于未定义的状态。此外,管理不当的依赖关系和第三方库可能会给系统带来未知漏洞。
在这篇文章中,我们的目的是让人们关注可以触发 Rust 运行时保护的更直接的方式,从而导致服务自行中止。
开发人员可以有意或无意地引入显式 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 的可能性。
显式 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 的研究团队通过现实世界中的例子探讨了这些风险,也足以证明了内存安全编程中需要谨慎和细致的设计。
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。