Original Title: "A Deep Dive Into How Curve Pool's $70 Million Reentrancy Exploit Was Possible"
Original Author: CyberPunkMetalHead
Original Translation: Odaily
Recently, the Curve pool vulnerability is different from most cryptocurrency hacking incidents we have seen in the past few years, because unlike many previous vulnerabilities, this time it is not directly related to the vulnerability of the smart contract itself, but rather to the underlying compiler of the language it uses. The content contains the following non-translatable terms: Curve.
Here, we are talking about Vyper: a programming language for smart contracts with a Pythonic style, designed to interact with the Ethereum Virtual Machine (EVM). I am very interested in the underlying reasons behind this vulnerability, so I have decided to conduct a thorough investigation.
With the development of this vulnerability, daily news headlines are reporting new numbers. It now appears that the situation is finally under control, but over 70 million US dollars were stolen before this. According to LlamaRisk's post-assessment, as of today, several DeFi project pools have also been hacked, including PEGD's pETH/ETH: 11 million US dollars; Metronome's msETH/ETH: 3.4 million US dollars; Alchemix's alETH/ETH: 22.6 million US dollars; and Curve DAO: approximately 24.7 million US dollars.
This vulnerability is known as a reentrancy error, which appears in certain versions of the Vyper programming language, particularly v 0.2.15, v 0.2.16, and v 0.3.0. Therefore, all projects using these specific versions of Vyper may become targets of the attack.
In order to understand why this vulnerability occurred, we first need to understand what reentrancy is and how it works.
If a function can be interrupted during its execution and can be safely called again before the previous call completes execution ("reentrant"), it is called a reentrant function. Reentrant functions are used in hardware interrupt processing, recursion, and other applications.
In order to make a function reentrant, it needs to meet the following conditions:
- It cannot use global and static data. This is just a convention, without hard restrictions, but if a function that uses global data is interrupted and restarted, it may lose information.
- It should not modify its own code. It should be able to execute in the same way regardless of when the function is interrupted. This can be managed, but it is generally not recommended to do so.
- It should not call other non-reentrant functions. Reentrancy should not be confused with thread safety, although they are closely related. A function can be thread-safe but still not reentrant. To avoid confusion, reentrancy only involves the execution of one thread. This is a concept from a time when there were no multitasking operating systems.
Here is an actual example:
i = 5
def non_reentrant_function():
return i** 5
def reentrant_function(number:int):
return number** 5
Function non_reentrant_function:
- This function has no parameters.
- It directly returns the fifth power of the global variable i.
- Therefore, when you call this function, it always returns 5** 5 , which is 3125 .
Function reentrant_function:
- This function has one parameter, "number", which is an integer.
- It returns the fifth power of the parameter "number".
- This means that you can pass any integer to this function and get the fifth power of that number as the return value. For example, if you pass in 2, it will return 2 to the power of 5, which is 32.
It is worth noting that many smart contract functions are not reentrant because they access global information such as wallet balances.
A lock is essentially a thread synchronization mechanism, where one process can claim or "lock" another process.
The simplest type of lock is called a binary semaphore. This type of lock provides exclusive access to locked data. There are more complex types of locks that can provide shared access to read data. Misuse of locks in programming can lead to deadlocks or livelocks, where processes continue to block each other and the state constantly changes but there is no progress.
Programming languages use locks in the backend to elegantly manage and share state changes between multiple subroutines. However, certain languages such as C# and Vyper allow locks to be used directly in the code.
@nonreentrant('lock')
def func():
assert not self.locked, "locked"
self.locked = True
# Do stuff
# Release the lock after finishing doing stuff
raw_call(msg.sender, b"", value= 0)
self.locked = False
# More code here
In the above example, we want to ensure that if msg.sender (the contract caller) is another contract, it will not call the code at execution time. If there is more code below raw_call() without a lock, msg.sender may call all the code above our function before it completes execution.
Therefore, in Vyper, the nonreentrant ('lock') decorator is a mechanism for controlling access to a function to prevent callers from repeatedly executing smart contract functions before they complete their run.
In many DeFi hacking incidents, it is usually a smart but malicious exploiter who discovers weaknesses in the way certain functions or data are exposed due to smart contract errors that the contract developer did not anticipate. However, what makes this case unique is that Curve's smart contract, as well as all other pools and projects that became victims of the attack, did not have any known vulnerabilities in the code itself. The contracts were solid.
nonreentrant(『lock』) exists.
Due to the issue with the way Vyper language handles reentrancy locks, this problem occurred. Therefore, the contract creator may have deployed seemingly reasonable code, but due to the compiler not handling the lock correctly, attackers were able to exploit this flawed lock to cause unexpected results in the contract behavior.
Let's take a look at the contracts that are truly vulnerable to reentrancy attacks. Notice the @nonreentrant('lock') modifier? In most cases, this should prevent reentrancy, but in reality it does not. Attackers are able to repeatedly call remove_liquidity() before the function returns.
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint 256,
_min_amounts: uint 256 [N_COINS],
_receiver: address = msg.sender
) -> uint 256 [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: uint 256 = self.totalSupply
amounts: uint 256 [N_COINS] = empty(uint 256 [N_COINS])
for i in range(N_COINS):
old_balance: uint 256 = self.balances[i]
value: uint 256 = 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, uint 256)"),
convert(_receiver, bytes 32),
convert(value, bytes 32),
),
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(uint 256 [N_COINS]), total_supply)
return amounts
So far, we know that a reentrancy attack is a method of repeatedly calling a function in a smart contract. But how does this lead to funds being stolen and a loss of $70 million in the Curve attack?
Do you notice the self.balanceOf[msg.sender] -= _burn_amount at the end of the smart contract? This tells the smart contract the liquidity of msg.sender in the pool, minus the burning fee. The next line of code is the message.sender calling transfer().
Therefore, a malicious contract can continuously call for withdrawals before the amount is updated, allowing them to almost choose to withdraw all liquidity from the pool.
The typical process of such attacks is as follows:
Welcome to join the official BlockBeats community:
Telegram Subscription Group: https://t.me/theblockbeats
Telegram Discussion Group: https://t.me/BlockBeats_App
Official Twitter Account: https://twitter.com/BlockBeatsAsia