Finding Real Vulnerabilities with the Renzo-Fuzzing Repo
Using the renzo-fuzzing repo to reproduce vulnerabilities from the Renzo audit report from code4rena
Introduction
Renzo is a protocol that integrates with EigenLayer to simplify the process of restaking ETH (natively or via liquid staking tokens) in EigenLayer, abstracting away complexities related to depositing/withdrawing and handling allocations to Operators of the Staker’s restaked ETH.
To simplify the process of fuzzing system invariants for protocols integrating with EigenLayer, we recently created the eigenlayer-fuzzing repo. This repo provides all the system setup and needed components for effective invariant testing.
As a proof-of-concept we then used the eigenlayer-fuzzing repo to scaffold a test harness for the renzo-fuzzing repo which allows anyone to fuzz properties on the Renzo system.
Now with the release of the audit report from Renzo’s Code4rena competition we’ll demonstrate how three high severity vulnerabilities found during the competition (related to the EigenLayer integration) could’ve been identified with properties defined in the renzo-fuzzing suite.
Findings
All the findings discussed here have to do with improper handling of the integration with EigenLayer on the Renzo side. It should be noted that none of these vulnerabilities were actually discovered using the renzo-fuzzing suite as it was not completed at the time of the competition, but we hope to highlight the power of a good fuzzing suite in finding high severity vulnerabilities using simple properties that one may define on such a system, now that there is a way to easily evaluate them.
H02 - Incorrect calculation of queued withdrawals can deflate TVL and increase ezETH mint rate
This vulnerability highlights the fact that when calculating the amount of ezETH liquid restaking token (native to Renzo) that gets minted to a user on depositing native ETH or a liquid staking token (LST) the calculation in OperatorDelegator::getTokenBalanceFromStrategy
doesn’t correctly account for tokens queued for withdrawal from EigenLayer. This is a consequence of the fact that EigenLayer doesn’t allow instantaneous withdrawals as a security feature and instead requires users to first queue a withdrawal, then after a 7-day escrow period they can complete their withdrawal.
This results in users that mint ezETH (by depositing into the Renzo system) while a withdrawal from EigenLayer has been queued receiving more ezETH than they should due to an incorrectly inflated exchange rate and similarly those that withdraw (by burning their ezETH during the same period) receiving a more favorable exchange rate between ETH/ezETH or LST/ezETH (where for Renzo the currently supported LSTs are stETH and wbETH).
To catch this vulnerability we can expand on the existing scaffolding provided by the renzo-fuzzing repo and define a new target function contract for functions in the OperatorDelegator
contract which Renzo admins use to withdraw staked ETH from EigenLayer.
To our this OperatorDelegatorTargets
we would typically add handlers for all public non-view functions in the OperatorDelegator
contract to test full functionality, but to catch this vulnerability we can define a single target function: operatorDelegator_queueWithdrawals to which we can add an assertion that the Renzo system’s TVL shouldn’t change after a withdrawal is queued.
When we execute an Echidna run with the defined assertion, we get a counterexample that violates our assertion, indicating that our property doesn’t hold.
If the job is run using the Recon runner, we automatically get a Foundry unit test that allows us to locally reproduce the assertion violation and would allow us to debug the source of the issue if its cause wasn’t already known.
H03 - ETH withdrawals from EigenLayer always fail due to OperatorDelegator’s nonReentrant receive()
This vulnerability points out that the implementation of a nonReentrant
modifier in the OperatorDelegator::completeQueuedWithdrawal
function prevents admins from ever successfully calling it because a call made by the function to the EigenLayer system eventually calls back into the OperatorDelegator
’s receive
function which uses the same nonReentrant
modifier that’s already locked from the initial call. As a result the function always reverts and the funds are stuck in EigenLayer with no way of being recovered.
While this vulnerability could’ve been discovered with a simple unit test, it could have also been discovered with a property that states that if a withdrawal has been successfully queued, a call to completeQueuedWithdrawal
must always succeed for it.
To implement an assertion for this property we need to add a handler for the completeQueuedWithdrawal
function to the OperatorDelegatorTargets
contract that we defined for the previous vulnerability.
In the operatorDelegator_queueWithdrawals
function we also need to define the following ghost variable to simplify passing in valid withdrawals to the OperatorDelegator::completeQueuedWithdrawal
function because queued withdrawals only have a byte array of their hashed data stored in Renzo but the function receives a Withdrawal
struct as a parameter.
This eigenLayerWithdrawalRequestsGhost
also serves as a form of clamping when used in the operatorDelegator_completeQueuedWithdrawal
function preventing it from unnecessarily wasting fuzz runs by evaluating all possible input values for each member of the Withdrawal
struct.
Further, we can use this as a precondition for our assertion because we know that for a Withdrawal
to end up in the eigenLayerWithdrawalRequestsGhost
it must have been successfully queued and if it’s been successfully queued, when an admin calls completeQueuedWithdrawal
after the 7-day withdrawal period, the function should never revert.
Using the try/catch block we can assert that a revert in the call implies that the admin wasn’t able to complete the withdrawal process.
The vulnerability description in the report didn’t include a POC because it would require too many steps, but the output of our Echidna job by the Recon runner gives us a simple POC that we can use for locally reproducing and debugging the issue:
H05 - Withdrawals of rebasing tokens can lead to insolvency and unfair distribution of protocol reserves
This vulnerability is related to the fact that slashing events on EigenLayer aren’t properly accounted for in the logic handling user withdrawals from Renzo.
When a user initiates a withdrawal from Renzo via WithdrawQueue::withdraw
, the amount of _assetOut
that they receive for an amount of ezETH that they’re burning is based on the current token value returned from renzoOracle::lookupTokenAmountFromValue
.
However, because withdrawals from EigenLayer require a 7-day escrow period before being claimable, the token value can change in the time between when the withdrawal is queued and when it’s claimed.
In particular, if there’s a slashing event on the EigenLayer side for the token being withdrawn, this results in the system’s balance of the token decreasing and therefore the user’s share of the balance should proportionally decrease, but since their withdrawal request was made before the change in balance and their shares aren’t updated after the slashing, the user can claim more than their fair share of the token balance.
The renzo-fuzzing repo comes equipped out of the box with support for handling slashing events on EigenLayer via the EigenLayerSystem contract so setting up this property for testing will be simpler. Additionally, the RestakeManagerTargets exposes all the functions we need to depositing and updating price in the system that the fuzzer will need to call to enable a valid slashing.
To implement a property that a user can’t withdraw more than their fair share, we therefore just need to add a WithdrawQueueTargets
contract with withdrawQueueTargets_withdraw
and withdrawQueueTargets_claim
functions to allow the fuzzer to queue and claim withdrawal requests as a user would.
In the withdrawQueueTargets_claim
function we define an assertion that checks the amount that a user can redeem before they claim a request (taken from the amountToRedeem
set when they queued a withdrawal) and after (calculated using the current totalTVL
which changes in a slashing event):
From the Recon job page for the run with this assertion defined we see that we get another violation, indicating that the user can in fact claim more than their fair share of staked ETH if there is a slashing event:
Wrapping Up
We’ve shown above how the renzo-fuzzing repo can be extended to test properties defined for the Renzo system with minimal additions. Similarly, for any protocol integrating with EigenLayer you can extend the eigenlayer-fuzzing repo to have access to the entire EigenLayer system, with built-in functionality for handling externalities such as slashing and use the Recon builder to create a testing harness that allows you to test properties specific to the integrating protocol.