Risk Warning: Beware of illegal fundraising in the name of 'virtual currency' and 'blockchain'. — Five departments including the Banking and Insurance Regulatory Commission
Information
Discover
Search
Login
简中
繁中
English
日本語
한국어
ภาษาไทย
Tiếng Việt
BTC
ETH
HTX
SOL
BNB
View Market
Deep dive into how reentrancy attacks stole $70 million worth of Curve tokens
jk
Odaily资深作者
2023-08-07 00:58
This article is about 2222 words, reading the full article takes about 4 minutes
Introduction to Reentrancy Attacks.

Original Article "A Deep Dive Into How Curve Pool’s $ 70 Million Reentrancy Exploit Was Possible", Author: Ann, Translated by Odaily jk.

The recent Curve pool vulnerability is different from most of the 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 smart contract itself, but to the underlying compiler of the programming language it uses.

Here, we are talking about Vyper: a programming language for smart contracts that has a Pythonic style, designed to interact with the Ethereum Virtual Machine (EVM). I am very interested in the underlying cause of this vulnerability, so I decided to dive deep into it.

As the vulnerability unfolded, daily headlines reported new figures. It seems that the situation is finally under control, but over $70 million has already been stolen. According to LlamaRisk's post-assessment, several DeFi projects' pools have been hacked, including PEGD's pETH/ETH: $11 million; Metronome's msETH/ETH: $3.4 million; Alchemix's alETH/ETH: $22.6 million; and Curve DAO: approximately $24.7 million.

This vulnerability is called a reentrancy error, which appears in certain versions of the Vyper programming language, specifically v0.2.15, v0.2.16, and v0.3.0. Therefore, all projects using these specific versions of Vyper could be potential targets for attacks.

What is Reentrancy? 

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 execution and safely called again before its previous call completes ( "reentry"), it is called reentrant. Reentrant functions are used in applications such as hardware interrupt handling and recursion.

To make a function reentrant, it needs to meet the following conditions:

  • It should not use global and static data. This is just a convention, not a hard limitation, but if a function that uses global data is interrupted and restarted, it may lose information.

  • It should not modify its own code. The function should be able to execute in the same way regardless of when it is interrupted. This can be managed, but it is generally not recommended.

  • 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 an era without multitasking operating systems.

Here is a practical example:

i = 5 
def non_reentrant_function():
  return i** 5 
def reentrant_function(number:int):
  return number** 5 

The function non_reentrant_function:

  • This function has no parameters.

  • It directly returns the fifth power of the global variable i .

  • So when you call this function, it always returns 5** 5 , which is 3125 .

The function reentrant_function:

  • This function has one parameter number, which is of integer type.

  • It returns the fifth power of the parameter number .

  • This means 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 2 , it will return the fifth power of 2, which is 32 .

It is worth noting that many smart contract functions are not reentrant because they access global information such as wallet balances.

What is a lock?

A lock is essentially a threading 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 the locked data. There are more complex types of locks that can provide shared access to read data. Misusing locks in programming can result in deadlocks or livelocks, where processes continuously block each other and the states keep changing without progress.

Programming languages use locks in the background to elegantly manage and share state changes between multiple subroutines. However, certain languages like C# and Vyper allow direct usage of locks 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 example above, we want to ensure that if msg.sender (the contract caller) is another contract, it cannot call the code under execution. If there were more code below raw_call() without the lock, msg.sender could call all the code above our function completes.

Therefore, in Vyper, the nonreentrant('lock') decorator is a mechanism for controlling access to functions to prevent callers from repeatedly executing the smart contract functions before they finish running.

In many DeFi hacking incidents, it is often smart contract errors that the contract developers did not anticipate, where a clever but malicious exploiter finds weaknesses in ways functions or data are exposed. But the uniqueness of this case is that the Curve smart contracts, as well as all the other pools and projects that became victims of the attack, do not have any known vulnerabilities in the code itself. The contracts are sturdy.

nonreentrant('lock') exists.

Due to a problem in the way Vyper language handles reentrancy locks, this issue occurred. So, the contract creator may have deployed seemingly reasonable code, but due to the compiler not handling the lock correctly, attackers can exploit this flawed lock to manipulate the contract behavior and result in unexpected outcomes.

Let's take a look at the contract truly affected by reentrancy attacks. Notice the @nonreentrant('lock') modifier? In theory, this should prevent reentrancy, but it actually fails to do so. Attackers can repeatedly call remove_liquidity() before the function returns the result.

@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

How is it being exploited?

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?

Notice the self.balanceOf[msg.sender] -= _burn_amount at the end of the smart contract? This tells the smart contract to deduct the burn fee from the liquidity of msg.sender in the contract pool. The next line of code is message.sender calling transfer().

Therefore, a malicious contract can continuously call withdrawal before the amount is updated, effectively allowing them to drain all liquidity from the pool.

The typical process of such an attack is as follows:

  • The vulnerable contract has 10 ETH.

  • The attacker calls deposit and deposits 1 ETH.

  • The attacker calls withdrawal of 1 ETH, at this point the withdrawal function performs some checks:

  • Does the attacker's account have 1 ETH? Yes.

  • Transfer 1 ETH to the malicious contract. Note: The balance of the contract has not been updated yet because the function is still executing.

  • The attacker calls withdrawal of 1 ETH again (reentry).

  • Does the attacker's account have 1 ETH? Yes.

This process repeats until there is no more liquidity in the pool.

This issue has already been fixed in the Vyper language and is no longer present in version 0.3.0. If you are a developer or using Vyper with Web3, make sure to update your version immediately.

Curve
Welcome to Join Odaily Official Community