Closed c4-bot-9 closed 3 months ago
Unfortunately, findings that were overlooked during the parent review are out of scope, and wardens are encouraged to forward them to the sponsor externally, for example through a bug bounty.
From the Guidelines for Mitigation Reviews, What Not To Include section:
Vulnerabilities submitted during the preceding audit should not be submitted as new HM issues during the mitigation review. They will be considered out of scope and ineligible for awards. If a warden feels an issue from the preceding audit was overlooked or undervalued, it is recommended to look into submitting it to the sponsor via other channels (e.g., a bug bounty program).
alcueca marked the issue as unsatisfactory: Out of scope
Lines of code
https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L323 https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L399 https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L502
Vulnerability details
Proof of Concept
Now going through the issue, it indeed is valid and would lead to heavy leakage of ezETH yield.
In the
OperatorDelegator
contract : https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol, thebaseGasAmountSpent
value is applied to all admin gas refunds recorded for methods like: queueWithdrawals(), completeQueuedWithdrawal(), verifyWithdrawalCredentials(), and verifyAndProcessWithdrawals(). However, the initial cost of transactions mostly depends on calldata size and is not the same for these methods/functions.Checking Etherscan, we can see that
baseGasAmountSpent
is set to 1,350,000 gas: https://etherscan.io/address/0xbAf5f3A05BD7Af6f3a0BBA207803bf77e2657c8F#readProxyContract#F2, which in our case would match the base cost ofverifyAndProcessWithdrawals
( for example see the gas profiling for the linked example transaction from the previous finding). This example transaction has 101,952 bytes of calldata and pays 1,330,000 gas in initial costs, which is short of just20,000
to match thebaseGasAmountSpent
. So if this periodic transaction is to properly be refunded, it must continue using the same value.However, issue with this implementation is that within the
queueWithdrawals
andcompleteQueuedWithdrawal
methods which will be called periodically to process withdrawals, they will have a much much lower initial gas cost. But protocol still have their gas expense being recorded using the same code with the largebaseGasAmountSpent
value, causing the admin gas refunds for these methods to be overestimated. The gasSpent will be inflated by almost 1.3M gas for each transaction.See where protocol records the expense for these calls:
queueWithdrawals()
here: https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L323 andcompleteQueuedWithdrawal()
here: https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L399And protocol still sets the same high gas cost for the not-so gas hefty call when verifying the the withdrawal credentials via
verifyWithdrawalCredentials()
, asidesqueueWithdrawals()
&completeQueuedWithdrawal()
.Impact
This bug case leads to refunds being over-refunded from the received rewards, which results in an unfair loss of yield for
ezETH
holders, since the refunds are deducted from the incoming rewards: https://github.com/Renzo-Protocol/Contracts/blob/ez-withdraw/contracts/Delegation/OperatorDelegator.sol#L625-L629The previous issue, provided an exquisite explanation on the amount of yield that could be lost, quote on quote: " Calculating the real base cost for queueWithdrawals:
21000 + countNonZeroBytesInCalldata * 16 + countZeroBytesInCalldata * 4
and adding 20K gas fornonReentrant
andonlyNativeEthRestakeAdmin
that are called beforegasleft()
(requiring two storage reads, two cold access address loads, and two non-zero storage writes foradminGasSpentInWei
and reentrancy status), we get with an input of4 + 5*32 + 5*32
bytes (selector + two arrays of 2 elements for each of the 3 supported tokens), only 324 bytes of calldata. With roughly 128 bytes of non-zero data (addresses, amounts, selector) it results in only 2,784 calldata gas cost. Together with 20K for modifiers and 21K for intrinsic cost, the correctbaseGasAmountSpent
should be 44,000 instead of the much larger 1,350,000.Similarly,
completeQueuedWithdrawal
also requires a differentbaseGasAmountSpent
, which is also much lower than currently set. Its calldata length is roughly 676 bytes, which is also two orders of magnitude shorter than the cost forverifyAndProcessWithdrawals
(at around 100,000 bytes).This is also true for
verifyWithdrawalCredentials
, but it is unlikely to be called periodically, so it is not as important. "As hinted, even the callDataLength of the
verifyWithdrawalCredentials()
function is way way lower than that of theverifyAndProcessWithdrawals()
that has ~ 100,000 bytes as shown in the example tx considering the processing of these withdrawals attached, theverifyWithdrawalCredentials()
then requires a lower value ofbaseGasAmountSpent
which then means that in our case, every instance of queryingverifyWithdrawalCredentials()
heavily over inflates the refunded gas and directly reflects on lesser yield forezETH
holders.All the above showcase how this would result in a loss of yield for the ezETH holders.
Tool used
Recommended Mitigation Steps
As hinted in the previous finding, consider removing the use of
baseGasAmountSpent
in the calculation forverifyWithdrawalCredentials()
, but keep it for theverifyAndProcessWithdrawals
that has expensive intrinsic costs. So introduce a nominal gas cost andAssessed type
Context