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

Forcing penalty to users converting by applying sandwich attack

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
);
// Store the capped overall deltaB, this limits the overall convert power for the block
pipeData.overallConvertCapacity = LibConvert.abs(LibDeltaB.overallCappedDeltaB());
IERC20(inputToken).transfer(C.PIPELINE, fromAmount);
executeAdvancedFarmCalls(advancedFarmCalls);
// user MUST leave final assets in pipeline, allowing us to verify that the farm has been called successfully.
// this also let's us know how many assets to attempt to pull out of the final type
toAmount = transferTokensFromPipeline(outputToken);
// Calculate stalk penalty using start/finish deltaB of pools, and the capped deltaB is
// passed in to setup max convert power.
pipeData.stalkPenaltyBdv = prepareStalkPenaltyCalculation(
inputToken,
outputToken,
pipeData.deltaB,
pipeData.overallConvertCapacity,
fromBdv,
pipeData.initialLpSupply
);
// Update grownStalk amount with penalty applied
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
);
// logging the penalty applied
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;
// the state of the well is such that converting bean to LP does not apply any penalty to Alice
setDeltaBforWell(int256(amount), beanEthWell, C.WETH);
depositBeanAndPassGermination(amount, users[1]);
// sandwich attack
// make the deltaB equal to zero before Alices's convert
setDeltaBforWell(0, beanEthWell, C.WETH);
// do the convert
// Create arrays for stem and amount
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]); // do this as user 1
convert.pipelineConvert(
C.BEAN, // input token
stems, // stems
amounts, // amount
beanEthWell, // token out
beanToLPFarmCalls // farmData
);
// sandwich attack
// make the deltaB equal to zero after the Alices's convert
setDeltaBforWell(0, beanEthWell, C.WETH);
}
function createBeanToLPFarmCalls(
uint256 amountOfBean,
AdvancedPipeCall[] memory extraPipeCalls
) internal view returns (AdvancedFarmCall[] memory output) {
// first setup the pipeline calls
// 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
);
// Fabricate advancePipes:
AdvancedPipeCall[] memory advancedPipeCalls = new AdvancedPipeCall[](100);
uint256 callCounter = 0;
// Action 0: approve the Bean-Eth well to spend pipeline's bean.
advancedPipeCalls[callCounter++] = AdvancedPipeCall(
C.BEAN, // target
approveEncoded, // calldata
abi.encode(0) // clipboard
);
// Action 2: Add One sided Liquidity into the well.
advancedPipeCalls[callCounter++] = AdvancedPipeCall(
beanEthWell, // target
addLiquidityEncoded, // calldata
abi.encode(0) // clipboard
);
// append any extra pipe calls
for (uint j; j < extraPipeCalls.length; j++) {
advancedPipeCalls[callCounter++] = extraPipeCalls[j];
}
assembly {
mstore(advancedPipeCalls, callCounter)
}
// 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;
}

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");
//.....
}
Updates

Lead Judging Commences

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

Lack of slippage on Pipeline Facet

Support

FAQs

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