Vyper Language Zero Day Exploits

9 min read

Learn how a compiler bug on Vyper was exploited, causing a loss of $73.5 million.

TL;DR#

On July 30, 2023, a bug on Vyper was used to exploit several DeFi protocols, caused due to broken reentrancy locks on their compiler versions 0.2.15, 0.2.16, and 0.3.0, which resulted in a total loss of approximately $73.5 million worth of assets.

Introduction to Vyper#

Vyper is a Python-based smart contract language for the EVM.

Vulnerability Assessment#

The root cause of the vulnerability is a misconfigured reentrancy lock in three different versions of the Vyper compiler.

Exploit Scenario#

The Vyper team issued a critical advisory regarding a flaw in their compiler versions 0.2.15, 0.2.16, and 0.3.0 related to faulty reentrancy protections. This vulnerability has already resulted in significant compromises on various DeFi protocols. In particular, the reentrancy guard, which was supposed to stop smart contracts from making recursive calls, didn't work because it set different storage positions depending on the function that was calling it, rendering them ineffective.

According to the documentation of the Vyper, the `@nonreentrant(<key>)` decorator places a lock on a function and all other functions with the same <key> value. An attempt by an external contract to call back into any of these functions causes the transaction to revert. Non-Reentrant locks work by setting a specially allocated storage slot to a `<locked>` value on function entrance and setting it to an `<unlocked>` value on function exit. On function entrance, if the storage slot is detected to be the `<locked>` value, execution reverts.

As viewed from their GitHub repository, the pull requests #2391 titled `fix: storage slot allocation bug` appears to have introduced the bug, and a patch of this was applied on the PR #2439 titled `Fix unused storage slots`. This patch further introduced a new bug which caused compilation to fail, but it was subsequently fixed as viewed from this PR #2514 titled `fix codegen failure with nonreentrant keys`.

A number of Curve pools were exploited due to this issue. Essentially, all of the exploited protocols used the `add_liquidity` and `remove_liquidity` functions. The exploiter of these protocols called these two functions interchangeably, thus bypassing the reentrancy guard. The decompiled Vyper pools deployed contract code shows that different storage slots in `stor_0` and `stor_2` were used as reentrancy locks, thus rendering the reentrancy lock non-effective.

function add_liquidity(uint256[2] varg0) public payable { 
  require(!(varg0 >> 160));
  require(!stor_0);
  stor_0 = 1
  ...
}
function remove_liquidity(uint256 varg0, uint256[2] varg1) public payable { 
  require(!(varg1 >> 160));
  require(!stor_2);
  stor_2 = 1
  ...
}

Affected Protocols#

Case I: JPEG'd

As viewed from the attack transaction on JPEG'd, their pETH pool was exploited by this vulnerability, causing a loss of approximately 6,106 WETH, totaling $11.4 million. The original attack was initiated by this exploiter, but was effectively front-run by a MEV bot.

The exploiter initially took a flash loan of 80,000 WETH from Balancer, supplied 32,431 WETH as liquidity to Curve, and received pETH-ETH LP tokens. A higher number of WETH were supplied yet again, minting further 82,182 pETH-ETH LP tokens. Approximately 3,740 pETH were withdrawn by removing some Curve liquidity.

The initial liquidity of 32,431 Curve LP tokens was burned to remove that liquidity, and another 1,184 pETH was withdrawn by burning more Curve LP tokens. The attacker was able to manipulate the price calculation between pETH and WETH by reentering the vulnerable `add_liquidity` function right after calling the `remove_liquidity` routine, thereby updating the balance in the process.

@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
@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

In the end, 4,924 pETH was swapped for 4,285 WETH using JPEG'd's exchange, and the borrowed flash loan was repaid to the Balancer. The profits from these were approximately 6,106 WETH.

Case II: Alchemix

The alETH (alETH+ETH-f) pool contract of Alchemix was also affected due to this bug, which resulted in a loss of 7,258 ETH worth approximately $13.6 million. We attempt to analyze the attack transaction executed by the exploiter.

The exploiter initially took a flash loan of 40,000 WETH from Balancer and deposited them in the alETH/ETH Curve pool to mint 19,895 alETH/ETH LP tokens. Furthermore, 34,277 alETH/ETH LP tokens were minted by providing additional liquidity to the same Curve pool. Approximately 4,821 alETH tokens were withdrawn from Alchemix by removing some liquidity, and 19,895 alETH/ETH LP tokens were burned to remove initial liquidity from the Curve alETH/ETH pool.

An additional 15,910 alETH/ETH LP tokens were burned to remove the rest of the liquidity from the same Curve pool. The exploiter then repaid the flash loan to Balancer, and the profit from these trades, worth 7,258 WETH, amounting to approximately $13.6 million.

As per multiple reports, approximately $22 million worth of assets were stolen from AlchemixFi, and around $13 million worth of assets were taken away under white-hat rescue operations.

Case III: MetronomeDAO

Furthermore, the sETH-ETH-f pool contract of MetronomeDAO was also affected, which resulted in a loss of 866 ETH worth approximately $1,625,950. This is the attack transaction in reference executed by the exploiter.

Case IV: Ellipsis

Ellipsis Finance was also attacked as a result of this vulnerability, which resulted in a loss of 282 WBNB, worth approximately $68,581.

Case V: deBridge

deBridge Finance was also exploited due to the same exploit, which resulted in a loss of 13.13 ETH, worth approximately $24,590.

Case VI: Curve Finance

The CRV/ETH LP contract of Curve Finance was also exploited in multiple transactions, which resulted in a loss of funds worth over $24 million. Approximately 7,680 WETH and 7.193 million CRV tokens, totaling $14.413 million, were exploited in one of the attack transactions. The affected contract used Vyper compiler version 0.3.0.

Aftermath#

Following the occurrence of the incident, a number of MEV bots were reported to have front-runned some of the exploits. Several security researchers were also working on white-hat rescue operations to aid all of the affected parties.

Vyper acknowledged the vulnerability and urged projects relying on the three affected versions to get in touch for immediate action. Curve operates 232 different pools, but only pools using Vyper versions 0.2.15, 0.2.16, and 0.3.0 were at risk. Curve Finance also issued a clarification, stating that CRV/USD or any of the associated non-ETH pool contracts remain unaffected.

The Alchemix exploiter also sent an on-chain message to an EOA stating that they should return the stolen assets. The address labeled `c0ffeebabe.eth` has returned 2,879.5 ETH, worth $5.4 million, to the Curve Finance deployer.

Out of $73.5 million in total stolen assets, approximately 73%, amounting to $52.3 million, have been recovered or returned thus far.

Alchemix: exploiter returned all of the $22 million, consisting of 7,258 ETH and 4,821 alETH.

JPEG'd: The frontrunner returned a total of 90% of the stolen assets, amounting to $11.5 million, consisting of 5,495.4 WETH.

MetronomeDAO: The `c0ffeebabe.eth` address returned approximately $7 million worth of stolen assets.

Alchemix: a white-hat operation to rescue $13 million.

The CRV/ETH LP exploiter has not yet returned the remaining stolen assets, which total $19.7 million.

Solution#

The DeFi sector faced a significant challenge when a bug was discovered in the Vyper compiler versions 0.2.15, 0.2.16, and 0.2.30. This flaw affected many DeFi protocols, underscoring the complex and multifaceted nature of smart contract development and deployment. Fortunately, a patch was swiftly deployed in the subsequent 0.2.31 version, reinforcing the proactive approach of the development community. However, this incident raises essential questions about the broader ecosystem's resilience and the contingency measures in place.

While developers and auditors strive to ensure the most robust codebase and conduct exhaustive testing, the dynamic and intricate nature of smart contracts means that vulnerabilities can occasionally slip through. These unforeseen risks necessitate a multi-layered security approach, combining both proactive and reactive measures.

This is where solutions like Neptune Mutual come into play, offering a safeguard against the aftermath of such vulnerabilities. If the affected projects had collaborated with Neptune Mutual and established dedicated coverage pools, the financial repercussions of the exploit could have been significantly mitigated. Neptune Mutual offers coverage for users who have suffered losses of funds or digital assets due to smart contract vulnerabilities, utilizing their innovative parametric policies.

Users who purchase our parametric cover policies do not need to provide evidence of their loss to receive payouts. Once an incident is confirmed and resolved through our incident resolution system, payouts can be claimed immediately. Our marketplace is available on multiple popular blockchain networks, including EthereumArbitrum, and the BNB chain, providing coverage to a diverse array of DeFi users and bolstering their confidence in the ecosystem.

Neptune Mutual Marketplace#

After this exploit, the covers for Curve Finance on both Ethereum and Arbitrum were flagged as `Incident Occurred` after being reported by community members.

The 7-day Incident Reporting period began across our marketplace on both chains, where NPM token holders participated in the reporting process.

A 24-hour wait period began after the end of the reporting period, and the resolution was made in favor of the First Reporter, hence regarded as the Final Reporter.

Following this, a 7-day window commenced on August 9th, roughly 9 days post-exploit, during which all Curve Finance policyholders were eligible to claim their payout. On August 16, the Curve Finance pool was open once again for purchasing cover and providing liquidity. 

Learn more about the Incident resolution process in our video here, and safeguard your digital assets from hacks, exploits and smart contract vulnerabilities with the Neptune Mutual Cover Marketplace

By

Tags