2023年7月30日,一系列攻击事件针对多个 Curve 资金池,导致了数百万美元的损失。这是一起典型的重入攻击,但其根本原因并不典型,源于一个导致重入保护失效的编译器漏洞。具体而言,该错误在于智能合约中不同函数的重入锁被分配了不同的存储槽位。因此,使用 Vyper 0.2.15、0.2.16 和 0.3.0 版本编译的智能合约均存在此漏洞。
背景
由于 Curve 在其智能合约开发中使用 Vyper 而非 Solidity,在此简要介绍 Vyper 语言以帮助理解相关漏洞。
Vyper 是一种由以太坊联合创始人 Vitalik Buterin 创建的基于 Python 的编程语言。正如其文档所述,Vyper 是一种面向合约、领域特定且具有 Python 风格的编程语言,目标平台是以太坊虚拟机(EVM)。其目标包括简洁性、“Python 风格”、安全性和可审计性。
Vyper 已成为继著名的 Solidity 之后,以太坊及兼容 EVM 的公链中第二大广泛使用的编程语言。Curve 是 Vyper 语言最大的采用者之一,其大部分合约都是用该语言编写的。许多与 Curve 相关或从 Curve 分叉的项目也使用 Vyper,以确保更好的代码复用性和与 Curve 系统的互操作性。
以下是来自 Curve 资金池的一段代码(在此次事件中遭到攻击的 pETH/ETH 资金池)。尽管其语法与 Python 非常相似,但 Vyper 和 Python 之间存在显著差异:
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice 从资金池中提取代币
@dev 提取金额基于当前的存款比例
@param _burn_amount 提取时销毁的 LP 代币数量
@param _min_amounts 接收基础代币的最低数量
@param _receiver 接收提取代币的地址
@return 提取的代币数量列表
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "提取的数量少于预期"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
合同中函数的所有参数和返回值都必须进行正确的类型注释。该语言不支持类或继承;关键字 'self' 仅用于引用合约本身以及访问状态变量。内置装饰器(例如 @external 和 @nonreentrant)用于表示函数的属性,且不支持自定义装饰器。
漏洞分析
该漏洞源于一个编译器错误,导致其缺乏重入保护。
重入攻击是区块链生态系统中最常见的攻击类型之一。具体而言,当执行合约逻辑触发外部调用,且其中一些调用可能会递归地回调原始合约时,就会发生重入攻击。这可能会在函数执行期间危险地将合约的中间状态暴露给其他合约,从而导致漏洞。为了应对这种情况,使用了重入保护(reentrancy guard)或锁,以确保合约在单笔交易执行期间无法被重入。
在上述代码片段中,@nonreentrant('lock') 注释表示 remove_liquidity 函数应受到名为 lock 的重入锁的安全保护。为了更清楚地说明,可以将其与 OpenZeppelin 的 ReentrancyGuard 合约及其 nonReentrant 修饰符进行比较。Vyper 的主要区别在于,重入锁不是由外部库提供的,而是语言本身的内置功能。在深入调查重入锁的实现之前,这看起来似乎令人满意。研究发现,Pull Request #2391(于 2021 年 7 月 23 日合并)中引入的代码段利用了 set_storage_slots 函数,根据 Vyper 源代码的 AST(抽象语法树)来分配存储槽位。
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
解析模块级 Vyper AST 以计算存储变量的布局。
"""
# 从 0 开始分配存储槽位
# 注意:存储是按字寻址,而非按字节寻址
storage_slot = 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 每个重入键使用一个字节或位
# 需要额外的 SLOAD 或在入口处缓存内存中的位置值
storage_slot += 1
然而,这里存在一个严重的错误:对于每一个重入锁,存储槽位都会递增 1。因此,不同的函数最终被分配了各自唯一的存储槽位作为其重入锁。为了评估该实现,我们编译了以下合约:
# 编译命令:vyper -f bytecode,bytecode_runtime,ir test_contract.vy
from vyper.interfaces import ERC20
test_addr: address
@external
def __init__(_addr: address):
self.test_addr = _addr
@external
@nonreentrant('lock')
def test_funcA():
pass
@external
@nonreentrant('lock')
def test_funcB():
pass
使用 Vyper 编译器版本 0.2.16+commit.59e1bdd,代码编译期间生成的 Vyper IR(中间表示)部分内容如下:
[if,
[eq, _func_sig, 2354224227 <test_funcA()>],
[seq,
[assert, [iszero, [sload, 0]]],
[sstore, 0, 1],
pass,
# Line 13
pass,
# Line 12
[sstore, 0, 0],
stop]],
# Line 17
[if,
[eq, _func_sig, 741100118 <test_funcB()>],
[seq,
[assert, [iszero, [sload, 1]]],
[sstore, 1, 1],
让我们重点关注第 4-5 行和第 16-17 行的 IR,其中生成的代码验证了重入锁并将锁状态存储在存储器中。然而,观察发现不同的函数对非重入锁使用了不同的槽位:test_funcA 使用槽位 0,而 test_funcB 使用槽位 1。这表明重入锁是无效的,因为合约可以通过不同的函数被重入。
攻击分析
以下提供一些关于 Curve 的背景信息。Curve 资金池使用户能够通过 add_liquidity 和 remove_liquidity 函数提供和提取流动性。添加流动性时,添加金额由总供应量的比例决定,具体为添加的流动性占现有流动性的比例。另一方面,remove_liquidity 根据提交的 LP(流动性提供者)代币占当前总供应量的比例计算要提取的代币数量,之后销毁这些 LP 代币。
此外,Curve 支持处理原生代币的资金池,并使用底层调用(Vyper 中的 raw_call 函数)将原生代币返还给用户。在下方的代码段中,remove_liquidity 函数首先根据 LP 代币数量和总供应量计算并转移要移除的代币,然后减少总供应量。
在正常情况下,这是安全的,因为重入锁本应防止在原始调用(raw calls)期间暴露中间状态。然而,当重入锁失效时——最终成为了被利用的漏洞——攻击就变得可行了。重入锁无效意味着中间状态(即待提取代币已被转出,但总供应量尚未减少的状态)在底层调用期间变得脆弱,从而允许对合约进行潜在的重入。
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice 从资金池中提取代币
...
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "提取的数量少于预期"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
else:
response: Bytes[32] = raw_call(
self.coins[1],
concat(
method_id("transfer(address,uint256)"),
convert(_receiver, bytes32),
convert(value, bytes32),
),
max_outsize=32,
)
if len(response) > 0:
assert convert(response, bool)
total_supply -= _burn_amount
self.balanceOf[msg.sender] -= _burn_amount
self.totalSupply = total_supply
log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)
return amounts
这种重入之所以可以被利用,是因为如上文第 27 行所示,总供应量尚未减少。如果我们此时重入合约并调用 add_liquidity,流动性的供给将基于不正确的(比实际更高的)总供应量,导致铸造出过多的 LP 代币,从而造成资金池损失。此次事件中的大多数攻击都利用了这一漏洞。以下讨论针对 Curve pETH-ETH 资金池进行的最大规模攻击交易之一:0xa84aa065ce。

攻击轨迹非常清晰:
- 攻击者从 Balancer 借入闪电贷,随后向 Curve pETH/ETH 资金池提供了 40,000 ETH 的流动性,获得了 32,431.41 个 pETH-ETH LP 代币。
- 随后,攻击者通过销毁 32,431.41 个 pETH/ETH 资金池 LP 代币,从资金池中提取了 3,740 pETH 和 34,316 ETH。
- 在流动性移除期间,资金池合约被重入。在 fallback 函数中,攻击者向 Curve pETH/ETH 资金池额外提供了 40,000 ETH 作为流动性,额外铸造了 82,182 个 LP 代币。在此过程中,使用的总供应量数据是流动性提取之前的数据,这是不正确的,导致铸造出的 LP 代币数量比正常情况下要多。
- 此后,攻击者通过销毁 10,272.84 个 Curve LP 代币,提取了 1,184.73 pETH 和 47,506.53 ETH。总而言之,攻击者通过铸造额外的 LP 代币并利用这些多出的 LP 代币耗尽了资金池来获利。
总结
该漏洞源于编译器,而非源代码。这标志着区块链生态系统中首次出现因编译器漏洞而导致重大经济损失的情况。
确实,使用 Vyper 0.2.15、0.2.16 和 0.3.0 版本编译的智能合约均存在漏洞,这会导致重入保护失效。https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) 2023年7月30日
鉴于编译器构成了关键的基础设施组件,其安全性对于区块链技术的完整性和功能性至关重要。与编译器相关的问题可能不会立即显现,但它们可能产生深远而严重的后果。保障编译器安全需要严谨的评估,这应包括全面的审计和稳健的漏洞赏金计划,以发现并解决漏洞。编译器漏洞固有的隐蔽性使得检测和修复变得复杂。这种复杂性突显了诸如 BlockSec Phalcon 等复杂攻击检测与防御机制的重要性,它们提供了必要的自动化防御,以有效保护 DeFi 协议。
阅读本系列的其它文章:
- 引言:2023 年十大“令人震惊”的安全事件
- #1:利用 Flashbots Relay 漏洞猎取 MEV 机器人
- #2:Euler Finance 事件:2023 年最大规模的黑客攻击
- #3:KyberSwap 事件:以极其微妙的计算巧妙利用舍入误差
- #5:Platypus Finance:凭借运气幸免于三次攻击
- #6:Hundred Finance 事件:引发易受攻击的分叉协议中精度相关攻击的浪潮
- #7:ParaSpace 事件:一场与时间赛跑的行动,挫败了行业内最关键的攻击
- #8:SushiSwap 事件:一次拙劣的救援尝试引发了一系列模仿攻击
- #9:MEV Bot 0xd61492:从猎食者到猎物,一场巧妙的攻击
- #10:ThirdWeb 事件:受信任模块之间的不兼容导致了漏洞



