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

Lack of reentrancy protection can brick the blueprint execution flow if the blueprint execution is nested.

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)) { // @audit no payable?
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:

  1. 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

  1. 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 // use _user();
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)); // remaining <= 77_000_000 so downcasting is safe.
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.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Refunding ETH to the caller can be exploited using the Tractor component to call any arbitrary function on behalf of the publisher

Support

FAQs

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