二、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出来。这个实现并未考虑到一笔交易中,从某个具备重入锁的函数重入到另一个具备重入锁的函数这种场景。
比如,在合约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]
修复后的代码会在45行判断锁的名字是否已经在ret里面,如果在,就会在已有的slot位置设置重入锁的key。这样就能保证具有同样名字的重入锁,在一笔交易里面可以共享该锁。
三、攻击复盘
我们以0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c这笔攻击交易为例,复盘漏洞利用的详细过程。
地址和交易信息
攻击交易Hash:0xa84aa065ce61dbb1eb50ab6ae67fc31a9da50dd2c74eefd561661bfce2f1620c
攻击者使用的攻击合约地址:0x466b85b49ec0c5c1eb402d5ea3c4b88864ea0f04
-
攻击者部署攻击代理合约 -
攻击代理合约从Balancer闪电贷80000枚WETH -
攻击代理合约withdraw WETH,换取80000枚ETH
-
攻击代理合约收到ETH,触发fallback函数,攻击开始 -
调用pETH-ETH-f池子的add_liquidity,注入40000 ETH流动性,获取32431枚LP代币 -
调用pETH-ETH-f池子的remove_liquidity,burn 32431枚LP代币撤回流动性
@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
-
攻击代理合约接收到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,计算公式可简单总结为
-
由于add_liquidity被重入,合约的pETH的数量(公式中的y)和total_supply的值未更新,导致应发放的LP token数量计算错误,在上图中add_liquidity的第54行,pool为攻击合约发放了82182枚LP token。作为对比,攻击者第一次正常调用add_liquidity时,同样注入40000枚ETH的流动性,池子合约仅发放了32431枚LP token。 -
重入到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的总供应量的情况。 -
最后,攻击者再次调用remove_liquidity(非重入,正常调用),将第5步重入到add_liquidity函数时获得的82182枚中的10272枚LP token销毁(只能销毁这些是因为池子的total supply只剩这么多),从池子获得47506枚ETH和1184枚pETH,抽干了池子全部的LP token。 -
攻击流程结束后,攻击者将两次移除流动性获得的1184 + 3740共约4900枚pETH在池子中兑换为ETH,再加上攻击者两次移除流动性获取的47506 + 34316枚ETH,总共收回86000多枚ETH,归还闪电贷80000枚WETH后,获利6100多枚ETH,约合1100万美元。
-
建议使用Vyper0.2.15,0.2.16和0.3.0编译器版本的项目尽快开始自查项目中是否使用了重入锁,并及时与Vyper编译器开发者团队取得联系。 -
Vyper 编译器重入锁 bug 早在 2021 年 12 月便在 Vyper 0.3.1 中被修复,到目前为止,Vyper 编译器已经更新到 0.3.9 版本。但是有些项目方一直在使用旧版本编译器编译合约,这将非常容易导致合约被攻击。我们建议大型项目的关键合约应该设计为可升级的,项目方应该要持续关注编译器的发展,及时更新合约。
值得一提的是,由ZAN开发的链上安全监控机器人也在第一时间(GMT+8 July 30 21:10)对这笔交易做出了告警。
1、时间线|Vyper编译器故障,Curve等协议遭攻击:https://www.theblockbeats.info/news/43915?search=1
2、https://twitter.com/vyperlang/status/1685692973051498497
About ZAN
点击阅读原文
Contact Us!原文始发于微信公众号(ZAN Team):Vyper编译器漏洞引发的Curve重入攻击分析