Platform: Code4rena
Start Date: 30/04/2024
Pot Size: $112,500 USDC
Total HM: 22
Participants: 122
Period: 8 days
Judge: alcueca
Total Solo HM: 1
Id: 372
League: ETH
Rank: 34/122
Findings: 4
Award: $257.72
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: guhu95
Also found by: 0rpse, 0x007, 0x73696d616f, 0xCiphky, 0xabhay, Audinarey, Bauchibred, Fassi_Security, GalloDaSballo, GoatedAudits, KupiaSec, LessDupes, MSaptarshi, OMEN, Ocean_Sky, RamenPeople, SBSecurity, Tendency, WildSniper, aslanbek, bill, blutorque, crypticdefense, cu5t0mpeo, d3e4, gjaldon, grearlake, gumgumzum, honey-k12, ilchovski, jokr, josephdara, kennedy1030, p0wd3r, peanuts, stonejiajia, t0x1c, tapir, underdog, zzykxx
0.4071 USDC - $0.41
https://github.com/Layr-Labs/eigenlayer-contracts/blob/15b679b08e7a3589ff83a5e84076ca4e0a00ec0b/src/contracts/pods/DelayedWithdrawalRouter.sol#L100-L105 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L123-L164 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L274-L358 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L405-L424 https://github.com/Layr-Labs/eigenlayer-contracts/blob/15b679b08e7a3589ff83a5e84076ca4e0a00ec0b/src/contracts/pods/DelayedWithdrawalRouter.sol#L127-L137 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L432-L437 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L459-L463
There are Native ETH Restake Admin operations in the Operator Delegator that send funds to the Delayed Withdrawal Router.
withdrawNonBeaconChainETHBalanceWei()
startDelayedWithdrawUnstakedETH()
verifyAndProcessWithdrawals()
startDelayedWithdrawUnstakedETH()
activateRestaking()
Once there are funds in the Delayed Withdrawal Router, anyone can claim these withdrawals after the set delay. When the protocol computes for its TVL, it does not include any of its Operator Delegators' ETH that is in the Delayed Withdrawal Router. This presents an MEV opportunity.
Users can extract value at the expense of other depositors by sandwiching the claimDelayedWithdrawals()
call whenever funds are in the Withdrawal Router.
Consider the following scenario:
Manual Review, Foundry
Consider including the total amount in delayed withdrawals and the nonBeaconChainETHBalanceWei
for each pod as part of the TVL calculation. Delayed Withdrawal Router's getUserDelayedWithdrawals()
can be used for getting the delayed withdrawals.
MEV
#0 - c4-judge
2024-05-16T09:44:38Z
alcueca marked the issue as not a duplicate
#1 - c4-judge
2024-05-16T09:44:42Z
alcueca changed the severity to 3 (High Risk)
#2 - c4-judge
2024-05-16T09:44:50Z
alcueca marked the issue as duplicate of #326
#3 - alcueca
2024-05-16T09:45:31Z
Root cause is that TVL changes can be sandwiched due to the immediate valuation of ezETH in withdraw
.
#4 - c4-judge
2024-05-16T09:45:36Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2024-05-16T09:50:32Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-05-24T10:26:54Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: guhu95
Also found by: 0rpse, 0x007, 0x73696d616f, 0xCiphky, 0xabhay, Audinarey, Bauchibred, Fassi_Security, GalloDaSballo, GoatedAudits, KupiaSec, LessDupes, MSaptarshi, OMEN, Ocean_Sky, RamenPeople, SBSecurity, Tendency, WildSniper, aslanbek, bill, blutorque, crypticdefense, cu5t0mpeo, d3e4, gjaldon, grearlake, gumgumzum, honey-k12, ilchovski, jokr, josephdara, kennedy1030, p0wd3r, peanuts, stonejiajia, t0x1c, tapir, underdog, zzykxx
0.4071 USDC - $0.41
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L274-L358 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/Mantle/METHShim.sol#L80 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L75
There are validator penalties and slashings that can decrease the value of staked ETH. This can affect all of Renzo's supported collateral tokens, wbETH, mETH, stETH and ETH.
There is typically a delay between the update of an Oracle and the penalty. In mETH, for example:
If a major slashing event occurs for the protocol's validators, which causes a large loss of ETH controlled by the protocol, the exchange rate will not reflect this slashing until the next Oracle update. As the Oracle is configured to run every 8 hours, it is possible for another party to detect the slashing event, and request an unstake of all of their ETH at an elevated exchange rate before the oracle data "catches up" and the correct rate is quoted.
The described issue above has been mitigated in mETH. However, the issue still applies to Renzo.
The Oracle update in mETH is done every 8 hours. During a major slashing event and before the next Oracle update, depositors can withdraw their ETH from Renzo. The withdrawal is done while TVL has not yet decreased due to the slashing. Users unable to withdraw before the huge decrease in TVL will shoulder the losses of the slashing event.
This leads to a loss of ETH for all remaining depositors and would cause a rush to exit the protocol. The damage is worse when the assets withdrawn are unaffected by the slashing.
The TVL of the protocol is used for computing the minting and withdrawal amounts and is calculated with:
totalTVL = sum(operatorTVL) + DepositQueue ETH Balance + WithdrawQueue TVL
A decrease in the price of any of its collateral tokens will decrease Renzo's TVL.
In the case of mETH, its price is fetched from an Oracle which calls methStaking.mETHToETH(1 ether)
to get its price.
ref: mETH Staking contract on mainnet
function mETHToETH(uint256 mETHAmount) public view returns (uint256) { if (mETH.totalSupply() == 0) { return mETHAmount; } return Math.mulDiv(mETHAmount, totalControlled(), mETH.totalSupply()); } /// @notice The total amount of ETH controlled by the protocol. /// @dev Sums over the balances of various contracts and the beacon chain information from the oracle. function totalControlled() public view returns (uint256) { OracleRecord memory record = oracle.latestRecord(); uint256 total = 0; total += unallocatedETH; total += allocatedETHForDeposits; total += totalDepositedInValidators - record.cumulativeProcessedDepositAmount; total += record.currentTotalValidatorBalance; total += unstakeRequestsManager.balance(); return total; }
Consider the following scenario:
Manual Review, Foundry
Consider adding a withdrawal delay when a major slashing event greatly decreases the protocol TVL. Another option is to add pause functionality to withdrawals so that permissioned managers can pause withdrawals in response to major slashing events. Note that the manual pause can still be front-runned and does not fully mitigate the issue.
MEV
#0 - C4-Staff
2024-05-15T14:18:47Z
CloudEllie marked the issue as duplicate of #513
#1 - c4-judge
2024-05-20T03:50:30Z
alcueca changed the severity to 3 (High Risk)
#2 - c4-judge
2024-05-20T03:50:42Z
alcueca marked the issue as duplicate of #326
#3 - c4-judge
2024-05-24T10:10:15Z
alcueca marked the issue as satisfactory
🌟 Selected for report: Aymen0909
Also found by: 0x73696d616f, GoatedAudits, LessDupes, crypticdefense, eeshenggoh, gjaldon, gumgumzum, pauliax, tapir
257.3101 USDC - $257.31
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L265-L324 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L134-L145
The fillERC20withdrawBuffer()
function in the Deposit Queue can only be called by the Restake Manager.
function fillERC20withdrawBuffer( address _asset, uint256 _amount ) external nonReentrant onlyRestakeManager { // ... snip ... }
However, the Operator Delegator calls fillERC20withdrawBuffer()
when it completes a queued withdrawal.
function completeQueuedWithdrawal( // ... snip ... for (uint256 i; i < tokens.length; ) { // ... snip ... // check if token is not Native ETH if (address(tokens[i]) != IS_NATIVE) { // ... snip ... if (bufferToFill > 0) { // ... snip .. // fill Withdraw Buffer via depositQueue restakeManager.depositQueue().fillERC20withdrawBuffer( address(tokens[i]), bufferToFill ); } // ... snip .. }
This call to fillERC20withdrawBuffer()
will revert because only the Restake Manager can call it. This leads to completeQueuedWithdrawal()
to revert constantly.
Once the withdraw buffer for an ERC20 collateral token needs filling, queued withdrawals can no longer be completed. All users can no longer withdraw that collateral token until the withdraw buffer is refilled. The only way to fill the withdraw buffer is through deposits of the collateral token. However, since there is this issue with withdrawals, there is a great disincentive for users to deposit their ERC20s into the Restake Manager. This can lead to the deposited ERC20 tokens being locked in the protocol indefinitely unless a change to the code is made.
Below are the steps to reproduce the issue:
Foundry, Manual Review
Consider removing the access restriction in completeQueuedWithdrawal()
or modify it to allow calls from all Operator Delegators.
Access Control
#0 - c4-judge
2024-05-20T04:07:49Z
alcueca marked the issue as satisfactory
🌟 Selected for report: pauliax
Also found by: 0rpse, 0x73696d616f, 0xAadi, 0xCiphky, 0xPwned, 0xhacksmithh, 0xnev, 0xnightfall, 0xordersol, 14si2o_Flint, Aamir, Aymen0909, BiasedMerc, DanielArmstrong, Fassi_Security, FastChecker, GoatedAudits, Greed, KupiaSec, LessDupes, Maroutis, NentoR, OMEN, SBSecurity, Stefanov, TheFabled, adam-idarrha, ak1, aman, araj, aslanbek, b0g0, baz1ka, bigtone, blutorque, carlitox477, carrotsmuggler, crypticdefense, eeshenggoh, fyamf, gesha17, gjaldon, grearlake, guhu95, honey-k12, hunter_w3b, ilchovski, josephdara, kinda_very_good, lanrebayode77, m_Rassska, maxim371, mt030d, mussucal, oakcobalt, p0wd3r, peanuts, rbserver, shui, siguint, t0x1c, tapir, twcctop, ustazz, xg, zhaojohnson, zigtur, zzykxx
0.0026 USDC - $0.00
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L316-L321 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L500-L504 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L594-L609 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L652 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L217-L224 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RateProvider/BalancerRateProvider.sol#L31-L40
The Restake Manager calculates the total TVL in the protocol including the Withdrawal Queue's TVL.
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) { // ... snip ... for (uint256 i = 0; i < odLength; ) { // ... snip ... for (uint256 j = 0; j < tokenLength; ) { // ... snip ... if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue) ); } // ... snip ...
There is a mistake, however, in the token being passed to lookupTokenValue()
when computing for the Withdrawal Queue's TVL. It uses the index i
instead of the index j
, which means the token value being looked up every time is of the same token. This leads to inaccurate TVL calculations for the Withdrawal Queue.
For example, there are 3 collateral tokens wbETH, mETH and stETH:
collateralTokens
array.wbETH price * wbETH balance + wbETH price * mETH balance + wbETH price * stETH
.Users observing this behavior can exploit it to mint more ezETH tokens and withdraw more collateral tokens at the expense of other users. This is essentially stealing other users' deposits. This can be done for the life of the protocol unless the issue is fixed.
calculateTVLs()
is used in the deposit and withdraw functions to compute for the mint and redeem amounts.
The TVL is inversely correlated with the mint amounts and it is correlated with the withdrawn amounts. To exploit this issue, consider the following scenario:
Manual Review, Foundry
The fix is changing the index i
to index j
when doing the lookup for the token's collateral value.
if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( - collateralTokens[i], + collateralTokens[j] collateralTokens[j].balanceOf(withdrawQueue) ); }
Loop
#0 - c4-judge
2024-05-16T10:26:08Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-16T10:38:47Z
alcueca changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-05-16T10:39:08Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-20T04:26:26Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-23T13:47:21Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: Sathish9098
Also found by: 0x73696d616f, 0xCiphky, 0xmystery, ABAIKUNANBAEV, Bauchibred, BiasedMerc, Fassi_Security, FastChecker, GalloDaSballo, GoatedAudits, K42, KupiaSec, LessDupes, Limbooo, ReadyPlayer2, Rhaydden, SBSecurity, Sabit, Sparrow, WildSniper, ZanyBonzy, adam-idarrha, adeolu, araj, aslanbek, atoko, b0g0, carlitox477, crypticdefense, fyamf, gesha17, gjaldon, grearlake, gumgumzum, hihen, honey-k12, hunter_w3b, inzinko, jesjupyter, jokr, kennedy1030, kind0dev, kinda_very_good, ladboy233, lanrebayode77, oakcobalt, oualidpro, pauliax, rbserver, t0x1c, tapir, underdog, xg, zzykxx
0 USDC - $0.00
https://github.com/Layr-Labs/eigenlayer-contracts/blob/dev/src/contracts/pods/DelayedWithdrawalRouter.sol#L100-L105 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L501-L525 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L481-L493 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L470-L474
The Restake Admin is refunded gas for running some critical operations in the Operator Delegator such as:
Gas is refunded to the ETH Restake Admin only when the Operator Delegator receives ETH from a non-Eigenpod address in a transaction that is started by the ETH Restake Admin.
receive() external payable nonReentrant { // check if sender contract is EigenPod. forward full withdrawal eth received if (msg.sender == address(eigenPod)) { restakeManager.depositQueue().forwardFullWithdrawalETH{ value: msg.value }(); } else { // considered as protocol reward uint256 gasRefunded = 0; uint256 remainingAmount = msg.value; if (adminGasSpentInWei[tx.origin] > 0) { gasRefunded = _refundGas(); // update the remaining amount remainingAmount -= gasRefunded; // If no funds left, return if (remainingAmount == 0) { return; } } // Forward remaining balance to the deposit queue address destination = address(restakeManager.depositQueue()); (bool success, ) = destination.call{ value: remainingAmount }(""); if (!success) revert TransferFailed(); emit RewardsForwarded(destination, remainingAmount); } }
The only ETH Restake Admin function that transfers ETH directly to the Operator Delegator is completeQueuedWithdrawal
. All the rest either do not transfer ETH or transfer ETH to Eigenlayer's Delayed Withdrawal Router. To get a refund, the ETH Restake Admin must claim the delayed withdrawal from the Delayed Withdrawal Router.
When refunding gas, the gas refund is taken from the contract's balance.
function _refundGas() internal returns (uint256) { uint256 gasRefund = address(this).balance >= adminGasSpentInWei[tx.origin] ? adminGasSpentInWei[tx.origin] : address(this).balance; bool success = payable(tx.origin).send(gasRefund); if (!success) revert TransferFailed(); // reset gas spent by admin adminGasSpentInWei[tx.origin] -= gasRefund; emit GasRefunded(tx.origin, gasRefund); return gasRefund; }
However, in the receive
callback, the amount of gas refunded is expected to be less than or equal to the ETH value received or it will revert due to underflow.
uint256 remainingAmount = msg.value; if (adminGasSpentInWei[tx.origin] > 0) { gasRefunded = _refundGas(); // update the remaining amount remainingAmount -= gasRefunded;
As long as the Operator Delegator has ETH balance lying around, the Restake Admin can only get refunded gas if the delayed withdrawals the Restake Admin claims are more than or equal to the gas refund. Otherwise, the Restake Admin transaction will revert due to the underflow.
Since claimDelayedWithdrawals
is a permissionless function, anyone can call it to prevent the Restake Admin from getting their gas refunds. It is also generally good to claim delayed withdrawals gradually to avoid spikes in TVL that could lead to MEV. This makes it more likely the Restake Admin is not refunded their gas expenses for running the critical admin operations in Operator Delegator.
This issue can naturally occur and anyone can trigger it. The Restake Admin can be prevented from getting their gas refunds and the Restake Admin claiming delayed withdrawals can be forced to revert.
Consider the following scenario:
Manual Review, Foundry
Consider applying the following change in the receive
callback.
-uint256 remainingAmount = msg.value; +uint256 remainingAmount = msg.value; // this is origin because Eigenlayer functions will send ETH to this contract. if (adminGasSpentInWei[tx.origin] > 0) {
Refunding gas when receiving ETH from the Eigenpod may also be worth it.
Other
#0 - C4-Staff
2024-05-15T16:22:44Z
CloudEllie marked the issue as duplicate of #149
#1 - c4-judge
2024-05-16T09:49:03Z
alcueca marked the issue as not a duplicate
#2 - jatinj615
2024-05-21T13:11:40Z
Yes, the remaining amount should be address(this).balance
instead of msg.value
.
#3 - alcueca
2024-05-23T09:44:09Z
I believe the sponsor meant that the remaining amount should be msg.value
instead of address(this).balance
.
Same impact as #148, but the root cause is different.
#4 - c4-judge
2024-05-23T09:44:14Z
alcueca marked the issue as satisfactory
#5 - c4-judge
2024-05-24T10:20:47Z
alcueca marked the issue as selected for report
#6 - 0xEVom
2024-05-26T20:16:04Z
Dear @alcueca,
The OperatorDelegator
contract is not supposed to hold any balance (as it is always forwarded in receive()
), so the mitigation suggested here doesn't change things and the finding does indeed share the root cause with #148. I also would like to add that I agree with the arguments presented by @s1n1st3r0 under #148's duplicate in favor of these findings being downgraded to QA.
#7 - c4-judge
2024-05-27T05:48:06Z
alcueca marked the issue as not selected for report
#8 - c4-judge
2024-05-27T05:48:10Z
alcueca changed the severity to QA (Quality Assurance)
#9 - c4-judge
2024-05-27T05:48:15Z
alcueca marked the issue as grade-b
#10 - alcueca
2024-05-27T10:56:14Z
But hold on, if the OD is not supposed to hold any funds, and its ETH balance will always be zero, nothing will ever be refunded because address(this).balance < adminGasSpentInWei[tx.origin]
and the gas refund becomes address(this).balance
.
So the transactions don't revert when the OD doesn't hold ETH, but nothing is refunded either. It makes no sense.
And it is true then that if an attacker forces the OD to hold ETH, and those holdings are greater than msg.value
, and adminGasSpentInWei[tx.origin]
is also greater than msg.value
, then receive()
will revert.
#11 - alcueca
2024-05-27T10:59:39Z
True, however, that the only reasonable use of receive()
is for EigenPods, which are not affected.
Still, you have a bunch of code here that is broken, a feature that should work according to comments and doesn't.
#12 - c4-judge
2024-05-27T10:59:52Z
This previously downgraded issue has been upgraded by alcueca
#13 - c4-judge
2024-05-27T11:01:40Z
alcueca marked the issue as primary issue
#14 - 0xEVom
2024-05-27T19:29:52Z
@alcueca the balance will be non-zero for the duration of the transaction.
And it is true then that if an attacker forces the OD to hold ETH, and those holdings are greater than
msg.value
, andadminGasSpentInWei[tx.origin]
is also greater thanmsg.value
, thenreceive()
will revert.
This is technically true, but the impact is minimal as the rewards being claimed need to be smaller than adminGasSpentInWei[tx.origin]
. This is unlikely to happen in practice and can be mitigated by simply waiting until the accumulated rewards exceed adminGasSpentInWei
.
Additionally, in that scenario the fees will be refunded from the force sent ETH, and the protocol will obtain more rewards than otherwise, so this attack only benefits the protocol.
The finding claims two impacts:
adminGasSpentInWei[tx.origin]
and will benefit the protocol.Based on this, I don't believe the finding merits Medium severity.
And some additional context based on your comments:
I believe the sponsor meant that the remaining amount should be
msg.value
instead ofaddress(this).balance
.
Using either address(this).balance
or msg.value
consistently across _refundGas
and receive()
will mitigate the issue, but using address(this).balance
will also sweep any funds force sent to the contract. Leaving the issue unmitigated will have the impact described above.
the only reasonable use of
receive()
is for EigenPods, which are not affected.
I believe rewards are sent from the DelayedWithdrawalRouter
to the OperatorDelagator
via claimDelayedWithdrawals()
, as explained in the finding.
#15 - alcueca
2024-05-28T04:54:14Z
I'm going to relent and downgrade this to QA. Even if the code is broken, the impact really is minimal as this is a function that the sponsor considers very low priority, supported by the natspec.
#16 - c4-judge
2024-05-28T04:54:25Z
alcueca changed the severity to QA (Quality Assurance)