Vyper编译器漏洞引发的Curve重入攻击分析

一、事件回顾
7月31日凌晨,以太坊EVM编译器Vyper官方发推表示,版本为0.2.15、0.2.16、0.3.0的Vyper编译器存在重入锁故障(在一笔交易中,两个不同函数的重入锁,并不共享一个锁变量)。截止7 月 31 日,据派盾监测,该问题导致Curve Finance 稳定币池:Alchemix、JPEG’d、MetronomeDAO、deBridge、Ellipsis 和 CRV/ETH 池累计损失 5200 万美元。

Vyper编译器漏洞引发的Curve重入攻击分析

二、Vyper编译器重入锁故障分析

一句话总结:Vyper编译器的递归锁未使用全局的slot存储重入锁状态,导致合约可能被跨函数重入

引入问题代码:

https://github.com/vyperlang/vyper/blob/v0.2.15/vyper/semantics/validation/data_positions.py

在30行,判断函数是否有重入锁,如果有,就会在31行调用set_reentrancy_key_position,新建一个storage slot出来。这个实现并未考虑到一笔交易中,从某个具备重入锁的函数重入到另一个具备重入锁的函数这种场景。

Vyper编译器漏洞引发的Curve重入攻击分析

比如,在合约https://etherscan.io/address/0x9848482da3ee3076165ce6497eda906e66bb85c5#code 中,函数add_liquidity和remove_liquidity都具备重入锁,当攻击者调用remove_liquidity移除流动性时,编译器判断出该函数具备重入锁,此时会新建一个storage slot存放重入锁变量1。移除流动性时,池子会给攻击者转钱,此时会触发攻击者的fallback函数,攻击者在fallback函数中重入到add_liquidity函数中,编译器判断出该函数具备重入锁,也会新建一个storage slot存放重入锁变量2。重入锁变量1和重入锁变量2并不是一个变量,所以无法防止攻击者重入到不同的函数。

@payable@external@nonreentrant('lock')def add_liquidity(    _amounts: uint256[N_COINS],    _min_mint_amount: uint256,    _receiver: address = msg.sender) -> uint256:    """    @notice Deposit coins into the pool    @param _amounts List of amounts of coins to deposit    @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit    @param _receiver Address that owns the minted LP tokens    @return Amount of LP tokens received by depositing    """    amp: uint256 = self._A()    ......
@external@nonreentrant('lock')def remove_liquidity(    _burn_amount: uint256,    _min_amounts: uint256[N_COINS],    _receiver: address = msg.sender) -> uint256[N_COINS]:    """    @notice Withdraw coins from the pool    @dev Withdrawal amounts are based on current deposit ratios    @param _burn_amount Quantity of LP tokens to burn in the withdrawal    @param _min_amounts Minimum amounts of underlying coins to receive    @param _receiver Address that receives the withdrawn coins    @return List of amounts of coins that were withdrawn    """    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]
修复递归锁故障的代码:https://github.com/vyperlang/vyper/blob/v0.3.1/vyper/semantics/validation/data_positions.py

修复后的代码会在45行判断锁的名字是否已经在ret里面,如果在,就会在已有的slot位置设置重入锁的key。这样就能保证具有同样名字的重入锁,在一笔交易里面可以共享该锁。

Vyper编译器漏洞引发的Curve重入攻击分析

三、攻击复盘

我们以0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c这笔攻击交易为例,复盘漏洞利用的详细过程。

地址和交易信息

攻击交易Hash:0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c

攻击者使用的攻击合约地址:0x466b85b49ec0c5c1eb402d5ea3c4b88864ea0f04

攻击者地址:0x6ec21d1868743a44318c3c259a6d4953f9978538
攻击流程
准备阶段
  1. 攻击者部署攻击代理合约
  2. 攻击代理合约从Balancer闪电贷80000枚WETH
  3. 攻击代理合约withdraw WETH,换取80000枚ETH
攻击阶段
  1. 攻击代理合约收到ETH,触发fallback函数,攻击开始
  2. 调用pETH-ETH-f池子的add_liquidity,注入40000 ETH流动性,获取32431枚LP代币
  3. 调用pETH-ETH-f池子的remove_liquidity,burn 32431枚LP代币撤回流动性
接下来,我们逐行分析remove_liquidity的执行过程,首先,remove_liquidity计算出当前条件下,应当撤回的ETH代币数量。此时,remove_liquidity函数执行到如图所示第27行位置。remove_liquidity执行到第27行时,34316枚ETH被转移到攻击代理合约。
@external@nonreentrant('lock')def remove_liquidity(    _burn_amount: uint256,    _min_amounts: uint256[N_COINS],    _receiver: address = msg.sender) -> uint256[N_COINS]:    """    @notice Withdraw coins from the pool    @dev Withdrawal amounts are based on current deposit ratios    @param _burn_amount Quantity of LP tokens to burn in the withdrawal    @param _min_amounts Minimum amounts of underlying coins to receive    @param _receiver Address that receives the withdrawn coins    @return List of amounts of coins that were withdrawn    """    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
  1. 攻击代理合约接收到ETH转账,触发fallback函数,调用add_liquidity函数再次注入40000枚ETH流动性。如果此时重入锁工作正常,攻击者不可能调用成功,然而,由于编译器漏洞,导致add_liquidity和remove_liquidity使用的重入锁变量并不是同一个,因此攻击者成功重入到了add_liquidity函数,请注意,此时合约还没有修改池子的total_supply,并且攻击者的LP token balance仍然没有被扣除。

对pool合约字节码进行反编译,add_liquidity和remove_liquidity的字节码部分如下

function add_liquidity() public payable {     require(!stor_0);    stor_0 = 1;  ...
function remove_liquidity(uint256 varg0) public payable {     require(!stor_2);    stor_2 = 1;

可以看出,add_liquidity和remove_liquidity使用的lock变量的确不是同一个,一个使用的是stor_0位置的lock,另一个使用的是stor_2位置的lock,因此从remove_liquidity重入到add_liquidity是可以实现的。

@payable@external@nonreentrant('lock')def add_liquidity(    _amounts: uint256[N_COINS],    _min_mint_amount: uint256,    _receiver: address = msg.sender) -> uint256:    """    @notice Deposit coins into the pool    @param _amounts List of amounts of coins to deposit    @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit    @param _receiver Address that owns the minted LP tokens    @return Amount of LP tokens received by depositing    """    amp: uint256 = self._A()    old_balances: uint256[N_COINS] = self.balances    rates: uint256[N_COINS] = self.rate_multipliers
# Initial invariant D0: uint256 = self.get_D_mem(rates, old_balances, amp)
total_supply: uint256 = self.totalSupply new_balances: uint256[N_COINS] = old_balances for i in range(N_COINS): amount: uint256 = _amounts[i] if total_supply == 0: assert amount > 0 # dev: initial deposit requires all coins new_balances[i] += amount
# Invariant after change D1: uint256 = self.get_D_mem(rates, new_balances, amp) assert D1 > D0
# We need to recalculate the invariant accounting for fees # to calculate fair user's share fees: uint256[N_COINS] = empty(uint256[N_COINS]) mint_amount: uint256 = 0 if total_supply > 0: # Only account for fees if we are not the first to deposit base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) for i in range(N_COINS): ideal_balance: uint256 = D1 * old_balances[i] / D0 difference: uint256 = 0 new_balance: uint256 = new_balances[i] if ideal_balance > new_balance: difference = ideal_balance - new_balance else: difference = new_balance - ideal_balance fees[i] = base_fee * difference / FEE_DENOMINATOR self.balances[i] = new_balance - (fees[i] * ADMIN_FEE / FEE_DENOMINATOR) new_balances[i] -= fees[i] D2: uint256 = self.get_D_mem(rates, new_balances, amp) mint_amount = total_supply * (D2 - D0) / D0 else: self.balances = new_balances mint_amount = D1 # Take the dust if there was any
assert mint_amount >= _min_mint_amount, "Slippage screwed you"
# Take coins from the sender assert msg.value == _amounts[0] if _amounts[1] > 0: response: Bytes[32] = raw_call( self.coins[1], concat( method_id("transferFrom(address,address,uint256)"), convert(msg.sender, bytes32), convert(self, bytes32), convert(_amounts[1], bytes32), ), max_outsize=32, ) if len(response) > 0: assert convert(response, bool) # dev: failed transfer # end "safeTransferFrom"
# Mint pool tokens total_supply += mint_amount self.balanceOf[_receiver] += mint_amount self.totalSupply = total_supply log Transfer(ZERO_ADDRESS, _receiver, mint_amount)
log AddLiquidity(msg.sender, _amounts, fees, D1, total_supply)
return mint_amount

add_liquidity函数用于计算发放给流动性提供者LP token,计算公式可简单总结为

Vyper编译器漏洞引发的Curve重入攻击分析
和对应代码中的D2和D0,对应代码中的total_supply,对于双代币池子,不变量D计算公式为
Vyper编译器漏洞引发的Curve重入攻击分析
  1. 由于add_liquidity被重入,合约的pETH的数量(公式中的y)和total_supply的值未更新,导致应发放的LP token数量计算错误,在上图中add_liquidity的第54行,pool为攻击合约发放了82182枚LP token。作为对比,攻击者第一次正常调用add_liquidity时,同样注入40000枚ETH的流动性,池子合约仅发放了32431枚LP token。
  2. 重入到add_liquidity并执行完毕后,上一层调用的remove_liquidity继续执行到第29行,转账给攻击合约3740枚pETH(移除流动性时,转账给流动性提供者的资产会是池子中pETH和ETH的混合,此前第3步中池子已经向攻击者转账了34316枚ETH)。随后在第41行,池子销毁攻击者第一次add_liquidity产生的32431枚LP token,并减少LP token的total supply,请注意,合约的total_supply并未增加82182,而是被第41行计算的较旧的值(重入发生前读取的total_supply)覆盖,但攻击者的LP token balance被重入的add_liquidity更新,造成了攻击者的LP token余额甚至大于池子LP token的总供应量的情况。
  3. 最后,攻击者再次调用remove_liquidity(非重入,正常调用),将第5步重入到add_liquidity函数时获得的82182枚中的10272枚LP token销毁(只能销毁这些是因为池子的total supply只剩这么多),从池子获得47506枚ETH和1184枚pETH,抽干了池子全部的LP token。
  4. 攻击流程结束后,攻击者将两次移除流动性获得的1184 + 3740共约4900枚pETH在池子中兑换为ETH,再加上攻击者两次移除流动性获取的47506 + 34316枚ETH,总共收回86000多枚ETH,归还闪电贷80000枚WETH后,获利6100多枚ETH,约合1100万美元。
攻击结束后,从池子合约查询到的攻击者LP token余额为71909,而池子记录的LP token total_supply只剩下0.00026,池子所有的LP token均被攻击者抽走。Vyper编译器漏洞引发的Curve重入攻击分析
四、总结
可以看出攻击者对于Curve.fi的pool合约,Vyper编译器都有很深的理解,另外一个有趣的点在于该编译器bug在Vyper 0.3.1中被修复时,开发者提交的git commit info中提到“this is not a semantic bug but an optimization bug since we allocate more slots than we actually need”,并且在过去的两年时间中,没有人意识到该bug会导致重入锁失效的问题,但在2023年却被攻击者发现并利用,这提醒我们不要对编译工具链的可靠性抱有盲目的信心。
以下是ZAN团队关于此次事件的看法和建议:
  1. 建议使用Vyper0.2.15,0.2.16和0.3.0编译器版本的项目尽快开始自查项目中是否使用了重入锁,并及时与Vyper编译器开发者团队取得联系。
  2. Vyper 编译器重入锁 bug 早在 2021 年 12 月便在 Vyper 0.3.1 中被修复,到目前为止,Vyper 编译器已经更新到 0.3.9 版本。但是有些项目方一直在使用旧版本编译器编译合约,这将非常容易导致合约被攻击。我们建议大型项目的关键合约应该设计为可升级的,项目方应该要持续关注编译器的发展,及时更新合约。

值得一提的是,由ZAN开发的链上安全监控机器人也在第一时间(GMT+8 July 30 21:10)对这笔交易做出了告警。

Vyper编译器漏洞引发的Curve重入攻击分析

Reference
1、时间线|Vyper编译器故障,Curve等协议遭攻击:https://www.theblockbeats.info/news/43915?search=1
2、https://twitter.com/vyperlang/status/1685692973051498497

About ZAN

ZAN团队技术由AntChain OpenLab支持,提供了多种Web3解决方案和基础设施,比如智能合约审计、KYT、KYC、节点服务、MPC协议、硬件加速等解决方案。

点击阅读原文Contact Us

原文始发于微信公众号(ZAN Team):Vyper编译器漏洞引发的Curve重入攻击分析

版权声明:admin 发表于 2023年8月2日 下午6:08。
转载请注明:Vyper编译器漏洞引发的Curve重入攻击分析 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...