📄

Report #13773

Report Date
November 20, 2022
Status
Closed
Payout

Possible Bug - withdrawDeposit

Report Info

Report ID

#13773

Target

Report type

Smart Contract

Impacts

Theft of unclaimed yield

Has PoC?

Yes

Bug Description

When you deposit bean using direct mode and withdraw using the same season as you made deposit in, you are able to withdraw more beans. For this exploit to hold true exploiter just needs to know the season which he made the deposit in than use that value when calling the withdrawDeposit. function.

Here are the steps:

  1. Deposit Bean using direct mode
  2. Wait an hour
  3. call sunrise()
  4. Withdraw beans

My initial analysis is that during removeDeposit (see screenshots) called in LibTokenSilo, seasons are treated as index id. That allows the withdrawDeposit checks to be bypassed, because index id = season (of Deposit).

Impact

If this is not by design than the impact is high as it can have an impact on the bean token and can cause the token to be drained. Opens up to risk of reentrancy.

Risk Breakdown

Difficulty to Exploit: Easy Weakness: CVSS2 Score:

Recommendation

In my test I noticed that the particular withdrawal does not happen if the season values are changed, perhaps my recommendation is to prevent users from directly calling the withdrawDeposit using the season as a value and have checks that make sure right amount of beans are removed if (withdrawDeposit) is called.

Proof of concept

// SPDX-License-Identifier: MIT pragma solidity 0.8.10;

import "forge-std/Test.sol"; import "forge-std/console.sol";

/*Key Information Attack Name : Locust Plague Summary : Seems code allows you to withdraw more beans than deposited using direct deposit mode.

Risk Assessment : CriticalVulnerable Contract : SiloFacet.sol Vulnerable Function(s) : Function withdrawDeposit Vulnerable Type : More beans minted Tools Used : Sol2UML, VS-Surya, Foundry and Slither Research links of Vulnerability : N/A

Disclaimer - This POC is the product of the authors best understanding of the project and associated vulnerability all claims to be proven. */

address constant BEAN_POOL = 0xc9C32cd16Bf7eFB85Ff14e0c8603cc90F6F2eE49; //CURVE_BEAN_METAPOOL address constant DIAMOND = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;

address constant BEAN3CRV = 0xc9C32cd16Bf7eFB85Ff14e0c8603cc90F6F2eE49; //CURVE_BEAN_METAPOOL

//Bean tokens address constant BEAN_LP = 0x1BEA3CcD22F4EBd3d37d731BA31Eeca95713716D; //urBEAN3CRV address constant BEAN = 0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab; //BEAN Token address constant urBEAN = 0x1BEA0050E63e05FBb5D8BA2f10cf5800B6224449; //urBEAN Token

address constant Crv = 0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490; //Curve.fi DAI/USDC/USDT (3Crv) address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

contract ContractTest is Test { ICurve private constant pool = ICurve(BEAN_POOL); IDIAMOND private constant diamond = IDIAMOND(DIAMOND);

IERC20 public constant lpToken = IERC20(BEAN_LP); IERC20 public constant bean = IERC20(BEAN); IERC20 public constant urbean = IERC20(urBEAN); IERC20 public constant crv = IERC20(Crv); IERC20 public constant bean2 = IERC20(BEAN3CRV); IERC20 public constant usdc = IERC20(USDC);

address alice = vm.addr(1); address user = 0x3a7268413227Aed893057a8eB21299b36ec3477e;

function setUp() public { vm.createSelectFork("mainnet",15973462); //fork mainnet at block 15948124 }

function testWithdrawal() external payable {

console.log("Bean balance user before deal :", bean.balanceOf(address(this)));

//deal address(this) 1 bean deal(address(bean), address(this), 1); assertEq(bean.balanceOf(address(this)), 1);

console.log("Bean balance of user before :", bean.balanceOf(address(this)));

bean.approve(address(diamond), type(uint).max); diamond.approveDeposit(address(bean), address(this), type(uint).max); diamond.deposit(address(bean), 1, 2); //deposit 1 bean using external mode

vm.warp(block.timestamp + 3600); diamond.sunrise();

diamond.withdrawDeposit(address(bean), 8487, 1); //withdraw 1 bean using external mode console.log("Bean balance of user after :", bean.balanceOf(address(this))); //recieve balance of 1978821989

} }

/interface starts here**/

interface ICurve { function get_virtual_price() external view returns (uint);

function remove_liquidity(uint lp, uint[2] calldata min_amounts) external returns (uint[2] memory);

function add_liquidity(uint[2] calldata _amounts, uint _min_mint_amount) external payable returns (uint);

function remove_liquidity_one_coin( uint lp, int128 i, uint min_amount ) external returns (uint);

function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external; }

interface IERC20 { function totalSupply() external view returns (uint);

function balanceOf(address account) external view returns (uint);

function transfer(address recipient, uint amount) external returns (bool);

function allowance(address owner, address spender) external view returns (uint);

function approve(address spender, uint amount) external returns (bool);

function transferFrom( address sender, address recipient, uint amount ) external returns (bool);

event Transfer(address indexed from, address indexed to, uint value); event Approval(address indexed owner, address indexed spender, uint value); }

interface IDIAMOND { function approveDeposit(address token, address account, uint256 amount) external; function deposit(address token ,uint256 amount , uint8 mode) external; function withdrawDeposit(address token, uint32 season, uint256 amount) external; function updateSilo(address account) external; function convert(bytes calldata convertData, uint32[] memory crates, uint256[] memory amounts) external; function withdrawDeposits(address token, uint32[] calldata seasons, uint256[] calldata amounts) external; function buyAndDepositBeans(uint256 amount, uint256 buyAmount) external; function plant() external; function enrootDeposit(address token, uint32 _season, uint256 amount) external; function harvest(uint256[] calldata plots) external; function mintFertilizer(uint128 amount, uint256 minLP, uint8 mode) external; function chop(address token, uint256 amount, uint8, uint8) external; function balanceOfStalk(address account) external; function claimWithdrawal(address, uint32, uint8) external; function claimPlenty() external; function withdrawFreeze() external; function transferDeposit(address sender, address recipient, address token, uint32 season, uint256 amount) external; function sunrise() external;

image
image

BIC Response

Thank you for your report. This behavior occurs because of the calling of diamond.sunrise(). Beanstalk rewards the caller of this function with Beans to incentivize decentralized actors to call the function once per hour. You can learn more about it here: https://docs.bean.money/farm/sun

The sunrise() function can be found in SeasonFacet.sol. The reward beans are delivered in this line:

        incentivize(msg.sender, C.getAdvanceIncentive());

The number of Beans rewarded depends on how long after the hour the sunrise function is called (the longer the wait, the more Beanstalk is willing to pay). It caps out around ~2000 BEAN. You'll notice that the amount received is 1978821989 (1,978_821989).

Some other notes on Beanstalk's behavior that you may find useful in future investigations:

  1. No BEAN is transferred during withdrawDeposit, because the user must wait until the next season to claim the tokens from their withdrawal. In your test, you can see this by placing a balanceOf call before and after the withdrawDeposit like so:
    console.log("Bean balance of user after Sunrise, before withdrawDeposit :", bean.balanceOf(address(this)));
    diamond.withdrawDeposit(address(bean), 8487, 1);
    console.log("Bean balance of user after Sunrise, after withdrawDeposit :", bean.balanceOf(address(this)));
  This 0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84
  Bean balance user before deal : 0
  Bean balance of user before : 1
  Bean balance of user after Sunrise, before withdrawDeposit : 1978821989
  Bean balance of user after Sunrise, after withdrawDeposit : 1978821989
  1. "External mode" only applies when tokens are transferred; there is a FromMode parameter on the deposit function, but not on the withdrawDeposit function since it doesn't transfer any tokens (transfer happens during claimWithdrawal).

Due to the reasons outlined above, we are closing the submission and no reward will be issued.