How a Vyper 0-day Led to Tens of Millions of Dollars in Hacked Funds

AfterDark Labs
8 min readJul 31, 2023
Connect with us at or @afterdark_labs

What Happened?

Over $50,000,000 has been hacked from protocols due to a novel vulnerability in the Vyper programming language. The root cause of the issue is the way the Vyper compiler assigns storage slots for contracts leveraging the built-in @reentrancy decorator. This only affects Vyper versions >= 0.2.15 < 0.3.1. If your protocol is currently deployed using any of these versions, we suggest reaching out to a security professional to determine if you are impacted and explore potential courses of action.

This article is going to go over the vulnerability from first principles. If you are familiar with the concepts of reentrancy and cross-function reentrancy, feel free to skip ahead to the section titled “Reentrancy in Vyper 0.2.15”.


Reentrancy in Solidity

Doubtless most developers and auditors in the space are familiar with reentrancy at this point. Still, it’s worth going over a few examples that relate to the issue regarding how Vyper’s compiler handled reentrancy protection. Below we have the quintessential example of reentrancy in a Solidity smart contract:

Example vault contract vulnerable to reentrancy

If we look at the withdraw() function here, we can see that it does not follow the checks-effects-interaction pattern and as a result is vulnerable to a classic reentrancy issue. Since the external call occurs before the balance of the msg.sender is updated, we can easily devise a contract that could call back into the withdraw function while the msg.sender still has a positive balance. The msg.sender could continue to get paid out its initial balance as long as the contract holds Ether, without donating any extra funds to the contract. In other words, anyone could drain this vault.


As a developer who wishes to prevent reentrancy issues, there are typically two approaches to take. The first is to follow the checks-effects-interaction pattern. In this case, that would mean moving the balances[msg.sender] = 0; line directly before the external call. This would make it so that even if the user did call back into the withdraw function, the balance would be correctly accounted for during the reentrancy.

A second way to prevent reentrancy issues is to make use of a reentrancy guard. In Solidity, reentrancy guards are not built into the language, so developers must leverage external libraries or otherwise implement their own. Let’s take a look at the popular OpenZeppelin’s reentrancy guard implementation:

A snippet from OpenZeppelin’s ReentrancyGuard contract

The way the reentrancy guard works is it sets the _status variable, which takes up a single slot in storage, to the value of either 1 or 2 depending on if a function has already been entered or not. Any function that has the nonReentrant modifier cannot be reentered, as once one function containing this modifier has been entered the _status variable is set to 1 (_ENTERED).

Consider our example now:

Example vault contract with some reentrancy protections

We can no longer enter the withdraw function more than one time. However, the observant reader may have noticed that there is another function containing withdrawal capabilities that does not have the nonReentrant modifier.

Cross-function Reentrancy

While it is true that the nonReentrant modifier prevents a user from reentering the withdraw() function during the external call, nothing prevents a user from calling the withdrawTo() function before the withdraw() function has updated the balances mapping. This issue is known as cross-function reentrancy and in this case it would allow the user to redeem their balance while simultaneously sending their balance out to another user before it is set to 0. In other words, users can spend double of what they actually have. This could be repeated an indefinite amount of times to eventually drain the vault.

This could be fixed by simply adding the nonReentrant modifier to withdrawTo() as well. This works, because the _status variable is occupying a single storage slot in the contract that determines whether a function has been entered or not. In other words, any function that leverages the nonReentrant modifier cannot be reentered.

Example vault with reentrancy protections to prevent draining

Reentrancy in Vyper 0.2.15

Reentrancy guards in Vyper are implemented differently than they are in Solidity. In Vyper, the @nonreentrant decorator is a built-in type handled by the language itself. Developers do not need to import a library to protect functions from reentrancy. Instead, they just need to apply the decorator to any function they wish to have reentrancy protection for. Here is the same vault written in Vyper:

Example vault with reentrancy protections to prevent draining

Let’s take a quick look at how the @nonreentrant decorator works in Vyper 0.2.15. When generating the intermediate representation (IR) for a function, the Vyper compiler includes the reentrant pre and post conditions as calculated from the get_nonreentrant_lock() function:

@nonreentrant decorator pre and post conditions

We gain quite a few insights from the above function, at least based on how the IR will be built. The precondition for the function will check that the storage slot (nkey) holding the reentrancy flag is not already set to the designated temp_value. If that holds true, then it will store the temp_value into the nkey storage slot, setting the reentrancy flag. Finally the post condition will store the final_value in the nkey storage slot, unsetting the reentrancy flag.

This looks quite a bit like the Solidity library we dug into, except one thing remains unanswered: how is the storage slot for nkey calculated? To determine this, we turn to the parse module that calculates the layout of storage variables during contract compilation:

The first loop goes over each function definition and determines if they have the @nonreentrant decorator. If they do, it sets the reentrancy key position to storage_slot. So far so good. But then it increments the storage_slot. Uh oh. Are we seeing the problem? Every single @nonreentrant decorator receives its own storage slot. This implies cross-function reentrancy is not covered by the decorator. Let’s confirm this with our vault example from before:

Unfortunately it is indeed true that the vault is still vulnerable to cross-function reentrancy despite having the @nonreentrant decorator placed on both withdraw functions. The vault example and its tests will be released in the near future for educational purposes. Now that we understand the problem let’s examine one of the protocols that was impacted by this vulnerability.

pETH-ETH Pool Attack

Taking a closer look at the pETH-ETH pool contract, it can be seen that functions for depositing and withdrawing from the protocol contain the @nonreentrant decorator. However, we know from our above example that this does not prevent cross-function reentrancy in Vyper 0.2.15. Examining the remove_liquidity() function, it becomes clear that this does not follow the checks-effects-interactions pattern as there are state variables updated after an external call is made to the msg.sender:

The question now becomes, is there another function that relies on the values from total_supply or the msg.sender’s balance? It turns out, there is. The add_liquidity() function will increase the total_supply and the balance of the msg.sender based on a calculated value called the mint_amount. Looking closer, we can see that the mint_amount itself is calculated using the existing total_supply:

This means that an artificially high total_supply, as is the case when liquidity has been removed but not yet accounted for, will produce an abnormally large mint_amount. Armed with this knowledge, let’s breakdown the attack transaction that drained the pool.

Attack Transaction

1. The attacker takes out a flash loan for 80,000 WETH from the balancer vault. They then withdraw that to give themselves 80,000 ETH.

Attacker balance: 80,000 ETH

2. The attacker calls add_liquidity() on the pETH-ETH curve pool with 40,000 ETH. This gives them a balance of 32,431 pETH-ETH LP tokens.

Attacker balance: 40,000 ETH | 32,431 pETH-ETH

3. The attacker calls remove_liquidity with a 32,431 _burn_amount. The value is calculated as 34,316 ETH which is then sent using the raw_call() to the attacker’s fallback function.

Attacker balance: 74,316 ETH | 32,431 pETH-ETH

4. The attacker’s fallback function contains a call to add_liqudity() before remove_liquidity() has completed. This is successful due to the issue in Vyper’s compiler for @nonreentrant decorators. The attacker sends 40,000 ETH to the add_liqudity() function. The mint_amount is artificially inflated and as a result the attacker is credited with 82,182 pETH-ETH.

Attacker balance: 34,316 ETH | 114,613 pETH-ETH

5. The second coin in the initial remove_liquidity() loop is hit and the attacker receives 3,740 pETH. At the end of the remove_liquidty() function, the initial _burn_amount of 32,431 is subtracted from the attacker’s pETH-ETH balance.

Attacker balance: 34,316 ETH | 82,182 pETH-ETH | 3,740 pETH

6. The attacker now calls remove_liquidity() once more with a 10,272 Ether _burn_amount which now is worth 47,506 ETH and 1,187 pETH. The attacker is credited with both the ETH and the pETH.

Attacker balance: 81,822 ETH | 71,910 pETH-ETH | 4927 pETH

Finally, the attacker exchanges their pETH for 4,285 ETH. They now convert all of their ETH to WETH, pay off the flash loan and their final balance is their profit.

Attacker balance: 6,106 WETH | 71,910 pETH-ETH

Ecosystem Impact

Multiple curve pools and other protocols have already been affected by this issue for about $52,000,000 in losses as of July 30, 2023.

If you’re interested in a smart contract audit or other security services, get the process started by visiting or reaching out to directly.



AfterDark Labs Shining a light on the darkest corners of Web3. We offer collaborative and client-centric blockchain security solutions.