Summary
Lack of reentrancy protection can brick the blueprint execution flow if the blueprint execution is nested.
Vulnerability Details
First, after a user publish a blueprint, another user can execute the blueprint:
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/df2dd129a878d16d4adc75049179ac0029d9a96b/protocol/contracts/beanstalk/farm/TractorFacet.sol#L86
function tractor(
LibTractor.Requisition calldata requisition,
bytes memory operatorData
)
external
payable
verifyRequisition(requisition)
runBlueprint(requisition)
returns (bytes[] memory results)
{
note the code modifier runBlueprint:
modifier runBlueprint(LibTractor.Requisition calldata requisition) {
require(
LibTractor._getBlueprintNonce(requisition.blueprintHash) <
requisition.blueprint.maxNonce,
"TractorFacet: maxNonce reached"
);
require(
requisition.blueprint.startTime <= block.timestamp &&
block.timestamp <= requisition.blueprint.endTime,
"TractorFacet: blueprint is not active"
);
LibTractor._incrementBlueprintNonce(requisition.blueprintHash);
LibTractor._setPublisher(payable(requisition.blueprint.publisher));
_;
LibTractor._resetPublisher();
}
first the code triggers LibTractor._setPublisher, then after the blueprint is executed:
the code _resetPublisher runs and the publisher is reset,
function _setPublisher(address payable publisher) internal {
TractorStorage storage ts = _tractorStorage();
require(uint160(bytes20(address(ts.activePublisher))) <= 1, "LibTractor: publisher already set");
ts.activePublisher = publisher;
}
* @notice Reset blueprint publisher address.
*/
function _resetPublisher() internal {
_tractorStorage().activePublisher = payable(address(1));
}
In the code, when the code involves getting msg.sender address, inside the blueprint execution context, the msg.sender will be the publisher,
example call:
function transferToken(
IERC20 token,
address recipient,
uint256 amount,
LibTransfer.From fromMode,
LibTransfer.To toMode
) external payable fundsSafu noSupplyChange oneOutFlow(address(token)) {
LibTransfer.transferToken(token, LibTractor._user(), recipient, amount, fromMode, toMode);
}
the sender address is LibTractor._user(), which is calling:
function _user() internal view returns (address payable user) {
user = _getActivePublisher();
if (uint160(bytes20(address(user))) <= 1) {
user = payable(msg.sender);
}
}
as we can see, the user (mgs.sender) will be the publisher address.
we go back to the modifier:
LibTractor._setPublisher(payable(requisition.blueprint.publisher));
_;
LibTractor._resetPublisher();
the problem is that the tractor contract can be re-entered and the blueprint execution can be nested.
consider the case:
user A create a blueprint execution AAA
the blue print execution AAA contains several steps of logic:
do action A
unwrap or refund
do action B
do action C
user B executes blueprint AAA, but blueprint AAA contains ETH transfer logic.
function unwrap(uint256 amount, LibTransfer.From mode) internal {
amount = LibTransfer.receiveToken(IERC20(WETH), amount, msg.sender, mode);
withdraw(amount);
(bool success, ) = msg.sender.call{value: amount}(new bytes(0));
require(success, "Weth: unwrap failed");
}
or
7 results - 6 files
protocol/contracts/beanstalk/farm/DepotFacet.sol:
57 results = IPipeline(PIPELINE).advancedPipe{value: value}(pipes);
58 results = IPipeline(PIPELINE).advancedPipe{value: value}(pipes);
59: LibEth.refundEth();
59 }
60 }
71 result = IPipeline(PIPELINE).pipe{value: value}(p);
72 result = IPipeline(PIPELINE).pipe{value: value}(p);
73: LibEth.refundEth();
73 }
74 }
protocol/contracts/beanstalk/farm/FarmFacet.sol:
59 s.sys.isFarm = 1;
60: LibEth.refundEth();
61 }
protocol/contracts/beanstalk/farm/TokenFacet.sol:
234 LibWeth.wrap(amount, mode);
235: LibEth.refundEth();
236 }
protocol/contracts/beanstalk/migration/L1TokenFacet.sol:
195 LibWeth.wrap(amount, mode);
196: LibEth.refundEth();
197 }
protocol/contracts/beanstalk/migration/L1Libraries/LibEth.sol:
15 library LibEth {
16: function refundEth() internal {
17 AppStorage storage s = LibAppStorage.diamondStorage();
protocol/contracts/libraries/Token/LibEth.sol:
19
20: function refundEth() internal {
21 AppStorage storage s = LibAppStorage.diamondStorage();
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/df2dd129a878d16d4adc75049179ac0029d9a96b/protocol/contracts/libraries/Token/LibEth.sol#L15
note the refund ETH send ETH out:
library LibEth {
function refundEth() internal {
AppStorage storage s = LibAppStorage.diamondStorage();
if (address(this).balance > 0 && s.isFarm != 2) {
(bool success, ) = msg.sender.call{value: address(this).balance}(new bytes(0));
require(success, "Eth transfer Failed.");
}
}
}
instead the external ETH transfer call, the user B execute a blueprint created by himself (key of the exploit)
then the publisher is reset in the end of the user B's blueprint execution:
LibTractor._setPublisher(payable(requisition.blueprint.publisher));
_;
LibTractor._resetPublisher();
but at this point, the blueprint AAA is not completed yet,
then all LibTractor._user() that means to return user A's address returns msg.sender user B's address.
the blue print execution AAA contains several steps of logic:
do action A
unwrap
do action B
do action C
if the do action B or C contains logic such as
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/df2dd129a878d16d4adc75049179ac0029d9a96b/protocol/contracts/beanstalk/barn/FertilizerFacet.sol#L86
function mintFertilizer(
uint256 tokenAmountIn,
uint256 minFertilizerOut,
uint256 minLPTokensOut
) external payable fundsSafu noOutFlow returns (uint256 fertilizerAmountOut) {
fertilizerAmountOut = _getMintFertilizerOut(
tokenAmountIn,
LibBarnRaise.getBarnRaiseToken()
);
require(fertilizerAmountOut >= minFertilizerOut, "Fertilizer: Not enough bought.");
require(fertilizerAmountOut > 0, "Fertilizer: None bought.");
uint128 remaining = uint128(LibFertilizer.remainingRecapitalization().div(1e6));
require(fertilizerAmountOut <= remaining, "Fertilizer: Not enough remaining.");
uint128 id = LibFertilizer.addFertilizer(
uint128(s.sys.season.current),
tokenAmountIn,
fertilizerAmountOut,
minLPTokensOut
);
C.fertilizer().beanstalkMint(
LibTractor._user(),
uint256(id),
(fertilizerAmountOut).toUint128(),
s.sys.fert.bpf
);
}
the stalk that should be minted to address A is minted to address B, which completely broke the blueprint execution.
the full execution:
user B executes user A's blueprint:
- publisher address is user A
- refund occurs, ETH sent to user B
- user B's blueprint is executed (can be any logic as simple as transfer dust token to themselves)
- publisher address is set to user B
- user B's blueprint is executed,
- publisher address is set back to address(1) and LibTractor._user() is msg.sender
- user A's bllueprint execution continues,
- all LibTractor._user() call in bluerpint AAA is broken.
Impact
blueprint execution is broken if reentrancy happens.
Tools Used
Manual Review.
Recommendations
add reentrancy protection in blueprint execution.