2023年7月30日,一系列漏洞利用攻击了多个Curve池,导致数百万美元的损失。这是一次典型的重入攻击,但其根本原因非同寻常,源于编译器的一个bug,导致重入保护缺失。具体来说,是在智能合约中,不同函数重入锁被分配了不同的存储槽。因此,使用Vyper版本0.2.15、0.2.16和0.3.0编译的智能合约存在漏洞。
背景
由于Curve使用Vyper而非Solidity进行智能合约开发,因此简要介绍Vyper语言以帮助理解相关漏洞。
Vyper是由以太坊联合创始人Vitalik Buterin创建的基于Python的编程语言。如其文档所示,Vyper是一种面向合约、领域特定、Pythonic的编程语言,以以太坊虚拟机(EVM)为目标。其目标包括简洁性、“Pythonicity”、安全性和可审计性。
Vyper已成为以太坊和EVM兼容链上使用第二广泛的编程语言,仅次于著名的Solidity。Curve是Vyper语言的最大采用者之一,其大部分合约都用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], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
合约中的所有函数参数和返回值都必须正确地进行类型注解。该语言不支持类或继承;关键字'self'仅用于指代合约本身以及访问状态变量。内置装饰器(例如@external和@nonreentrant)用于表示函数的属性,不支持自定义装饰器。
漏洞分析
漏洞源于编译器的一个bug,导致重入保护缺失。
重入攻击是区块链生态系统中一种非常常见的攻击类型。具体来说,当合约逻辑的执行触发外部调用时,其中一些外部调用可能会递归地回溯调用原始合约。这会在函数执行期间将合约的中间状态危险地暴露给其他合约,可能导致漏洞。为了应对这种情况,通常会采用重入守卫(或锁)来确保在单个事务执行期间合约不能被重入。
在前面提到的代码片段中,@nonreentrant('lock')注解表示remove_liquidity函数应该使用名为lock的重入锁进行保护。为了更清晰地理解,可以将其与OpenZeppelin ReentrancyGuard合约及其nonReentrant修饰符进行比较。Vyper的主要区别在于,重入锁不是由外部库提供的,而是语言本身的内置功能。直到深入研究重入锁的实现后,才发现这一点似乎不足以应对。发现在2021年7月23日合并的Pull Request #2391中引入的代码段,利用set_storage_slots函数根据Vyper源代码的AST(抽象语法树)来分配存储槽。
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
解析模块级别的Vyper AST以计算存储变量的布局。
"""
# 从0开始分配存储槽
# 注意:存储是字(word)可寻址的,而非字节可寻址
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代币数量和总供应量计算并转移要提取的代币,然后Subsequently,总供应量被减少。
在正常情况下,这是安全的,因为重入锁应该可以防止在raw调用期间暴露中间状态。然而,当重入锁无效时——一个最终被利用的缺陷——攻击就变得可行了。无效的重入锁意味着中间状态(此时要提取的代币已经转出,但总供应量尚未减少)在低级调用期间变得脆弱,允许潜在的重入合约。
@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], "Withdrawal resulted in fewer coins than expected"
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代币耗尽池子而获利。
总结
此漏洞源于编译器,而非源代码。这是编译器bug首次导致区块链生态系统遭受重大经济损失。
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
鉴于编译器是关键基础设施组件,其安全性对于区块链技术的完整性和功能至关重要。编译器相关的问题可能不会立即显现,但它们可能产生广泛而严重的影响。确保编译器的安全需要严格的评估,这应该包括全面的审计和健全的漏洞赏金计划,以发现和解决漏洞。编译器bug固有的微妙性使其检测和缓解工作复杂。这种复杂性凸显了复杂的攻击检测和预防机制的重要性,例如BlockSec的BlockSec Phalcon提供的机制,它们提供必要的自动化防御来有效保护DeFi协议。
阅读本系列其他文章:
- 引言:2023年十大“惊人”安全事件
- #1:通过利用Flashbots Relay中的漏洞来收割MEV机器人
- #2:Euler Finance事件:2023年最大黑客事件
- #3:KyberSwap事件:通过极其精妙的计算, masterfully利用四舍五入误差
- #5:Platypus Finance:幸运地躲过了三次攻击
- #6:Hundred Finance事件:催化了易受攻击的forked协议中精度相关漏洞的浪潮
- #7:ParaSpace事件:一场与时间赛跑,以阻止行业迄今为止最关键的攻击
- #8:SushiSwap事件:一次笨拙的救援尝试导致了一系列模仿攻击
- #9:MEV Bot 0xd61492:从捕食者到猎物,经历一次巧妙的攻击
- #10:ThirdWeb事件:受信任模块之间的不兼容性暴露了漏洞



