Vyper 不可重入锁漏洞事后分析技术报告

  • aisiji
  • 更新于 2023-08-15 22:51
  • 阅读 2240

编者按:Vyper被黑的时间线和反思从审计的角度重现并提醒开发者注意考虑项目的依赖,本文从开发的角度深度分析并总结了这次漏洞的前因后果

编者按: Vyper 被黑的时间线和反思 从审计的角度重现并提醒开发者注意考虑项目的依赖,本文从开发的角度深度分析并总结了这次漏洞的前因后果

Vperlang 团队特别感谢 Omniscia 团队

2023 年 7 月 30 日,由于 Vyper 编译器(特别是 0.2.15、0.2.16 和 0.3.0 版本)中的一个潜在漏洞,多个 Curve.Fi 流动池被利用。虽然 v0.3.1 版本发现并修补了该漏洞,但当时并未意识到该漏洞对使用易受攻击编译器的协议的影响,也未明确通知这些协议。该漏洞本身是一个有问题的重入保护,在某些特定条件下可以被绕过,我们将在本报告中深入探讨。

虽然包括 Curve.Fi 官方报告在内的其他报告已经充分报道了黑客攻击事件本身,但我们还是想深入探讨一下 Vyper 编译器本身到底出了什么问题,为什么漏洞难以被发现,以及整个生态系统能从这些事件中学到什么。

如果你熟悉区块链领域以及 Vyper 的存在原因,我们建议你跳过背景部分,因为其中包含的基本信息很可能你已经了解。

背景

Vyper

Vyper 是以以太坊虚拟机(EVM)为目标的,面向合约的,特定领域的 pythonic 编程语言。Vyper目标和原则是语言和编译器的简单性、安全性和可审计性。

EVM:一个单线程非并发的机器

在 EVM 上部署代码的一个常见问题是重入概念。与传统程序不同的是,"区块链程序" 的控制流会被让给正在执行的 "活动" 程序。 "区块链程序" 也称为合约。

详细来说,我们可以认为所有区块链程序都在一个单线程上运行,不支持并发。每当一个程序调用其他程序时,整个控制流会传递给被调用的程序。

重入:一个广泛存在于 Web 3.0 的问题

这意味着,在外部调用期间原始调用者的执行流基本上被冻结,直到被调用程序执行结束,调用者才会重新回到原来的位置。这种方式会导致不同类型的漏洞,其中最著名的就是重入漏洞。

当控制流传递让给被调用合约时,被调用的合约可以在冻结时重新进入原始调用者。容易受到此类攻击的合约会在外部合约调用的重入时更新状态更新,这意味着它们被冻结时的状态已过时且不正确。

解决办法

生态里的应用提出了两种方法来对抗重入攻击,并从根本上使重入失效:Checks-Effects-Interactions(CEI)模式和重入防护。

Checks-Effects-Interactions(CEI)模式

CEI 模式是一种编程方法,它规定函数代码应首先执行安全检查(Checks),然后执行存储中的影响(Effects)- 修改状态,最后在函数结束时执行与外部合约的交互(Interactions)。

如果严格遵守这一模式,"交互"(即传递控制流)期间的合约状态将是最新且正确的,从而使任何可能的重入合约利用变得不可能。

重入防护

在大多数情况下,CEI 模式就足够了,但 DeFi 生态系统是多方面的,函数往往依赖外部调用的结果来继续自己的执行。在这种情况下,CEI 模式就不适用了,必须设置重入防护

安全是 Vyper 语言的核心原则之一, Vyper 决定通过特定的 @nonreentrant 函数修饰器在语言级别直接引入重入防护。自 Vyper 早期版本之一 v0.1.0-beta.9 发布以来,重入防护就一直是该语言的核心功能。

重入防护的核心功能是在两种状态(activated, inactive)之间设置一个存储值。当标记为 @nonreentrant 的函数被调用时,flag 会:

  • 确保处于非激活状态
  • 设置为激活状态(activated)

一旦函数调用结束后,flag :

  • 设置为非激活状态(inactive)

有了这种机制,@nonreentrant 用户就可以确保只有在函数结束后才能重新调用它,也就是说,无论执行何种外部调用,都不会发生重入。还有更复杂的重入攻击形式存在(如view重入、跨合约重入),但就本漏洞而言,基本情况才是最重要的。

Vyper 漏洞历史时间线

@nonreentrant 基于标签的重入防护

自引入以来,@nonreentrant 一直支持设置 <key>,这与只在合约级全局应用的不可重入锁相比,它提供了更大的灵活性。

一个简单的实现是用mapping来获取key并设置相关的重入flag,但这种方法会因mapping查找的 keccak256 gas消耗而产生额外费用。

由于 Vyper 是一种不向用户提供原始存储访问的语言,因此在编译时它会完全了解合约使用的所有存储 slot 。因此,Vyper 会负责分配存储 slot ,包括确保存储变量和重入 key 锁的 slot 不会重叠。

PR#1264在 Vyper v0.1.0-beta.9 版本中引入了这个功能,用了一种简单的方法来确保不重叠,即在合约原始 slot 的特定偏移量(准确的说是 0xFFFFFF)处存储重入 flag 。

重构编译器

在开发新功能的同时,从 2018 年开始,Vyper 编译器开始了长达数年的重构工作,将当时的单通道架构重构为多通道架构,该架构将类型检查和语义分析的关注点分离到前端,与代码生成后端不同。与大多数大型重构项目一样,这项工作是渐进和零碎的,与其他错误修复和功能开发同时进行,直到 2023 年的 PR#3390 才最终完成。

位置优化: 更智能地分配存储 slot

PR#2308 是 Vyper v0.2.9 版本的一部分,其目的是在处理完合约的常规存储变量的所有 slot 后,利用第一个可用的未分配存储 slot ,而不是从 0xFFFFFF 常量开始作为重入锁flag ,从而更智能地分配存储空间。这将节省字节码空间,因为在字节码中,加载或存储不可重入键的存储槽位置可以使用更少的字节。

避免损坏:正确的抵消计算

上述 v0.2.9 版本的 PR 运行良好,只要在(物理的)存储布局前面按顺序分配的变量不跨越多个顺序 slot ,就能保证重入防护 flag slot 和存储 slot 之间没有重叠。

由于 Vyper 语言和代码库当时正在进行重大重构,PR#2361v0.2.13 版本的一部分)引入了一种更有效的方法,优化合约中存储跨多个存储 slot (32 字节)的变量。作为更大规模重构工作的一部分,它还将常规存储变量的槽计算从代码生成(codegen)后端通道到了新的前端通道中,但保留了重入 key 的 slot 计算。由于重入 key 的 slot 计算依赖于常规存储变量的分配结果,因此最终在前端和代码生成(codegen)通道之间保留了两种不同的常规存储变量分配器实现。这导致 PR#2308 的偏移计算不正确,需要更新。

PR#2379 引入了这个更新(v0.2.14 版本的一部分),其目的是通过考虑在存储中声明的变量的正确大小,而不是假设所有变量都占用一个 slot (在早期实现中确实如此),来正确计算重入 flag 的存储偏移量。不过,第二次更新仍有一个 bug,源于前端和代码生成(codegen) 分配器实现之间的差异,我们将在下文中加以说明。

由于这些 bug,v0.2.13v0.2.14 版本在发布后不久就被 "撤回(yanked)" 。

简而言之,"yanking "指的是为历史目的在版本库中提供标签,但不发布和提供下载。有关详细信息,请参阅 PEP-592

决定性事件:v0.2.14版本中的 "重入防护损坏"

v0.2.14 发布后不久,一位 Vyper 用户在 Vyper GitHub 代码库中打开了 issue #2393 ,指出在 Yearn vault 代码升级到 0.2.14 时,重入防护测试失败。

截取该用户提交问题时 Yearn 最新可用版本的快照,用 v0.2.14 编译,并用 EtherVM 反编译器检查反编译后的字节码(伪代码),会发现存储偏移 storage[0x2e]被用作 @nonreentrant("withdraw") 的 "flag",应用于 Vault.vy 文件的 def depositdef withdraw 实例中。

然而,合约级别的managementFee变量使用了相同的存储偏移量,这可以通过评估 managementFee() getter 函数和 setManagementFee() setter 函数的反编译函数来验证,这两个函数将重复使用相同的存储偏移量。

v0.2.13 版本编译相同的代码库时发现,重入防护按预期运行,并且没有出现存储重叠。不过,v0.2.14 版本的 PR#2379 并未完全解决重入防护损坏问题。

v0.2.14 版本中为 @nonreentrant 修饰器分配存储 slot 的代码仍然会在新的前端代码和当时的 codegen 分配器之间产生不正确的交互。由于前端和 codegen 分配器之间映射类型的分配策略不同,重入 slot 最终仍会与常规存储变量重叠。v0.2.14 版的数据损坏代码如下

def get_nonrentrant_counter(self, key):
    """
    Nonrentrant locks use a prefix with a counter to minimise deployment cost of a contract.
    We're able to set the initial re-entrant counter using the sum of the sizes
    of all the storage slots because all storage slots are allocated while parsing
    the module-scope, and re-entrancy locks aren't allocated until later when parsing
    individual function scopes. This relies on the deprecated _globals attribute
    because the new way of doing things (set_data_positions) doesn't expose the
    next unallocated storage location.
    """
    if key in self._nonrentrant_keys:
        return self._nonrentrant_keys[key]
    else:
        counter = (
            sum(v.size for v in self._globals.values() if not isinstance(v.typ, MappingType))
            + self._nonrentrant_counter
        )
        self._nonrentrant_keys[key] = counter
        self._nonrentrant_counter += 1
        return counter

将其与当时计算常规存储变量存储布局的前端代码进行比较。

    available_slot = 0
    for node in vyper_module.get_children(vy_ast.AnnAssign):
        type_ = node.target._metadata["type"]
        type_.set_position(StorageSlot(available_slot))
        available_slot += math.ceil(type_.size_in_bytes / 32)

虽然这段代码可以正确地消耗key值,并为相同的key值生成相同的 @nonreentrant 存储偏移量,但它却错误地计算了存储偏移量。

具体来说,旧的分配器没有为MappingType条目(即HashMap)分配存储 slot ,而新的分配器则分配了存储槽。MappingType 存储条目永远不会被写入,而是被编译器保留(参考:Issue 2436)。这导致了不可重入 key 分配器与前端分配器之间的不一致,从而导致了所报告的存储损坏。

引入漏洞:v0.2.15 版中失效的重入锁

v0.2.14 被撤回之后,为了修正 v0.2.14 版本中的重入防护损坏问题,v0.2.15 版本中的 PR#2391 通过将重入 key 移动到常规存储变量前面进行物理分配,修复了之前提到的 PR#2379 中引入的 bug。此外,为了减少此类问题再次出现的概率,该版本还将存储 slot 分配逻辑移至前端中与常规存储变量分配相同的函数中,从而完成了将存储 slot 分配逻辑从 codegen 通道中移除的工作。不过,这样做的同时,也删除了旧的 self._nonreentrant_keys 数据结构,更重要的是,删除了确保每个不可重入key只分配一个锁的相应逻辑:

    if key in self._nonrentrant_keys:
        # --> SAFE. only allocate one slot per key <--
        return self._nonrentrant_keys[key]

实际漏洞出现在 v0.2.15 版本的以下代码中:

# Allocate storage slots from 0
# note storage is word-addressable, not byte-addressable
storage_slot = 0

for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is not None:
        # --> BUG! should check nonreentrant key not already allocated <--
        type_.set_reentrancy_key_position(StorageSlot(storage_slot))
        # TODO use one byte - or bit - per reentrancy key
        # requires either an extra SLOAD or caching the value of the
        # location in memory at entrance
        storage_slot += 1

该漏洞是由于重入 key 的storage_slot偏移忽略了 @nonreentrant(<key>) 修饰器的实际 <key>,而只是简单的为每个看到的 @nonreentrant 修饰器预留一个新 slot ,而不管用的是什么 "key"。

潜伏: v0.2.15v0.2.16v0.3.0

v0.2.15 中引入的漏洞在 v0.2.16v0.3.0 临时版本中未被检测到,原因是当时 Vyper 代码库中没有足够的测试来检测该漏洞,这段时间为 2021 年 7 月 21 日至 2021 年 11 月 30 日之间的 4 个月。

所有使用 v0.2.15v0.2.16v0.3.0 版本编译的 Vyper 合约都会受到重入防护功能故障的影响。

修复:v0.3.1

v0.3.1 版通过调整编译器为合约中每个变量分配数据 slot 的方式,解决了此漏洞。该漏洞在两个不同的 PR 中得到修复。

PR#2439: 修复未使用的存储 slot

第一个部分修复漏洞的 PR 是 PR#2439,其中包含以下描述:

这不是一个语义漏洞,而是一个优化漏洞 我们分配的 slot 比实际需要的多,导致 slot 出现 "漏洞"。 分配器--已分配但未使用的 slot 。

这种描述实际上并没有清楚地说明问题所在。关于 "漏洞"的描述是通过观察编译输出的layout是如何为每个重入 key 生成单个slot值而得出的。为了更好地理解发生了什么,让我们来看看 v0.3.0 中的数据分配函数:

for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is not None:
        type_.set_reentrancy_key_position(StorageSlot(storage_slot))

        # TODO this could have better typing but leave it untyped until
        # we nail down the format better
        variable_name = f"nonreentrant.{type_.nonreentrant}"
        ret[variable_name] = {
            "type": "nonreentrant lock",
            "location": "storage",
            "slot": storage_slot,
        }

        # TODO use one byte - or bit - per reentrancy key
        # requires either an extra SLOAD or caching the value of the
        # location in memory at entrance
        storage_slot += 1

这段代码的问题在于,它将每个 type_(即单个 @nonreentrant key)的重入 key 位置设置为 storage_slot 的最新值,并在每次迭代时递增。这意味着 @nonreentrant(<key>)的相同实例会使用不同的 storage_slot 值,但variable_nameret 变量在每次迭代时都会被覆盖。

因此,编译器的layout输出包含单个 nonreentrant.<key> 条目和单个存储偏移,这意味着检查编译器的输出似乎只是简单的 "跳过" 连续的 @nonreentrant(<key>) 声明的存储 slot ,这与 PR 最初的逻辑是一致的。

v0.3.1版本中部分修补的,无漏洞代码:

 for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is None:
        continue

    variable_name = f"nonreentrant.{type_.nonreentrant}"

    # a nonreentrant key can appear many times in a module but it
    # only takes one slot. ignore it after the first time we see it.
    if variable_name in ret:
        continue

    type_.set_reentrancy_key_position(StorageSlot(storage_slot))

    # TODO this could have better typing but leave it untyped until
    # we nail down the format better
    ret[variable_name] = {
        "type": "nonreentrant lock",
        "location": "storage",
        "slot": storage_slot,
    }

    # TODO use one byte - or bit - per reentrancy key
    # requires either an extra SLOAD or caching the value of the
    # location in memory at entrance
    storage_slot += 1

现在,代码会在第一次识别到重复的重入 key 时分配一个单一的storage_slot。但是,它不会在有相同偏移量的每个 type_ 上调用 set_reentrancy_key_position 函数,这就意味着除了第一个外,其他任何 @nonreentrant(<key>) 条目都将使用 "未定义 "的存储偏移量。

这导致编译器在尝试编译带有 @nonreentrant 修饰器的合约时出现panic 。为了纠正这一问题,有必要进一步修改,以确保所有 @nonreentrant 修饰器都能正确认识到它们需要操作的存储 slot 。

panic: 也就是说,编译器会直接出错,而不会生成任何代码。编译器 "panic" 虽然会让用户感到恼火,但被认为是一种 "安全" 错误,因为它不会生成代码。︎

PR#2514: 修复 在使用不可重入键时 codegen 失败的问题

PR#2514 是缓解 @nonreentrant漏洞的最后一份 PR。具体来说,它扩展了上述代码段,以确保 set_reentrancy_key_position函数被正确调用,并为给定的 @nonreentrant 锁分配正确的 slot。

v0.3.1 版本 Vyper 的最终无漏洞代码如下:

for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is None:
        continue
    variable_name = f"nonreentrant.{type_.nonreentrant}"

    # a nonreentrant key can appear many times in a module but it
    # only takes one slot. after the first time we see it, do not
    # increment the storage slot.
    if variable_name in ret:
        _slot = ret[variable_name]["slot"]
        type_.set_reentrancy_key_position(StorageSlot(_slot))
        continue

    type_.set_reentrancy_key_position(StorageSlot(storage_slot))
    # TODO this could have better typing but leave it untyped until
    # we nail down the format better
    ret[variable_name] = {
        "type": "nonreentrant lock",
        "location": "storage",
        "slot": storage_slot,
    }
    # TODO use one byte - or bit - per reentrancy key
    # requires either an extra SLOAD or caching the value of the
    # location in memory at entrance
    storage_slot += 1

正如我们在上述片段中看到的,set_reentrancy_key_position 现在可以正确调用每个nonreentranttype_条目,而且只要在 @nonreentrant(<key>) 修饰器中指定了相同的key,就会正确使用相同的storage_slot

此外,除了上述修复之外,PR 还包含了 Vyper 代码库中一个急需但遗漏的测试;一个专门检查跨函数重入的单元测试:

@external
@nonreentrant('protect_special_value')
def protected_function(val: String[100], do_callback: bool) -> uint256:
    self.special_value = val

    if do_callback:
        self.callback.updated_protected()
        return 1
    else:
        return 2

@external
@nonreentrant('protect_special_value')
def protected_function2(val: String[100], do_callback: bool) -> uint256:
    self.special_value = val
    if do_callback:
        # call other function with same nonreentrancy key
        # --> (revert expected here) <--
        Self(self).protected_function(val, False)
        return 1
    return 2

然而,虽然在编译器代码库中发现、修复并测试了该错误,但当时并未意识到其对正式合约的影响,也没有明确通知可能使用相关编译器版本的协议。

@nonreentrant 修饰器中重复使用的相同 <key> 值的概念只有一个目的:跨函数重入防护。在 Vyper 0.3.1 版本发布之前,Vyper 存储库中一直缺少这样的测试,这也是导致该漏洞被引入并长期未被发现的原因之一。

漏洞总结

  • 受影响版本: v0.2.15v0.2.16v0.3.0
  • 根本原因:对 v0.2.13 中引入的重入防护数据损坏问题的补救不当
  • 漏洞简介:Vyper 合约中的所有 @nonreentrant 修饰器都将使用唯一的存储偏移量(无论其 key 如何),这意味着在使用易受影响版本编译的所有合约上都可能出现跨函数重入。

利用漏洞的条件

虽然漏洞本身很容易识别,而且在各种合约中都能观察到,但其要利用它却需要满足的一系列非常特殊的条件。具体来说:

  • 使用以下任一 vyper 版本编译的 .vy 合约:0.2.15, 0.2.16, 0.3.0
  • 使用 @nonreentrant 修饰器并带有特定key的主要函数,且未严格遵循 CEI 模式(即在存储更新前有对不可信任方的外部调用)。
  • 使用相同key的次要函数,会受到主函数导致的不正常状态的影响

不幸的是,这些条件正是在 Curve.Fi 流动性池中被利用的条件,因为它们需要在敏感的存储更新之前,在函数中执行原生 ETH 的分发(在 EVM 上,这只能通过执行上下文转移 CALL $^{[4]}$ 来完成),原本这些函数应该受到正常运行的 @nonreentrant 的保护。

从技术上讲,还有其他方法可以发送以太币,但在本文撰写时并不适用。EIP-5920 可能是这方面的一个积极进展。︎

总结和启示

对于任何大型生产型软件项目来说,错误都是一个不幸而严峻的现实。我们能做的就是尽最大可能减少错误及其相关风险。

我们可以采取几个切实可行的步骤来提高使用 Vyper 编译的智能合约的正确性:

1、改进编译器的测试,包括继续提高覆盖率、将编译器输出与语言规范进行比较,以及利用形式化验证(FV)工具进行编译器字节码验证。 2、为开发人员提供工具,使他们更容易采取多方面的方法来测试代码,包括源代码和字节码级测试。 3、使用 Vyper 对协议进行更严密的双向反馈

但是,仅仅关注最新版本编译器的正确性是不够的;由于智能合约的不可更改性,使用 Vyper 过去版本编译的合约可能会存在风险。

因此,确保 Vyper 过去版本的安全是另一个重要的新焦点,我们将在未来投入大量资源。这与为最新版本引入新功能、提供错误修复和重构一样重要。

最终,我们希望从最近发生的事件中吸取教训,确保 Vyper 成为世界上最稳固、最安全的智能合约语言和编译器项目。因此,这些目标将得到我们团队内外各种与安全相关的新举措的支持,这些举措包括:

  • 与 Codehawks 合作进行短期竞争性审计,重点关注 Vyper 的最新版本
  • 与 Immunefi 合作开展短期和长期(开放式)漏洞赏金计划,涵盖 Vyper 编译器的所有版本
  • Vyper 安全联盟,这是一个协调一致的多协议悬赏计划,旨在帮助发现当前和过去的编译器漏洞,这些漏洞会影响 Vyper 的实时 TVL 安全版本
  • 与包括 ChainSecurity、OtterSec、Statemind 和 Certora 在内的多家审计公司合作,审查 Vyper 过去的版本,以确保大量实时 TVL 的安全,并帮助对编译器进行持续审查。
  • 扩大团队;包括一个专门的安全工程角色,旨在全面改进 Vyper 的安全工具,包括内部工具和面向用户的工具
  • 与为 Solidity 提供的现有安全工具包合作,使 Vyper 生态系统受益匪浅
  • 设计语言规范,这将有助于正式验证和帮助编译器本身的测试工作

我们希望很快就能看到你使用 Vyper :).请继续关注未来几周有关这些计划的更多公告!如需了解更多公告,请关注 Vyper 官方 twitter。如需帮助,请访问 Vyper Github!如果你对 Vyper 感兴趣并希望提供资金帮助,或者只是想聊聊天,请通过 Vyper discord 联系我们,我们将随时欢迎你加入社区。


英文原文链接:https://hackmd.io/@vyperlang/HJUgNMhs2#fn1

点赞 2
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
aisiji
aisiji
江湖只有他的大名,没有他的介绍。