Skip to content

Latest commit

 

History

History

CurvePoolOracle

Curve Pool Oracle

Step-by-step

  1. Take a flashloan
  2. Add a lot of liquidity to an eth/{something} pool to get LP tokens
  3. Remove liquidity triggering fallback when receiving ETH
  4. Request a loan from a system using get_virtual_price as a price oracle for LP tokens
  5. Loan will be given taking a higher-than-real price for LP tokens
  6. Repay flashloan

Detailed Description

Understanding this attacks needs some familiarity with AMMs and how crypto system compose with each other.

We will start by reviewing Curve's Stable Swaps. There's no need to go deep into the math, we can just understand that is is an AMM that is optimized for trades between assets that are really close in price (different from constant product AMMs).

The graph below should give us a fairly good intuition of how the curve in an stableswap look like:

stableswap curve

Stableswaps keep a value D as the swap-invariant (like constant product keeps k). Just like in constant product, this is a semi-invariant in in practice as it will increase due to fees.

Now, we must understand some particularities of Curve before moving forward. We probably can't do it better than the folks at ChainSecurity have already done, so we really recommend you go and read the blog post. Let's do a very condensed summary just in case you don't have time to read the full story today:

A primer on Curve Pool

Curve Pools have a get_virtual_price that returns the prices of LP tokens (calculated as D / total_lp_token_supply ~= total_underlying_supply / total_lp_token_supply).

When you burn your LP tokens, this code is executed:

// _amount is the amount of lp tokens burn
// amount[i] is the total amount of each token
CurveToken(lp_token).burnFrom(msg.sender, _amount)
for i in range(N_COINS):
    value: uint256 = amounts[i] * _amount / total_supply
    if i == 0:
        raw_call(msg.sender, b"", value=value)
    else:
    assert ERC20(self.coins[1]).transfer(msg.sender, value)

Note that if i == 0 the value is sent natively. This means msg.sender is suddenly executed. At this point, the contract has burned the LP tokens but has only sent part of the underlyings. If you remember, get_virtual_price was total_underlying_supply / total_lp_token_supply: this number is now off, as only a part of the total_underlying_supply has been sent while all of the corresponding total_lp_token_supply has been burned.

Here is a the implementation of get_virtual_price:

@view
@external
def get_virtual_price() -> uint256:
    """
    @notice The current virtual price of the pool LP token
    @dev Useful for calculating profits
    @return LP token virtual price normalized to 1e18
    """
    D: uint256 = self.get_D(self._balances(), self._A())
    # D is in the units similar to DAI (e.g. converted to precision 1e18)
    # When balanced, D = n * x_u - total virtual value of the portfolio
    token_supply: uint256 = ERC20(self.lp_token).totalSupply()
    return D * PRECISION / token_supply

If an attacker finds a protocol that uses get_virtual_price as a price oracle for LP tokens, they could be exploited.

Unfortunately, this is exactly what QiDao and Market XYZ did. For the purposes of this reproduction, we are gonna concentrate on QiDAO but the concept for Market XYZ is the same.

At this point, you only need to know that QiDAO is a lending platform which used MasterPriceOracle to get the prices for their collateralized borrows. Unfortunately, this oracle used get_virtual_price, which we now know how to exploit.

trace

Possible mitigations

  • Ensure that the tokens addresses provided match the addresses from the targeted pool or check if they are whitelisted.
  • Use a reentrancy mutex if arbitrary tokens are meant to be handled.
  • Review the checks-effects-interactions pattern and evaluate the steps at which tokens flow in and out the contract.

Diagrams and graphs

Sources and references