该文章深入分析了 Vyper 编译器中存在的漏洞,该漏洞影响了 Curve.Fi 流动性池,导致其被利用。文章详细追溯了漏洞的产生、发展和修复过程,解释了 @nonreentrant
装饰器在特定 Vyper 版本中的失效原因,以及攻击者如何利用此漏洞获利。此外,文章还总结了经验教训,并提出了改进 Vyper 安全性的措施和未来计划。
####### Vyperlang 团队,特别感谢 Omniscia 团队[1]
在 2023 年 7 月 30 日,多个 Curve.Fi 流动性池由于 Vyper 编译器中的一个潜在漏洞而被利用,特别是在 0.2.15
、0.2.16
和 0.3.0
版本中。 虽然该漏洞已被 v0.3.1
版本发现并修复,但当时并未意识到对使用易受攻击编译器的协议的影响,并且没有明确通知他们。 该漏洞本身是一个未正确实现的重入保护,在特定条件下可以绕过,我们将在本报告中深入探讨。
虽然这些攻击本身已在其他事后分析中得到充分报道包括 Curve.Fi 的官方分析,但我们希望深入了解 Vyper 编译器本身到底出了什么问题,为什么该漏洞难以发现,以及整个生态系统可以从这些事件中学习到什么。
如果你熟悉区块链领域以及 Vyper 存在的原因,我们建议跳过“背景”部分,因为它包含你很可能已经知道的非常基本的信息。
Vyper 是一种面向合约的、特定领域的、pythonic 编程语言,其目标是以太坊虚拟机 (EVM)。 它的目标包括简单性、pythonicity、安全性以及可审计性。
EVM 上部署的代码的一个常见问题是重入的概念。 与传统程序不同,“区块链程序”的控制流将交给任何给定时刻正在执行的“活动”程序。 从此以后,“区块链程序”将被称为合约。
为了详细说明,我们可以将所有区块链程序视为在单个线程上运行,不支持并发。 每当一个程序调用另一个程序时,整个控制流将传递给被调用的程序。
这意味着原始调用者的执行基本上被冻结,直到被调用的程序结束,此时调用者将在他们离开的确切位置恢复。 虽然不同类型的漏洞可能会因这种行为而产生,但最广为人知的是重入。
由于控制流被交给被调用的合约,因此被调用的合约可以在原始调用者冻结时重新进入原始调用者。 容易受到此类攻击的合约将在其外部合约调用之后包含状态更新,这意味着当它们被冻结时,它们的状态已过时且不正确。
生态系统已经提出了两种方法来对抗重入攻击并基本上使其无效:Checks-Effects-Interactions(CEI) 模式和重入保护。
CEI 模式是一种编程方法,规定函数代码应首先执行其安全检查,然后在存储中执行任何效果,最后在函数末尾执行与外部合约的交互。
如果严格遵循此模式,则合约在“交互”(即控制流放弃)期间的状态将是最新的且正确的,从而使任何潜在的利用不可能,无论合约如何重新进入。
在大多数情况下,CEI 模式就足够了,但是,DeFi 生态系统是多方面的,并且函数常常依赖于外部调用的结果来继续执行自己的操作。 在这种情况下,CEI 模式不适用,并且必须设置重入保护。
作为 Vyper 语言的核心原则之一,即安全性,Vyper 决定通过特殊的 @nonreentrant
函数装饰器直接在语言级别引入重入保护。 自 Vyper 的 v0.1.0-beta.9
版本(Vyper 的最早版本之一)以来,重入保护一直是该语言的核心功能。
在其核心,两种实现的功能相同:它们在两个状态(激活、非激活)之间设置一个存储值。 当调用一个标记为 @nonreentrant
的函数时,该标志:
一旦函数调用结束,该标志:
通过这种机制,@nonreentrant
用户可以确保该函数只能在其结束后重新调用,这意味着无论执行哪些外部调用,都不会发生重入。 存在更复杂形式的重入攻击(即仅 view
重入、跨合约重入),但就此漏洞而言,基本情况才是最重要的。
@nonreentrant
: 基于标签的重入保护自从引入以来,@nonreentrant
装饰器始终支持设置``,与仅在合约级别全局应用的非重入锁相比,提供了更大的灵活性。
一个简单的实现是使用一个 mapping
,它接受 key
并在其上设置相关的重入标志,但是,由于 mapping
查找的 keccak256
gas 成本,这种方法会产生额外的成本。
由于 Vyper 是一种不向用户提供原始存储访问的语言,因此在编译时它将完全了解合约使用的所有存储槽。 因此,它承担了分配存储槽的工作,包括确保存储变量和重入密钥锁的槽不相互重叠。
PR#1264 在 Vyper 的 v0.1.0-beta.9
版本中引入了此功能,它采用了一种简单的方法来确保没有重叠,将重入标志存储在距离合约原始槽的特定偏移量处(准确地说是 0xFFFFFF
)。
与新功能开发同步,从 2018 年开始,Vyper 编译器启动了一项多年-多年-多年的工作,将当时的单通道架构重构为多通道架构,这将类型检查和语义分析的问题分离到前端中,这与代码生成后端不同。 与大多数大型重构项目一样,这项工作是增量的和零碎的,与其他错误修复和功能开发一起进行,直到最终在 2023 年达到高潮,即 PR#3390。
Vyper v0.2.9
版本的 PR#2308 旨在通过利用在处理完合约的常规存储变量的所有槽后可用的第一个未分配的存储槽,而不是从 0xFFFFFF
常量开始,使存储分配更智能。 这将节省字节码空间,因为在字节码中,PUSH
指令(通过将 nonreentrancy 密钥的位置推送到堆栈,位于存储槽的任何加载或存储之前)可以使用更少的字节。
只要在(物理)存储布局的前面顺序分配的变量不跨越多个顺序槽,上述 v0.2.9
版本的 PR 就可以正常工作,并将保证非重入标志槽和存储槽之间没有重叠。
由于当时 Vyper 语言和代码库正在进行重大重构,因此 PR#2361(v0.2.13
版本的一部分)引入了一种更有效的方式来存储可以在合约中跨越 1 个以上存储槽(32 字节)的变量。 作为更大的重构工作的一部分,它还将常规存储变量的槽计算从现有的代码生成通道移到了新的前端通道中,但保留了重入密钥的槽计算。 由于重入密钥的槽计算取决于常规存储变量分配的结果,因此它最终在前端和代码生成通道之间保留了两种不同的常规存储变量分配器实现。 这导致 PR#2308 的偏移量计算不正确,需要更新。
更新在 PR#2379(v0.2.14
版本的一部分)中引入,旨在通过考虑存储中声明的变量的正确大小,而不是假设它们都占用一个槽(在早期实现中是正确的),从而正确计算重入标志的存储偏移量。 然而,第二次更新仍然存在一个 bug,该 bug 源于前端和代码生成分配器之间的差异,我们将在下面进行描述。
由于这些 bug,v0.2.13
和 v0.2.14
版本都在发布后不久被“撤回”[2]。
v0.2.14
中的重入保护损坏在 v0.2.14
发布后不久,一位 Vyper 用户在 Vyper GitHub 存储库中打开了 issue #2393,指出当他们将 Yearn vault 代码升级到 0.2.14
时,重入保护测试失败。
获取 Yearn 的最新可用版本 的快照,当用户打开他们的问题时,使用 v0.2.14
编译它,并使用 EtherVM 反编译器 检查反编译的字节码,将显示伪代码存储偏移量 storage[0x2e]
在 Vault.vy
文件的 def deposit
和 def withdraw
实例中用作 @nonreentrant("withdraw")
关键字的“标志”。
但是,相同的存储偏移量用于合约级别的 managementFee
变量。 这可以通过评估 managementFee()
getter 函数以及将重新使用相同存储偏移量的 setManagementFee
setter 函数的反编译函数来验证。
使用 v0.2.13
编译相同的代码库表明,重入保护按预期方式工作,并且在存储中没有重叠。 但是,v0.2.14
版本的 PR#2379 并未完全解决损坏问题。
v0.2.14
版本的代码为 @nonreentrant
装饰器分配存储槽,仍然产生新的前端代码和现有代码生成分配器之间的不正确交互。 由于前端和代码生成分配器之间映射类型的不同分配策略,重入槽最终与常规存储变量重叠。 v0.2.14
的数据损坏代码如下:
def get_nonrentrant_counter(self, key):
"""
Nonrentrant locks use a prefix with a counter to minimise deployment cost of a contract. // Nonrentrant 锁使用带计数器的前缀,以最大限度地降低合约的部署成本。
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 // 这依赖于已弃用的 _globals 属性
because the new way of doing things (set_data_positions) doesn't expose the // 因为新的做事方式 (set_data_positions) 不会公开
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
)分配存储槽,而新的分配器分配了槽。 MappingType
存储条目永远不会写入,但无论如何都会被编译器保留(参考:Issue 2436)。 这导致了非重入密钥分配器和前端分配器之间的不一致,从而导致了报告的存储损坏。
v0.2.15
中重入锁故障在 v0.2.14
被撤回后,为了纠正 v0.2.14
版本对重入保护的损坏,v0.2.15
版本中包含的 PR#2391 旨在通过将重入密钥分配到常规存储变量的物理前面来修复先前 PR#2379 中引入的 bug。 此外,为了减少再次引入此类问题的机会,它通过将逻辑移动到与前端中常规存储变量分配相同的函数中,完成了从代码生成通道中删除存储槽分配逻辑的工作。 但是,这样做,它删除了旧的 self._nonreentrant_keys
数据结构,以及至关重要的伴随逻辑,该逻辑确保每个非重入密钥仅分配一个锁:
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 // 从 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 // TODO 每个重入密钥使用一个字节 - 或位 -
# requires either an extra SLOAD or caching the value of the // 需要额外的 SLOAD 或在入口处缓存该值
# location in memory at entrance // 内存中的位置
storage_slot += 1
该漏洞的产生是由于重入密钥的 storage_slot
偏移量忽略了 @nonreentrant()
装饰器的实际`,并且只是为每个看到的
@nonreentrant` 装饰器保留了一个新的槽,而不管使用了什么“密钥”。
v0.2.15
, v0.2.16
和 v0.3.0
在 v0.2.15
中引入的漏洞在临时版本 v0.2.16
和 v0.3.0
中都未被检测到,这是由于当时 Vyper 代码库中缺少足够的测试来检测它,从 2021 年 7 月 21 日到 2021 年 11 月 30 日,为期 4 个月。
所有已使用版本 v0.2.15
、v0.2.16
和 0.3.0
编译的 Vyper 合约都容易受到重入保护故障的影响。
v0.3.1
发布v0.3.1
版本通过调整编译器如何将数据槽分配给合约中的每个变量来解决了此漏洞。 该漏洞已在两个不同的 PR 中修复。
第一个部分修复该漏洞的 PR 是 PR#2439, 其中包含以下描述:
this is not a semantic bug but an optimization bug since we allocate // 这不是一个语义错误,而是一个优化错误,因为我们分配
more slots than we actually need, leading to "holes" in the slot // 比我们实际需要的槽更多,导致槽分配器中出现“漏洞”
allocator – slots which are allocated but unused. // - 已分配但未使用的槽。
此描述实际上并未清楚地描述该问题。 “漏洞”的描述源于检查编译输出的 layout
如何为每个重入密钥产生一个 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 // TODO 这可以有更好的类型,但在我们确定格式之前不要键入
# 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 // TODO 每个重入密钥使用一个字节 - 或位 -
# requires either an extra SLOAD or caching the value of the // 需要额外的 SLOAD 或在入口处缓存该值
# location in memory at entrance // 内存中的位置
storage_slot += 1
此代码的问题在于,它将每个 type_
(即单个 @nonreentrant
密钥)的重入密钥位置设置为 storage_slot
的最新值,并在每次迭代中递增。 这意味着 @nonreentrant()
的唯一实例都使用不同的 storage_slot
值,但是,variable_name
的 ret
条目在每次迭代中都被覆盖。
因此,编译器的 layout
输出包含一个 nonreentrant.
条目和一个存储偏移量,这意味着检查编译器的输出似乎只是“跳过”连续 @nonreentrant()
声明的存储槽,如 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 // TODO 这可以有更好的类型,但在我们确定格式之前不要键入
# 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 // TODO 每个重入密钥使用一个字节 - 或位 -
# requires either an extra SLOAD or caching the value of the // 需要额外的 SLOAD 或在入口处缓存该值
# location in memory at entrance // 内存中的位置
storage_slot += 1
该代码现在将在第一次识别出重复的重入密钥时适当地分配单个 storage_slot
。 但是,它不会在每个具有相同偏移量的 type_
上调用 set_reentrancy_key_position
函数,这意味着超出第一个的任何 @nonreentrant()
条目都将具有要使用的“未定义”存储偏移量。
这导致在尝试使用 @nonreentrant
装饰器编译合约时出现编译器 panic[3]。 为了纠正这一点,需要进一步更改以确保所有 @nonreentrant
装饰器都正确地知道它们需要操作的存储槽。
完成缓解 @nonreentrant
漏洞的最终 PR 是 PR#2514。 详细来说,它扩展了上述代码段,以确保使用为给定 @nonreentrant
锁分配的正确槽来正确调用 set_reentrancy_key_position
函数。
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 // TODO 这可以有更好的类型,但在我们确定格式之前不要键入
# 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 // TODO 每个重入密钥使用一个字节 - 或位 -
# requires either an extra SLOAD or caching the value of the // 需要额外的 SLOAD 或在入口处缓存该值
# location in memory at entrance // 内存中的位置
storage_slot += 1
如上所述,现在可以为每个 type_
条目正确调用 set_reentrancy_key_position
,并且只要在 @nonreentrant()
装饰器中指定了相同的 `,它都会正确地使用相同的
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
但是,虽然在编译器代码库中已识别、修复和测试了该 bug,但当时并未意识到对生产合约的影响,并且没有明确通知可能使用相关编译器版本的协议。
可以在 @nonreentrant
装饰器中重复使用的唯一`值的概念仅存在一个目的:跨函数重入保护。 在 Vyper 存储库中缺少此类测试,直到其
0.3.1` 版本,这是导致漏洞首先引入并持续未被检测到的因素之一。
v0.2.15
、v0.2.16
、v0.3.0
v0.2.13
中引入的重入保护数据损坏问题的不正确修复@nonreentrant
装饰器都将使用唯一的存储偏移量,而与其``无关,这意味着使用易受攻击的版本编译的所有合约都可以进行跨函数重入虽然该漏洞本身很容易识别,并且已在各种实时合约中观察到,但其盈利能力源于需要满足的一组非常具体的条件。 具体来说:
vyper
版本之一编译的 .vy
合约:0.2.15
、0.2.16
、0.3.0
`的
@nonreentrant` 装饰器但不严格遵循 CEI 模式的主函数(即,在存储更新之前包含对不受信任方的外部调用)不幸的是,这些条件正是 被利用的 Curve.Fi 流动性池 中所表现出来的条件,因为他们需要在执行敏感存储更新之前执行本机 ETH
的外部分配(在 EVM 上,这只能通过执行上下文传输 CALL[4] 完成),这些函数在其他情况下会受到正常运行的 @nonreentrant
保护。
Bug 是任何大型生产软件项目中不幸而严峻的现实。 我们可以做的是尽最大可能地尝试减轻 bug 及其相关风险。
我们可以采取以下几个实际步骤来提高使用 Vyper 编译的智能合约的正确性:
但仅仅关注最新版本编译器的正确性是不够的; 由于智能合约的不可变性,使用 Vyper 的过去版本编译的合约可能会保护大量资金。
因此,保护 Vyper 的过去版本是我们未来将投入大量资源的另一个重要的新关注点。 它与为最新版本引入新功能、提供错误修复和重构同样重要。
最终,展望未来,我们希望从最近的事件中吸取教训,以确保 Vyper 成为世界上最可靠和最安全的智能合约语言和编译器项目。 因此,这些目标将得到我们团队内部和外部的各种新的安全相关举措的支持,包括:
我们希望很快见到你使用 Vyper :)。 请继续关注未来几周内有关这些举措的更多公告! 要关注更多公告,请关注 Vyper 官方 Twitter。 要帮助贡献,请参阅 Vyper Github! 如果你对 Vyper 感到兴奋并希望提供资金 – 或者只是想聊天 – 请通过 Vyper Discord 与我们联系,我们将始终乐于欢迎你加入社区。
特别感谢 Omniscia 团队,虽然他们没有直接隶属于 Vyper,但他们为这篇事后分析报告贡献了大量的共同创作、反馈和审查 ↩︎
简而言之,“撤回”是指这些标签可用于存储库中以供历史用途,但这些版本未发布供下载。 有关更多信息,请参阅 PEP-592。 ↩︎
也就是说,编译器只会报错,而根本不会生成任何代码。 虽然这让用户很恼火,但编译器 panic 被认为是“安全的”错误,因为它不会发出任何进入生产环境的代码。 ↩︎
从技术上讲,还有其他传输以太币的方法,但它们在撰写本文时并不适用。 EIP-5920 可能是这方面的一个积极发展。 ↩︎
- 原文链接: hackmd.io/@vyperlang/HJU...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!