Possible Bug - withdrawDeposit
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:
- Deposit Bean using direct mode
- Wait an hour
- call sunrise()
- 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;
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:
- 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
- "External mode" only applies when tokens are transferred; there is a
FromMode
parameter on thedeposit
function, but not on thewithdrawDeposit
function since it doesn't transfer any tokens (transfer happens duringclaimWithdrawal
).
Due to the reasons outlined above, we are closing the submission and no reward will be issued.