A Deep Dive into how Curve’s $70 million reentrancy exploit was possible

The recent Curve Pool Exploit is different from the majority of cryptocurrency hacks that we’ve seen happen in the last few years because unlike many of the previous exploits, this one is not directly related to a vulnerability of the smart contract itself, but rather, the underlying compiler of the language that it was written in.

In this case, we’re talking about Vyper: a smart contract-oriented Pythonic programming language designed to interact with the Ethereum Virtual Machine (EVM). The circumstances surrounding this exploit are really fascinating once you understand where in the Vyper language this vulnerability exists. As the exploit unfolded, headlines kept reporting new numbers every day. It seems that the situation has finally been contained, but not before over $70 million U.S. Dollars were stolen.

As of today, several DeFi projects’ pools were also 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: around $24.7 million, according to LlamaRisk’s post-exploit assessment.

The exploit is known as a reentrancy malfunction, that was made possible on certain versions of the Vyper programming language, specifically v0.2.15, v0.2.16 and v0.3.0. Therefore all projects using these specific Vyper versions are a vector for attack.

What is reentrancy?

In order to understand how this exploit was possible in the first place, we need to first need to understand what reentrancy is and how it works. A function is called reentrant if it can be interrupted in the middle of its execution and then safely called again (“re-entered”) before its previous invocations complete execution. Reentrant functions are used in applications like hardware interrupt handling, recursion, etc.

In order for a function to be reentrant, it needs to satisfy the following conditions:

  1. It may not use global and static data. This is just a convention, there are no hard restrictions in place, but a function using global data can lose information if interrupted and re-started.
  2. It should not modify its own code. The function should be able to execute in the same way regardless of when it is being interrupted. This can be managed, but it’s generally advised against.
  3. It should not call another non-reentrant function.

Reentrancy should not be confused with thread safety, though they are closely related. A function can be thread-safe and still not reentrant. The key for avoiding confusion is that reentrant refers to only one thread executing. It is a concept from a time when no multitasking operating systems existed.

Here’s a practical example:

i = 5

def non_reentrant_function():
  return i**5

def reentrant_function(number:int):
  return number**5

What is a Lock?

A lock is essentially a thread-synchronization mechanism where a certain process can claim or “lock” another process.  The simplest type of lock is known as a binary semaphore. This 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 lead to deadlock or livelocks where processes continuously block each other with a repeated state change yet make no progress. Programming languages use locks behind the scenes to gracefully manage and share state changes between multiple subroutines.

However, some languages, such as C# and Vyper allow the use of locks directly in code.

lock: bool

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 make sure that if msg.sender (the contact caller) is another contract, it does not call the code while it is executing. If there is more code underneath the raw_call(), without a lock msg.sender could call the entire code above it, before our function finishes executing.

So in Vyper, a reentrant lock, is a mechanism that controls the access to a specific part of a function to prevent callers from executing smart-contract functions before they finish running. In many cases when it comes to DeFi hacks, it’s usually smart-contract mistakes that the contract developers did not see coming, and a clever exploiter found a weakness in the way certain functions or data are exposed.

What makes this situation unique is that Curve’s Smart Contracts, as well as all the other Pools and projects that fell victim to the attack, had no known vulnerabilities within the code itself. The contracts were sound. It was the way the Vyper language handled the reentrancy locks that caused the issue. So a contract creator likely deployed code that looks sound, only to fail due to the compiler not handling locks properly and allowing attackers to exploit the fact that code in a smart contract could be called multiple times, leading to the contracts behaving in an unexpected way.

Let’s take a look at the actual contract that feel victim to the reentrancy attack. Note the @nonreentrant(‘lock’) decorator? This should normally prevent reentrancy, however, it did not. Attackers were able to call remove_liquidity() repeatedly before the function would return a result.

@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

How was this actually exploited?

By now, we know that reentrancy attacks are a way of repeatedly calling a certain function in a smart contract. But how does this lead to stolen funds and the $70 million lost in the Curve hack?

Notice the self.balanceOf[msg.sender] -= _burn_amount towards the end of the smart contract? This tells the smart contract how much liquidity the msg.sender has in the pool, minus the burn fee. The next line of code calls transfer() on the message.sender for the amount associated to them.

A malicious contract could therefore continuously call withdraw before the amount has been updated, pretty much giving them access to withdraw all the liquidity in the pool if they so choose.

The usual flow for such an attack would go like this:

  • The vulnerable contract has 10 eth
  • The attacker calls deposit and deposits 1 eth
  • The attacker calls withdraw for 1 eth, at which point the withdraw function performs some checks:
  • Does the attacker have 1 eth in their account? Yes.
  • Transfer 1 eth to the malicious contract. NOTE: The balance of the contract has not changed yet because the function is still executing.
  • The attacker calls withdraw for 1 eth again. (reentry)
  • Does the attacker have 1 eth in their account? Yes.
  • This would repeat until the pool has no more liquidity.

The issue in the Vyper language was fixed and it is no longer present after version 0.3.0. Just a reminder that if you are a developer, or Web3 org that is using Vyper, please make sure you bump your version immediately.

Enjoyed this article?

 

Sign up to the newsletter

You’ll receive more guides, articles and tools via e-mail. All free of course. But if you value this blog and its educational resources, you can subscribe to become a paid member for only $3 a month. This will keep the website open and free.

Next Post

Google Business Profile: New Guidelines and Policies

An email sent out to all Google Business Profile (GBP) managers on July 31st announced the arrival of new GBP policies and guidelines. Specifically, a clearer list of violations that would lead to the restriction or suspension of GBPs.  This email leads users to a page offering an “Overview of […]
Google Business Profile: New Guidelines and Policies

You May Like