GithubHelp home page GithubHelp logo

2024-04-renzo-findings's Introduction

Renzo Audit

Audit findings are submitted to this repo.

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.

Note that when the repo is public, after all issues are mitigated, your comments will be publicly visible; they may also be included in your C4 audit report.


Review phase

Sponsors have three critical tasks in the audit process: Reviewing the two lists of curated issues, and once you have mitigated your findings, sharing those mitigations.

  1. Respond to curated High- and Medium-risk submissions ↓
  2. Respond to curated Low/Non-critical submissions and Gas optimizations ↓
  3. Share your mitigation of findings (optional) ↓

Note: It’s important to be sure to only review issues from the curated lists. There are two lists of curated issues to review, which filter out duplicate issues that don't require your attention.


     

Types of findings

(expand to read more)

High or Medium risk findings

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

QA reports

Any warden submissions in the QA category are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.

1. Respond to curated High- and Medium-risk submissions

This curated list will shorten as you work. View the original, longer list →

For each curated High or Medium risk finding, please:

1. Label as one of the following:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue. Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Note: Adding or changing labels other than those in this list will be automatically reverted by our bot, which will note the change in a comment on the issue.


2. Respond to curated Low/Non-critical submissions

This curated list will shorten as you work. View the original, longer list →

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • Add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once Step 1 and 2 are complete

When you have finished labeling and responding to findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.


3. Share your mitigation of findings (Optional)

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-04-renzo-findings's People

Contributors

howlbot-integration[bot] avatar c4-judge avatar c4-bot-5 avatar c4-bot-6 avatar c4-bot-1 avatar c4-bot-4 avatar c4-bot-9 avatar thebrittfactor avatar c4-bot-3 avatar c4-bot-7 avatar c4-bot-8 avatar c4-bot-2 avatar cloudellie avatar c4-bot-10 avatar jacobheun avatar code4rena-id[bot] avatar

Stargazers

maryam avatar  avatar wyq199 avatar decentbug avatar  avatar manijeh avatar

Watchers

Ashok avatar  avatar Bronze Pickaxe avatar

2024-04-renzo-findings's Issues

Protocol uses the `STETH/ETH` feed leading to frequent ingestion of non-fresh prices and allowance of arbitrage

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Oracle/RenzoOracle.sol#L54-L66
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/README.md#L325-L327
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L217-L224

Vulnerability details

Proof of Concept

From the contest's README, we can see this https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/README.md#L299-L301

| Question                   | Answer              |
| -------------------------- | ------------------- |
| ERC20 used by the protocol | ezETH, stETH, wBETH |

Now going to this message from the discord right at the start of the contest the below has also been indicated:

🚨 Wardens, please be advised that all the assets are priced in ETH denominations. i.e stETH/ETH, wbETH/ETH, ezETH/ETH.

The above two sources hint that the protocol is going to use the STETH/ETH chainlink oracle, this can then be confirmed by the snippet below and the check applied when setting the addresses used for the oracle lookup, i.e https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Oracle/RenzoOracle.sol#L54-L66

    function setOracleAddress(
        IERC20 _token,
        AggregatorV3Interface _oracleAddress
    ) external nonReentrant onlyOracleAdmin {
        if (address(_token) == address(0x0)) revert InvalidZeroInput();

        // Verify that the pricing of the oracle is 18 decimals - pricing calculations will be off otherwise
        //@audit
        if (_oracleAddress.decimals() != 18)
            revert InvalidTokenDecimals(18, _oracleAddress.decimals());

        tokenOracleLookup[_token] = _oracleAddress;
        emit OracleAddressUpdated(_token, _oracleAddress);
    }

Navigating to the official Chainlink site for the price feed addresses for assets we can see that STETH has two feeds provided by Chainlink, one against ETH and the other against USD.

Evidently, the only feed with the 18 decimals is the STETH/ETH feed so this is what protocol ends up configuring internally as the oracle for STETH, however this would lead to protocol to ingest stale data and allow for arbitrage opportunities, considering the disparity between the STETH/ETH and the STETH/USD feed.

The STETH/ETH feed has a heartbeat of a whooping 86400 seconds, however the STETH/USD feed has got it's heartbeat for 3600 seconds, this means that the latter is going to be updated 24 times more often than the former ( STETH/ETH ), this then causes protocol's overall asset pricing in most cases to be lagging and flawed as not enough fresh price is being used.

Keep in mind that a call to the oracle is always made whenever there is a deposit or redemption to be made, this can be hinted from this instance in the withdrawQueue.sol , here we can see that to calls are being made to get the price of the asset from RenzoOracle, one directly while querying the amount to redid, and the other indirectly while attempting to calculate the protocol's current TVL which also makes a call to the RenzoOracle to get the token value present with the operator delegators and also in the withdrawal queue.

Other instances where the price of the collateral token being supported which is potentially STETH can be pinpointed by this search command

Now since the price from the Chainlink's STETH/ETH feed is not going to be as fresh as that returned by the STETH/USD feed this means that all calculations that depend directly on the TVL/ Asset's value is potentially flawed, note that this covers the main window under the requested bug windows/attack ideas since the integrity on the TVL calculations (ezETH Pricing) is now going to be flawed, i.e user are now going to mint & withdraw at non-fresh prices considering the stETH is a core integrated token and it's going to hold quite a high percentage from the overall TVL holdings of the ODs and protocol as a whole

Impact

As hinted in the Proof of Concept, this bug window effectively breaks the TVL calculations & essentially the ezETH pricing logic since the price difference now allows for heavy arbitrage to be allowed.

Would be key to note that this has also been stated in the contest's README, https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/README.md#L325-L327

## Main invariants

- ezETH should be minted or redeemed based on current supply and TVL.

But as shown in this report, this invariant would be broken as the " current supply and TVL" calculated would be non-fresh and as such the ezETH would actually be minted in multiple instances not on the grounds of the current supply and TVL.

Tool used

Recommended Mitigation Steps

Consider using the STETH/USD feed instead, this can also be easily integrated, you just need to get the price from the STETH/USD feed and normalise it with the price from ETH/USD feed this way the final price would be in the denominations of STETH/ETH, but in this case we now can assure that the most fresh price would be used since both the STETH/USD & ETH/USD feed have a heartbeat of 3600 seconds.

Assessed type

Oracle

No zero amount check when depositing to `operatorDelegator` during the `RestakeManager.deposit()` function.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L491-L576

Vulnerability details

Impact

No zero amount check could result in unfair reversal of the transaction.

Proof of Concept

At L562 of the RestakeManager.deposit() function, if _amount = 0 then it will be reverted. It is quite possible for the _amount to become 0. In fact, at the begining of the RestakeManager.deposit() function, the _amount is not 0. However, during filling buffer, the _amount could become 0 at L549. Then, the whole transaction will be reverted.

    function deposit(
        IERC20 _collateralToken,
        uint256 _amount,
        uint256 _referralId
    ) public nonReentrant notPaused {
        [...]

        // Check the withdraw buffer and fill if below buffer target
        uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit(
            address(_collateralToken)
        );
        if (bufferToFill > 0) {
            bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill;
            // update amount to send to the operator Delegator
549         _amount -= bufferToFill;

            // safe Approve for depositQueue
            _collateralToken.safeApprove(address(depositQueue), bufferToFill);

            // fill Withdraw Buffer via depositQueue
            depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill);
        }

        // Approve the tokens to the operator delegator
        _collateralToken.safeApprove(address(operatorDelegator), _amount);

        // Call deposit on the operator delegator
562     operatorDelegator.deposit(_collateralToken, _amount);

        [...]
    }

Tools Used

Manual review

Recommended Mitigation Steps

There should be a zero amount check before depositting to operatorDelegator.

    function deposit(
        IERC20 _collateralToken,
        uint256 _amount,
        uint256 _referralId
    ) public nonReentrant notPaused {
        [...]

        // Call deposit on the operator delegator
+       if(_amount > 0) {
562         operatorDelegator.deposit(_collateralToken, _amount);
+       }

        [...]
    }

Assessed type

DoS

Attacker can front-running deposit in L2s

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L367-#L391

Vulnerability details

Vulnerability details

In xRenzoDeposit contract, function _trade() is used to handle swap token in connext when deposit token:

function _trade(uint256 _amountIn, uint256 _deadline) internal returns (uint256) {
.  .  .  .  .
    uint256 minOut = 0;   // <---

    // Swap the tokens
    uint256 amountNextWETH = connext.swapExact(
        swapKey,
        _amountIn,
        address(depositToken),
        address(collateralToken),
        minOut,
        _deadline
    );

minOut variable is set to 0, allow attacker to front-running user in L2s that can front-running other user like in BSC to make profit.

Impact

User can be front-run by MEV bots to gain profit.

Tools Used

Manual review

Recommended Mitigation Steps

Allow user to submit slippage when deposit token in L2s.

Assessed type

Other

`ezETH`'s minting rate can be broken for consequent users to deposit due to it being vulnerable to the classic first depositor bug

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Oracle/RenzoOracle.sol#L124-L151

Vulnerability details

Proof of Concept

Take a look at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Oracle/RenzoOracle.sol#L124-L151

    function calculateMintAmount(
        uint256 _currentValueInProtocol,
        uint256 _newValueAdded,
        uint256 _existingEzETHSupply
    ) external pure returns (uint256) {
        //@audit
        // For first mint, just return the new value added.
        // Checking both current value and existing supply to guard against gaming the initial mint
        if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) {
            return _newValueAdded; // value is priced in base units, so divide by scale factor
        }

        // Calculate the percentage of value after the deposit
        uint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) /
            (_currentValueInProtocol + _newValueAdded);

        // Calculate the new supply
        uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) /
            (SCALE_FACTOR - inflationPercentaage);

        // Subtract the old supply from the new supply to get the amount to mint
        uint256 mintAmount = newEzETHSupply - _existingEzETHSupply;

        // Sanity check
        if (mintAmount == 0) revert InvalidTokenAmount();

        return mintAmount;
    }

This function gets eventually called whenever there is an attempt, to mint ezETH, issue however is the logic applied to the first minting attempt, as hinted by the @Audit tag, this function ends up returning the _newValueAdded as the the amount of token to be minted to the depositor the above comments around the function hint that protocol is trying to protect the initial mint but this attempt at protecting it still leaves it susceptible to the initial donation attack.

The general process of this attack is well-known, and a detailed explanation of this attack can be found here https://mixbytes.io/blog/overview-of-the-inflation-attack.

In short, in our case to kick-start the attack, the malicious user will just usually mint the smallest possible amount of ezETH and then donate significant assets (any of the accepted collateralToken or even native ETH) so as to inflate the TVL of assets present in protocol. Subsequently, it will cause a rounding error when other users deposit.

Impact

Malicious users can easily break ezETH's minting rate.

Keep in mind that RenzoOracle.calculateMintAmount() is called to get the amount to the depositor from both depositor wrappers, found RestakeManager.depositETH() & RestakeManager.deposit().

Recommended Mitigation Steps

Ensure that there is always a minimum number of ezETH to guard against inflation attack by minting a certain amount of shares to zero address (dead address) during contract deployment (similar to what has been implemented in Uniswap V2).

We could have a minimum amount of eZETH that can be minted, this way even if a user is to front run another honest user's attempt they would have to pass in a reasonable sum, alternatively integrate the non-possibility of deposits and withdrawals in the same block.

Assessed type

Context

Incorrect index at the calculation of `totalWithdrawalQueueValue` in `RestakeManager.calculateTVLs()`.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L274-L358

Vulnerability details

Impact

Incorrect calculation of totalWithdrawalQueueValue results in incorrect return value of the calculateTVLs() function, will impact the exchange rate of ezETH and result in incorrect actions throughout the entire protocol.

Proof of Concept

The totalWithdrawalQueueValue is added by the value of collateral tokens in WithdrawQueue contract by looping all collateral tokens. However as you can see at L318, at the j th loop, the first parameter of the function lookupTokenValue() is the i th collateral token, not the j th collateral token. As a result, totalWithdrawalQueueValue is differ from it should be, leading to incorrect return value of calculateTVLs() and incorrect action of the entire system.

    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
        [...]

                // record token value of withdraw queue
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
318                     collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }

        [...]
    }

Tools Used

Manual review

Recommended Mitigation Steps

The index i should be replaced to j.

    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
        [...]

                // record token value of withdraw queue
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
-                       collateralTokens[i],
+                       collateralTokens[j],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }

        [...]
    }

Assessed type

Context

If a collateral token gets removed, protocol's TVL logic automatically gets heavily flawed

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L274-L358
WithdrawQueue.withdraw()`](https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L217-L224
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/L1/xRenzoBridge.sol#L214-L215
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L594-L600

Vulnerability details

Proof of Concept

First going to the contest's outlined documentation we can see that the first invariant stated is :

ezETH should be minted or redeemed based on current supply and TVL.

Additionally the requested bug windows/attack ideas to focus on clearly hinted we check on the integrity on the TVL calculations (ezETH Pricing), i.e a user should not mint or withdraw at invalid prices.

Now Take a look at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L244-L264

    function removeCollateralToken(
        IERC20 _collateralTokenToRemove
    ) external onlyRestakeManagerAdmin {
        // Remove it from the list
        uint256 tokenLength = collateralTokens.length;
        for (uint256 i = 0; i < tokenLength; ) {
            if (address(collateralTokens[i]) == address(_collateralTokenToRemove)) {
                collateralTokens[i] = collateralTokens[collateralTokens.length - 1];
                collateralTokens.pop();
                emit CollateralTokenRemoved(_collateralTokenToRemove);
                return;
            }
            unchecked {
                ++i;
            }
        }

        // If the item was not found, throw an error
        revert NotFound();
    }

The restake manager is allowed to remove a collateral whenever, which pops it off the collateralTokens array, note that this function exists and is to be used, so we can consider this normal integration, however there are no checks that no operator or even the withdrawalQueue have a balance of this collateral token that's to be removed, and also this collateral gets completely sidestepped when calculating the OD and protocol's TVL, see how the TVL's are being calculated at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L274-L358

    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
            ///(...snip)
            uint256[] memory operatorValues = new uint256[](collateralTokens.length + 1);
            operatorDelegatorTokenTVLs[i] = operatorValues;

            // Iterate through the tokens and get the value of each
            uint256 tokenLength = collateralTokens.length;
            for (uint256 j = 0; j < tokenLength; ) {
                // Get the value of this token

                uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(
                    collateralTokens[j]
                );

                // Set the value in the array for this OD
                operatorValues[j] = renzoOracle.lookupTokenValue(
                    collateralTokens[j],
                    operatorBalance
                );

                // Add it to the total TVL for this OD
                operatorTVL += operatorValues[j];

                // record token value of withdraw queue
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }

                unchecked {
                    ++j;
                }
            }

            // Get the value of native ETH staked for the OD
            uint256 operatorEthBalance = operatorDelegators[i].getStakedETHBalance();

            // Save it to the array for the OD
            operatorValues[operatorValues.length - 1] = operatorEthBalance;

            // Add it to the total TVL for this OD
            operatorTVL += operatorEthBalance;

            // Add it to the total TVL for the protocol
            totalTVL += operatorTVL;

            // Save the TVL for this OD
            operatorDelegatorTVLs[i] = operatorTVL;

            // Set withdrawQueueTokenBalanceRecorded flag to true
            withdrawQueueTokenBalanceRecorded = true;

            unchecked {
                ++i;
            }
        }

        // Get the value of native ETH held in the deposit queue and add it to the total TVL
        totalTVL += address(depositQueue).balance;

        // Add native ETH help in withdraw Queue and totalWithdrawalQueueValue to totalTVL
        totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }

We can see that there is a need to route through all operators and their delegators to iterate to get the amount of each collateral token they own which is then added to their total TVL, also the amount present in the withdrawal queue for each collateral token is counted and added to the protocol's totalTVL being returned from the calculation , however since the way this collateralTokens are queried is dependent on the stored array, the collateral token that's been popped essentially wouldn't get iterated on, that's to say all the amount of asset protocol holds via the OD of the WithdrawalQueue is not going to be taken in this calculation which would mean that the amount of TVL returned by this calculation is going to be heavily deflated considering these balances just get sidestepped and are not added to the calculation

Impact

This easily faults any logic that directly/indirectly queries or needs the either the OD's or the protocol's totalTVL, to list out some noteworthy impacts:

Keep in mind that this function inherently gets called when there is a need to price the assets to be withdrawn or to get the rate from the BalancerRateProvider among other instances i.e:

        (, , uint256 totalTVL) = calculateTVLs();

        // Enforce TVL limit if set
        if (maxDepositTVL != 0 && totalTVL + msg.value > maxDepositTVL) {
            revert MaxTVLReached();
        }
  • One last subtle thing to note is that about how the operatorTVL is finalized, which is by accumalating it into the operatorDelegatorTVLs that gets returned, so now whenever there is a need to deposit, either via depositTokenRewardsFromProtocol() or deposit() the chooseOperatorDelegatorForDeposit()function is being called, and all this function does is to pick the OperatorDelegator with the TVL below the threshold, but this would return the wrong data since the operator delegator TVL has been deflated as it would assume the operator to be below the threshold whereas they should be above... lastly an operator that in real sense would be able to process a withdrawal would also not be chosen for the withdrawal in some cases via the checks in chooseOperatorDelegatorForWithdraw() leaving users assets to be stuck in protocol if say there are little number of operators and their deflated TVL is making this check revert before withdrawals are processed.

TLDR: Asides the already stated impacts, the invariant about using the current TVL/ correct ezETH pricing to process withdrawals or mint requests is also going to be broken.

Recommended Mitigation Steps

Consider reimplementing this logic and ensure that after removals of collateral tokens this don't immediately affect the accounting logic of protocol, i.e they shouldn't be dropped/sidestepped immediately from the TVL calculations and instead they should only be dropped after all parties no longer hold this previously supported collateral token.

Assessed type

Context

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Incorrect implementation of `OperatorDelegator.getTokenBalanceFromStrategy()` function.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L327-L335

Vulnerability details

Impact

The getTokenBalanceFromStrategy() function returns a small value than it should be, leading to falling down the exchange rate of ezETH, which is loss of funds to the ezETH holders.

Proof of Concept

Let's consider L329 of the getTokenBalanceFromStrategy() function. Since queuedShares is a mapping of token shares in the withdrawal queue, queuedShares[address(this)] is always 0. Actually, it should be queuedShares[address(token)]. As a result, the function returns only tokenStrategyMapping[token].userUnderlyingView(address(this)), not tokenStrategyMapping[token].sharesToUnderlyingView(queuedShares[address(token)]), even though there are some queued amounts of token. Consequently, this will impact the TVL and cause the exchange rate of ezETH to be lower than it should be, leading to incorrect actions of the entire protocol, such as a loss of funds for the ezETH holders.

    function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) {
        return
329         queuedShares[address(this)] == 0
                ? tokenStrategyMapping[token].userUnderlyingView(address(this))
                : tokenStrategyMapping[token].userUnderlyingView(address(this)) +
                    tokenStrategyMapping[token].sharesToUnderlyingView(
                        queuedShares[address(token)]
                    );
    }

Tools Used

Manual review

Recommended Mitigation Steps

At L329, address(this) should be replaced to address(token).

    function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) {
        return
-           queuedShares[address(this)] == 0
+           queuedShares[address(token)] == 0
                ? tokenStrategyMapping[token].userUnderlyingView(address(this))
                : tokenStrategyMapping[token].userUnderlyingView(address(this)) +
                    tokenStrategyMapping[token].sharesToUnderlyingView(
                        queuedShares[address(token)]
                    );
    }

Assessed type

Context

The protocol does not work when there is negative shares happen in a pod.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L338-L345

Vulnerability details

Impact

The protocol as a whole does not work when there happens negatives shares in one of operator delegators.

Proof of Concept

The OperatorDelegator contract contains a function getStakedETHBalance that returns the amount of ETH staked for the operator delegator:

function getStakedETHBalance() external view returns (uint256) {
    // accounts for current podOwner shares + stakedButNotVerified ETH + queued withdraw shares
    int256 podOwnerShares = eigenPodManager.podOwnerShares(address(this));
    return
        podOwnerShares < 0
            ? queuedShares[IS_NATIVE] + stakedButNotVerifiedEth - uint256(-podOwnerShares)
            : queuedShares[IS_NATIVE] + stakedButNotVerifiedEth + uint256(podOwnerShares);
}

It fetches shares staked in the EigenLayer pod by calling podOwnerShares function which returns int256 type.
This value can possibly be native in EigenLayer by slashing for example, that's why it returns the shares amount as int256.

However in the calculation above, when queuedShares[IS_NATIVE] + stakedButNotVerifiedEth is smaller than -podOwnerShares, the function reverts because of underflow.

This getStakedETHBalance function is used when calculating the TVL of the Renzo protocol, which means that whenever there happens a negative share in one operator delegator, the protocol stops working because TVL calculation reverts.

Tools Used

Manual Review

Recommended Mitigation Steps

getStakedETHBalance function has to be modified so that if podOwnerShares is bigger than queuedShares[IS_NATIVE] + stakedButNotVerifiedEth it should return zero.

Assessed type

DoS

Support only 18 decimal assets everywhere

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L93-L94
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L101-L122
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L220-L233

Vulnerability details

Impact

The Renzo protocol is supposed to work only with assets with 18 decimal points. This is enforced in RestakeManager and RenzoOracle:

        // Verify the token has 18 decimal precision - pricing calculations will be off otherwise
        if (IERC20Metadata(address(_newCollateralToken)).decimals() != 18)
            revert InvalidTokenDecimals(
                18,
                IERC20Metadata(address(_newCollateralToken)).decimals()
            );
        // Verify that the pricing of the oracle is 18 decimals - pricing calculations will be off otherwise
        if (_oracleAddress.decimals() != 18)
            revert InvalidTokenDecimals(18, _oracleAddress.decimals());

As you can see, the oracle does not enforce that the token itself has 18 decimals, only the oracle's decimals are checked.

The withdrawal queue is missing this enforcement entirely. When initializing and updating withdrawalBufferTarget any asset and value is accepted unless it is empty:

        for (uint256 i = 0; i < _withdrawalBufferTarget.length; ) {
            if (
                _withdrawalBufferTarget[i].asset == address(0) ||
                _withdrawalBufferTarget[i].bufferAmount == 0
            ) revert InvalidZeroInput();
            withdrawalBufferTarget[_withdrawalBufferTarget[i].asset] = _withdrawalBufferTarget[i]
                .bufferAmount;
            unchecked {
                ++i;
            }
        }
    /**
     * @notice  Updates the WithdrawBufferTarget for max withdraw available
     * @dev     Permissioned call (onlyWithdrawQueueAdmin)
     * @param   _newBufferTarget  new max buffer target available to withdraw
     */
    function updateWithdrawBufferTarget(
        TokenWithdrawBuffer[] calldata _newBufferTarget
    ) external onlyWithdrawQueueAdmin {
        if (_newBufferTarget.length == 0) revert InvalidZeroInput();
        for (uint256 i = 0; i < _newBufferTarget.length; ) {
            if (_newBufferTarget[i].asset == address(0) || _newBufferTarget[i].bufferAmount == 0)
                revert InvalidZeroInput();
            emit WithdrawBufferTargetUpdated(
                withdrawalBufferTarget[_newBufferTarget[i].asset],
                _newBufferTarget[i].bufferAmount
            );
            withdrawalBufferTarget[_newBufferTarget[i].asset] = _newBufferTarget[i].bufferAmount;
            unchecked {
                ++i;
            }
        }
    }

When initiating the withdrawal, users can choose the _assetOut. The value is then calculated with the help of Oracle:

        // update amount in claim asset, if claim asset is not ETH
        if (_assetOut != IS_NATIVE) {
            // Get ERC20 asset equivalent amount
            amountToRedeem = renzoOracle.lookupTokenAmountFromValue(
                IERC20(_assetOut),
                amountToRedeem
            );
        }

Oracle always scales the value by 10**18:

        // Price is times 10**18 ensure token amount is scaled
        return (_value * SCALE_FACTOR) / uint256(price);

So there might exist a situation where a token with other than 18 decimals has an oracle of 18 decimals. If this token is added to the withdrawable assets, the calculations will be off. Furthermore, the admin cannot remove the withdrawal asset once it is added. The protocol should enforce that this situation will not happen.

Proof of Concept

This is a hypothetical scenario, but let's say an admin decides to enable withdrawals in USDC. It has 8 but the price feed has 18 decimals (https://data.chain.link/feeds/ethereum/mainnet/usdc-eth).

In this case, the amountToRedeem will be incorrectly calculated, which is scaled by SCALE_FACTOR (10**18) with no adjustment for token decimals.

Tools Used

Manual review.

Recommended Mitigation Steps

  1. The Withdraw queue should require that withdrawalBufferTarget asset has 18 decimals.
  2. Renzo oracle should require that the token itself has 18 decimals.

Assessed type

Decimal

Mode network are not supported by both connext and CCIP, price cant be updated

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/Oracle/RenzoOracleL2.sol#L1

Vulnerability details

Vulnerability details

From contest page: L2-Specific-contracts deployed on: Base, Arbitrum, Linea, BSC, Mode, which mean L2 contracts will be deployed on Mode network
From CCIP docs and Connext docs, it can be seen that Mode network is not supported, which mean there is not way to get price in Mode network

Impact

All function in the contract that deployed on Mode network are non-functional

Tools Used

Manual review

Recommended Mitigation Steps

Since Mode network are not supported by both CCIP and Connext, deployment in Mode network should be paused until it is supported

Assessed type

Other

At `WithdrawQueue` contract, burning `ezETH` should be in the `claim()` function, not in the `withdraw()` function.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L206-L263
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L279-L312
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L274-L358

Vulnerability details

Impact

Assets that are included in queued withdrawals still participate in TVL, leading to incorrect exchange rate. Consequently, later withdrawers could take fewer assets, even none at all.

Proof of Concept

Let's consider following scenario:

  1. The price of stETH is 1. And following users deposit assets and receive ezETH.
    • Alice: 100 stETH, 100 ezETH,
    • Bob: 96 ETH, 96 ezETH,
    • Charlie: 4 ETH, 4 ezETH. Then the state is:
    • total supply of ezETH: 200,
    • TVL: 200,
    • price of ezETH: 1.
  2. Alice makes a transaction to withdraw stETH with her 100 ezETH, then she can claim 100 stETH after a delay. And now, the state doesn't change yet.
  3. The price of stETH increases to 1.1. Then the state becomes:
    • total supply of ezETH: 200,
    • TVL: 100 * 1.1 + 100 = 210,
    • price of ezETH: 210 / 200 = 1.05.
  4. Bob makes a transaction to withdraw ETH with his 96 ezETH, then he can claim 1.05 * 96 = 100 ETH after a delay.
  5. After the cooldown periods, Alice claims 100 stETH and Bob claims 100 ETH. Then the state becomes:
    • total supply of ezETH: 4,
    • TVL: 0.

Finally, Charlie can take nothing even though he has 4 ezETH.

This problem occurs because the burnning ezETH is at L299 of the claim() function, not in the withdraw function so that assets to be claimed still participate to TVL and impact the exchange rate.

Tools Used

Manual review

Recommended Mitigation Steps

Burnning ezETH should be in the withdraw function, not in the claim() function. And, RestakeManager.calculateTVLs() function should consider the requested withdrawal amounts.

    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
        [...]

        // transfer ezETH tokens to this address
        IERC20(address(ezETH)).safeTransferFrom(msg.sender, address(this), _amount);
+       ezETH.burn(address(this), _amount);

        [...]
    }
    function claim(uint256 withdrawRequestIndex) external nonReentrant {
        [...]

        // burn ezETH locked for withdraw request
-       ezETH.burn(address(this), _withdrawRequest.ezETHLocked);

        [...]
    }
    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
        [...]

                // record token value of withdraw queue
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                        collateralTokens[i],
-                       collateralTokens[j].balanceOf(withdrawQueue)
+                       IWithdrawQueue(withdrawQueue).getAvailableToWithdraw(address(collateralTokens[j]))
                    );
                }

        [...]

        // Add native ETH help in withdraw Queue and totalWithdrawalQueueValue to totalTVL
-       totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);
+       totalTVL += (
+                   IWithdrawQueue(withdrawQueue).getAvailableToWithdraw(IS_NATIVE) +
+                   totalWithdrawalQueueValue
+               );

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }

Assessed type

Context

First depositor can set absurdly high share price

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L121-L149

Vulnerability details

Impact

The first user can manipulate the protocol by deciding on the initial price share. A whale (e.g. Justin Sun) or a malicious actor with the help of a flash loan can brick the protocol.

This can be done by sending a large number of tokens (say X) directly to the contract, e.g. Withdraw queue, because contract balances are included in TVL. Then minting a 1 share on this huge TVL will make subsequent users practically impossible to join. The next user will need at least X+1 tokens to get at least 1 share because there is a protection that the user should get at least something in return:

        // Sanity check
        if (mintAmount == 0) revert InvalidTokenAmount();

Proof of Concept

Send 1k ETH directly to the contract. Then deposit 1 wei. This will return 1 share token (ezETH) because calculateMintAmount returns _newValueAdded when _existingEzETHSupply == 0.

The next user needs to deposit at least 1k + 1 wei ETH to get 1 share. The bigger the initial amount the more practically inaccessible the protocol becomes.

In the Tools Used section I've provided a contract and values to test this.

Tools Used

Remix IDE and a simplified version of the contract to calculate mint values:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.19;

contract RenzoOracle {

    /// @dev Error when calculating token amounts is invalid
    error InvalidTokenAmount();

    constructor() { }

     /// @dev Given amount of current protocol value, new value being added, and supply of ezETH, determine amount to mint
    /// Values should be denominated in the same underlying currency with the same decimal precision
    function calculateMintAmount(
        uint256 _currentValueInProtocol,
        uint256 _newValueAdded,
        uint256 _existingEzETHSupply
    ) external pure returns (uint256) {
        // For first mint, just return the new value added.
        // Checking both current value and existing supply to guard against gaming the initial mint
        if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) {
            return _newValueAdded; // value is priced in base units, so divide by scale factor
        }

        // Calculate the percentage of value after the deposit
        uint256 inflationPercentaage = (10 ** 18 * _newValueAdded) /
            (_currentValueInProtocol + _newValueAdded);

        // Calculate the new supply
        uint256 newEzETHSupply = (_existingEzETHSupply * 10 ** 18) /
            (10 ** 18 - inflationPercentaage);

        // Subtract the old supply from the new supply to get the amount to mint
        uint256 mintAmount = newEzETHSupply - _existingEzETHSupply;

        // Sanity check
        if (mintAmount == 0) revert InvalidTokenAmount();

        return mintAmount;
    }
}

Tested with these values:

  1. calculateMintAmount 1000000000000000000000 1 0 -> 1
  2. calculateMintAmount 1000000000000000000001 1000000000000000000001 1 -> 1

Recommended Mitigation Steps

The protocol or the owner should be the first depositor and set a reasonable initial price. Initial shares should be burned.

Assessed type

DoS

When depositing assets to an operator delegator, it should check if the asset is supported in the operator delegator

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L362-L393

Vulnerability details

Impact

The protocol stops working when chosen operator delegator does not support a specific asset

Proof of Concept

When depositing assets through the RestakeManager contract, it chooses the first operator delegator which is available based on TVL allocation limit. However, it does not check if the chosen operator delegator supports the specific asset.

For example, there can be a following scenario:

  • There are two operator delegator instances, the first one has allocation of 60% TVL while the second one has 40% TVL allocation
  • The second operator delegator is not chosen for deposit until 60% of TVL limit is filled by first operator delegator
  • But first operator delegates only supports stETH and does not support wBETH while the second operator delegator supports both assets

In the scenario above, wBETH asset can not be deposited until 60% TVL limit is met so that the second operator delegator is chosen.

Tools Used

Manual Review

Recommended Mitigation Steps

When choosing available operator delegator, it should not only check for TVL allocation but also check if the asset to deposit is supported by the operator delegator.

Assessed type

DoS

Depositing assets to EigenLayer strategy reverts because of incorrect token approval

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L164

Vulnerability details

Impact

Depositing assets does not work, which is the most important feature of the protocol.

Proof of Concept

When assets are deposited to RestakeManager, it chooses one available operator delegator and deposits assets to EigenLayer through the operator delegator, which finally calls _deposit function of OperatorDelegator contract:

function _deposit(IERC20 _token, uint256 _tokenAmount) internal returns (uint256 shares) {
    // Approve the strategy manager to spend the tokens
@>  _token.safeApprove(address(strategyManager), _tokenAmount);

    // Deposit the tokens via the strategy manager
    return
        strategyManager.depositIntoStrategy(tokenStrategyMapping[_token], _token, _tokenAmount);
}

It approves the amount of token to the StrategyManager contract of EigenLayer.
However in _depositIntoStrategy function of StrategyManager contract, it directly transfers tokens from msg.sender to the specific strategy:

function _depositIntoStrategy(
    address staker,
    IStrategy strategy,
    IERC20 token,
    uint256 amount
) internal onlyStrategiesWhitelistedForDeposit(strategy) returns (uint256 shares) {
    // transfer tokens from the sender to the strategy
@>  token.safeTransferFrom(msg.sender, address(strategy), amount);

    // ...

    return shares;
}

As a result, deposit fails because the assets are not approved from OperatorDelegator contract to the specific Strategy contract.

Tools Used

Manual Review

Recommended Mitigation Steps

The assets should be approved to strategy contract, not to strategy manager.

function _deposit(IERC20 _token, uint256 _tokenAmount) internal returns (uint256 shares) {
    // Approve the strategy manager to spend the tokens
@>  _token.safeApprove(tokenStrategyMapping[_token], _tokenAmount);

    // Deposit the tokens via the strategy manager
    return
        strategyManager.depositIntoStrategy(tokenStrategyMapping[_token], _token, _tokenAmount);
}

Assessed type

DoS

Minting logic still vulnerable to the donation attack

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L121-L164

Vulnerability details

Impact

In the previous audit report by Halborn, there was a critical issue identified as "(HAL-02) MINTING LOGIC FLAW IN DEPOSIT FUNCTION FOR INITIAL SUPPLY". The project introduced a fix, however, it is not sufficient protection.

Anyone can donate value to the contract and thus increase the TVL anytime, e.g. sending ETH directly to the deposit or withdraw queue. A malicious user can sandwich the first deposit by donating some tokens, then minting the initial shares, and instantly withdrawing afterward.

The basic idea is to watch the mempool for the first deposit and frontrun it with 2x less value setting the initial price of 1 share and to take advantage of value truncation next.

How many shares to mint/redeem is calculated in the RenzoOracle contract. In the Proof of Concept section I am providing an example of how these functions can be manipulated to extract value from one user to another.

Proof of Concept

Let's demonstrate the issue with an example (the numbers contain decimals to account for a realistic scenario):

User action value _currentValueInProtocol _existingEzETHSupply Return
A transfer tokens 50000000000000000000 (50) 0 0 0
A depositETH 1 50000000000000000000 (50) 0 1
B depositETH 100000000000000000000 (100) 50000000000000000001 (50.000...1) 2 1
A withdraw 1 150000000000000000001 (150.000...1) 2 75000000000000000000 (75)
B withdraw 1 75000000000000000000 (75) 1 75000000000000000000 (75)

A malicious user A sees the deposit transaction of 100 ETH from user B in the mempool and frontruns it by sending 50 ETH directly to the contract (e.g. with the help of a flash loan) and depositing 1 wei himself. In return, he gets 1 ezETH (initial shares). When 100 ETH from user B arrives, he will too receive 1 ezETH in shares. Then, user A can withdraw his shares and exchange 1 ezETH for 75 ETH. His profit is 75 - 50 = 25 ETH. On the other hand, User B takes a -25 ETH loss.

Tools Used

Remix IDE with a simplified version of RenzoOracle contract to test mint/redeem values:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.19;

contract RenzoOracle {

    /// @dev Error when calculating token amounts is invalid
    error InvalidTokenAmount();

    constructor() { }

     /// @dev Given amount of current protocol value, new value being added, and supply of ezETH, determine amount to mint
    /// Values should be denominated in the same underlying currency with the same decimal precision
    function calculateMintAmount(
        uint256 _currentValueInProtocol,
        uint256 _newValueAdded,
        uint256 _existingEzETHSupply
    ) external pure returns (uint256) {
        // For first mint, just return the new value added.
        // Checking both current value and existing supply to guard against gaming the initial mint
        if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) {
            return _newValueAdded; // value is priced in base units, so divide by scale factor
        }

        // Calculate the percentage of value after the deposit
        uint256 inflationPercentaage = (10 ** 18 * _newValueAdded) /
            (_currentValueInProtocol + _newValueAdded);

        // Calculate the new supply
        uint256 newEzETHSupply = (_existingEzETHSupply * 10 ** 18) /
            (10 ** 18 - inflationPercentaage);

        // Subtract the old supply from the new supply to get the amount to mint
        uint256 mintAmount = newEzETHSupply - _existingEzETHSupply;

        // Sanity check
        if (mintAmount == 0) revert InvalidTokenAmount();

        return mintAmount;
    }

    // Given the amount of ezETH to burn, the supply of ezETH, and the total value in the protocol, determine amount of value to return to user
    function calculateRedeemAmount(
        uint256 _ezETHBeingBurned,
        uint256 _existingEzETHSupply,
        uint256 _currentValueInProtocol
    ) external pure returns (uint256) {
        // This is just returning the percentage of TVL that matches the percentage of ezETH being burned
        uint256 redeemAmount = (_currentValueInProtocol * _ezETHBeingBurned) / _existingEzETHSupply;

        // Sanity check
        if (redeemAmount == 0) revert InvalidTokenAmount();

        return redeemAmount;
    }
}

Recommended Mitigation Steps

There is some good advice on how to combat this (section "How not to get rekt"):
https://www.euler.finance/blog/exchange-rate-manipulation-in-erc4626-vaults

Assessed type

Timing

Vulnerable implementation on reading L2 oracle might DOS L2 deposit, even when valid price is available

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L293

Vulnerability details

Impact

Vulnerable implementation on reading L2 oracle might DOS L2 deposit, even when valid price is available.

Proof of Concept

In xRenzoDeposit.sol, there can be two price feeds available from getMintRate(): (1) chainlink l2 oracle price(RenzoOracleL2),and (2) recorded price from L1 (L1 -xRenzoBrdige::sendPrice -> L2 - xRenzoDeposit::updatePrice).

Based on the if control flow, the intended behavior is when (1) is not available, (2) can be used.

The vulnerability is in xRenzoDeposit::getMintRate, the reading chainlink L2 oracle is not wrapped in try-catch. This will cause an unwanted behavior: when (1) is not available(revert), (2) will not be used either due to tx revert.

//contracts/Bridge/L2/xRenzoDeposit.sol
    function getMintRate() public view returns (uint256, uint256) {
        // revert if PriceFeedNotAvailable
        if (receiver == address(0) && address(oracle) == address(0)) revert PriceFeedNotAvailable();
        if (address(oracle) != address(0)) {
            //@audit If oracle.getMintRate call revert (e.g. due to invalid price or stale price), recorded lastPrice cannot be used either, entire tx will revert
|>          (uint256 oraclePrice, uint256 oracleTimestamp) = oracle.getMintRate();
            return
                oracleTimestamp > lastPriceTimestamp
                    ? (oraclePrice, oracleTimestamp)
                    : (lastPrice, lastPriceTimestamp);
        } else {
            return (lastPrice, lastPriceTimestamp);
        }
    }

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L293)

As a result, even when valid lastPrice is available, deposit flow might still be DOSsed.(xRenzoDeposit::deposit -> getMintRate -> oracle.getMintRate())

Tools Used

Manual

Recommended Mitigation Steps

Consider wrapping oracle.getMintRate() in try-catch, and fall back to check and pass lastPrice if oracle.getMintRate() failed.

Assessed type

Other

QA Report

See the markdown file with the details of this report here.

Attacker can steal reward by sandwiching `claimDelayedWithdrawals()` From eigenLayer

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L206

Vulnerability details

Vulnerability details

Protocol handle ETH withdrawal from eigenPod by sending them to the DepositQueue and WithdrawQueue contract through OperatorDelegator contract:

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 }();  // <----
    }
.  .  .  .  .
}

After delay (7 days - from eigenLayer docs), withdrawal can claimed by calling the permissionless function EigenLayer::DelayedWithdrawalRouter::claimDelayedWithdrawals(), this call will instantly increase the TVL of the protocol.

An attacker can take advantage of this to steal a part of the rewards:

  1. Mint a sensible amount of ezETH by depositing an accepted asset
  2. Call EigenLayer::DelayedWithdrawalRouter::claimDelayedWithdrawals(), after which the value of the ezETH just minted will immediately increase.
  3. Call withdraw() function from WithdrawQueue contract

Impact

Part of delayed withdrawal token can be stolen

Tools Used

Manual review

Recommended Mitigation Steps

Do not let user withdraw right after deposit.

Assessed type

Other

`XERC20Lockbox#withdraw()` currently does not work as expected since it reverts in the attempt to burn the requested `XERC20` tokens to be withdrawn

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L103-L137
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20.sol#L107-L114

Vulnerability details

Proof of Concept

Take a look at the current logic applied to withdrawals in the XERC20Lockbox https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L103-L137

    function withdraw(uint256 _amount) external {
        _withdraw(msg.sender, _amount);
    }

    function withdrawTo(address _to, uint256 _amount) external {
        _withdraw(_to, _amount);
    }

    function _withdraw(address _to, uint256 _amount) internal {
        emit Withdraw(_to, _amount);

        XERC20.burn(msg.sender, _amount);

        if (IS_NATIVE) {
            (bool _success, ) = payable(_to).call{ value: _amount }("");
            if (!_success) revert IXERC20Lockbox_WithdrawFailed();
        } else {
            ERC20.safeTransfer(_to, _amount);
        }
    }

These functions are eventually called whenever there is a need to withdraw ERC20 tokens from the lockbox and from the snippet attached above this, just in it's sense is implemented like a normal withdrawal approach and should work without problems, issue here however is with the way the logic is held up on before the call to burn the requested tokens to be withdrawn via XERC20.burn(msg.sender, _amount);, if we check the implementation of XERC20.burn() at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20.sol#L107-L114

    function burn(address _user, uint256 _amount) public virtual {
        if (msg.sender != _user) {
            _spendAllowance(_user, msg.sender, _amount);
        }

        _burnWithCaller(msg.sender, _user, _amount);
    }

We can see that in the case where the (msg.sender != _user) the execution attempts to spend the allowance the user has given to the msg.sender, would be key to note that from our withdrawal attempt via XERC20Lockbox#withdraw() in XERC20.burn() the msg.sender() is always not going to be the user since msg.sender is now the XERC20Lockbox.sol contract. Back to the logic present in XERC20Lockbox#withdraw(), we can see that before querying XERC20.burn(), no approval is set for the lockbox contract to spend the user's amount of tokens to be withdrawn neither are the tokens transferred in and then directly burnt, which then means that all attempts to withdraw the tokens would fail, cause when the call gets to the burn execution in XERC20.sol this attempt to spend allowance would always revert.

Impact

Functionality of protocol's core logic is flawed, and it's availability is affected, considering the current code scope, all attempts to withdraw tokens from the XERC20 lockbox would encounter a revert.

Recommended Mitigation Steps

A recommended fix could be to first transfer in the XERC20 tokens from the users during the attempt of withdrawal and then directly burn these tokens from the lockbox itself, ensuring that the attempt would never revert, so consider applying these changes to https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L117-L137

    function _withdraw(address _to, uint256 _amount) internal {
        emit Withdraw(_to, _amount);
+ 
+       XERC20.transferFrom(msg.sender, address(this), _amount);
- 
-       XERC20.burn(msg.sender, _amount);
+ 
+       XERC20.burn(address(this), _amount);

        if (IS_NATIVE) {
            (bool _success, ) = payable(_to).call{ value: _amount }("");
            if (!_success) revert IXERC20Lockbox_WithdrawFailed();
        } else {
            ERC20.safeTransfer(_to, _amount);
        }
    }

Assessed type

Token-Transfer

The amount of unclaimed assets should be excluded in the `maxDepositTVL` check.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L510-L512

Vulnerability details

Impact

The actual TVL max limit will be much less than maxDepositTVL, when there are many unclaimed assets in withdrawQueue.
In the worst case, that is, when the value of unclaimed assets exceeds the maxDepositTVL, this protocol cannot work.

Proof of Concept

Any deposits will revert if it pushs TVL above the maxDepositTVL.

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L510-L512

        // Enforce TVL limit if set, 0 means the check is not enabled
        if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) {
            revert MaxTVLReached();
        }

The value of all unclaimed assets is added in the calculation of TVL. So, if there are large amount of unclaimed tokens in the withdrawQueue, the actual TVL max limit will be much less than maxDepositTVL. I think that this is not intended.
In the worst case, that is, when the value of unclaimed assets exceeds the maxDepositTVL, this protocol cannot work.

Tools Used

Manual review

Recommended Mitigation Steps

The amount of unclaimed assets should be excluded in the maxDepositTVL check.

Assessed type

Context

QA Report

See the markdown file with the details of this report here.

`WithdrawQueue.sol'`s current implementation of executing withdrawal requests asides having a 0% hardcoded on-chain slippage could also lead to users receiving way little redeemed amounts not in their favor/control

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L206-L263

Vulnerability details

Proof of Concept

Take a look at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L206-L263

    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
        // check for 0 values
        if (_amount == 0 || _assetOut == address(0)) revert InvalidZeroInput();

        // check if provided assetOut is supported
        if (withdrawalBufferTarget[_assetOut] == 0) revert UnsupportedWithdrawAsset();

        // transfer ezETH tokens to this address
        IERC20(address(ezETH)).safeTransferFrom(msg.sender, address(this), _amount);

        // calculate totalTVL
        (, , uint256 totalTVL) = restakeManager.calculateTVLs();

        // Calculate amount to Redeem in ETH
        uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(
            _amount,
            ezETH.totalSupply(),
            totalTVL
        );

        // update amount in claim asset, if claim asset is not ETH
        if (_assetOut != IS_NATIVE) {
            // Get ERC20 asset equivalent amount
            amountToRedeem = renzoOracle.lookupTokenAmountFromValue(
                IERC20(_assetOut),
                amountToRedeem
            );
        }
        //@audit
        // revert if amount to redeem is greater than withdrawBufferTarget
        if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer();

        // increment the withdrawRequestNonce
        withdrawRequestNonce++;

        // add withdraw request for msg.sender
        withdrawRequests[msg.sender].push(
            WithdrawRequest(
                _assetOut,
                withdrawRequestNonce,
                amountToRedeem,
                _amount,
                block.timestamp
            )
        );

        // add redeem amount to claimReserve of claim asset
        claimReserve[_assetOut] += amountToRedeem;

        emit WithdrawRequestCreated(
            withdrawRequestNonce,
            msg.sender,
            _assetOut,
            amountToRedeem,
            _amount,
            withdrawRequests[msg.sender].length - 1
        );
    }

This function is used to create withdrawals however there is no availability for a user to provide their own slippage, i.e if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer()... keep in mind that the amountToRedeem is calculated on-chain from the RenzoOracle and not in control of the user, i.e protocol automatically calculates this value and then hardcodes a 0% slippage on the attempt to withdraw, this leads to two issues as explained in the Impact section.

Impact

Since the calculation of the amountToRedeem is not in the user's control (i.e no slippage provided) this could lead to multiple scenarios.

  • In the case the amountToRedeem value returned from RenzoOracle is very minute or unfair to the user there is no way for them to dispute the transaction, i.e a normal withdrawal flaw that consists of the ideas of exchange rates or conversions always have a user slippage attached so they can clarify if the final amount to be gotten is accepted by them or they would like to try the withdrawal later on a better term to them, also note that this could actually happen even in normal market condition, but then consider a situation where the oracle is stale and the amountToRedeem deviates too much from the real market price and the user would not like to go on with the withdrawal request tx.

  • Another case is when the amountToRedeem returned value is not up to the getAvailableToWithdraw(_assetOut) but there is a very minute/negligible difference, which users might have accepted but this transaction instead fails, and this could lead to users losing out on monetary value of their assets as they might be attempting to withdraw to get their token out cause they feel the crypto market is taking a dump, but they can't do that on time since they can't really place their withdrawal requests on time

Recommended Mitigation Steps

Considering attaching a slippage parameter to the WithdrawQueue's withdrawal attempt and then ensure that the value returned from renzoOracle.lookupTokenAmountFromValue() is not less than this value and revert if otherwise, additionally the check here should be made against the slippage provided value to ensure a user's withdrawal request is not DOS'd if they can accept a value <= the current available max withdrawal.

Assessed type

Context

Attacker can force `xReceive()` function in `xRenzoBridge` failed to steal tokens

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L139-#L201

Vulnerability details

Vulnerability details

From connext documentation, If the call on the receiver contract, token will be stucked on the receivers. In the xReceive() function, it will deposit token to restakeManager contract:

    restakeManager.depositETH{ value: ethAmount }();

Also there is a condition that can lead to revert:

function depositETH(uint256 _referralId) public payable nonReentrant notPaused {
    // Get the total TVL
    (, , uint256 totalTVL) = calculateTVLs();

    // Enforce TVL limit if set
    if (maxDepositTVL != 0 && totalTVL + msg.value > maxDepositTVL) {
        revert MaxTVLReached();   // <---
    }
.  .  .  .

Moreover, there is a function to withdarw ERC20 from xRenzoBridge contract, and BRIDGE_ADMIN is not trusted role:

function recoverERC20(address _token, uint256 _amount, address _to) external onlyBridgeAdmin {
    IERC20(_token).safeTransfer(_to, _amount);
}

Which will lead to attack scenario that malicious BRIDGE_ADMIN can force revert xReceive function by deposit enough token to make depositETH function revert, and use recoverERC20 function to steal token.

Impact

Token can be stolen by malicious bridge admin

Tools Used

Manual review

Recommended Mitigation Steps

Make sure xReceive function cant be revert in any circumstance.

Assessed type

Other

Malicious operators can `undelegate` theirselves to manipulate the exchange rate

Lines of code

https://github.com/Layr-Labs/eigenlayer-contracts/blob/6de01c6c16d6df44af15f0b06809dc160eac0ebf/src/contracts/core/DelegationManager.sol#L211-L258

Vulnerability details

Vulnerability details

If a malicious operator undelegates itself in EigenLayer delegation manager contract, the exchange rate can significantly decrease

Operators' delegator contracts delegate their balance to the operators. Operators can undelegate themselves from any delegation forwarded to them by triggering this function: DelegationManager.sol#L211-L258

When EigenPod shares are undelegate, the EigenPod shares are removed. Unlike the strategy shares, the EigenPod shares are used to account for how much ETH is held by each operator. If an operator undelegate, then the entire EigenPod balance will be "0": DelegationManager.sol#L247-#L253 -> StrategyManager.sol#L352-#L381

TVL is rely on shares in eigenLayer:

function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
.  .  .  .  .  .
            uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(   // <----
                collateralTokens[j]
            );

Function getTokenBalanceFromStrategy()

function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) {
    return
        queuedShares[address(this)] == 0
            ? tokenStrategyMapping[token].userUnderlyingView(address(this))  // <---
            : tokenStrategyMapping[token].userUnderlyingView(address(this)) + // <---
                tokenStrategyMapping[token].sharesToUnderlyingView(
                    queuedShares[address(token)]
                );
}

Function userUnderlyingView in eigenLayer:

function userUnderlyingView(address user) external view virtual returns (uint256) {
    return sharesToUnderlyingView(shares(user));
}

Total share of user get from stakerStrategyShares function, which is reduced when undelegate:

function shares(address user) public view virtual returns (uint256) {
    return strategyManager.stakerStrategyShares(user, IStrategy(address(this)));
}

Impact

It will lead to unexpectedly decrease the TVL, leading to a decrease in the exchange rate without warning which would affect the deposits/withdrawals of the ezETH in different assets aswell.

Tools Used

Manual review

Recommended Mitigation Steps

Assessed type

Other

totalTVL will be incorrect due to wrong collateral index implementation

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L318

Vulnerability details

Impact

totalTVL will be incorrect due to wrong collateral index implementation.

Proof of Concept

In RestakeManager::calculateTVLs, incorrect collateral token index is used when calculating totalWithdrawalQueueValue.

In the second for-loop, j is based on collateralTokens.length. However, collateralTokens[i] is used where i is based on operatorDelegators.length. This pairs incorrect collateralToken address with the correct token balance collateralTokens[j].balanceOf(withdrawQueue), which result in incorrect totalWithdrawalQueueValue and incorrect totalTVL.

//contracts/RestakeManager.sol
    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
...
        uint256 odLength = operatorDelegators.length;
...
        for (uint256 i = 0; i < odLength; ) {
...
            uint256 tokenLength = collateralTokens.length;
            for (uint256 j = 0; j < tokenLength; ) {
...
                if (!withdrawQueueTokenBalanceRecorded) {
                    //@audit this should be collateralTokens[j]
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
|>                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }
...
         //@audit totalTVL will be incorrect
|>        totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L318)

Any flows that consume totalTVL will use incorrect value. Most importantly, ezETH will be minted against incorrect collateral value and ezETH price will be incorrect.

Tools Used

Manual

Recommended Mitigation Steps

Change to renzoOracle.lookupTokenValue( collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue) );

Assessed type

Error

Missing logic for receiving tokens from EigenLayer's `DelayedWithdrawalRouter`, leading to loss of rewards

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L1

Vulnerability details

Impact

Rewards from Ethereum PoS are lost.

Proof of Concept

When rewards are generated from Ethereum PoS, they can be fetched through partial withdrawal.
However, when partial withdrawals happen, the EigenLayer sends those rewards to its DelayedWithdrawalRouter contract so that they can be fetched after a cool down period, as shown in the code snippet below.

function verifyAndProcessWithdrawals(...) {
    // ...

    // @audit amountToSendGwei represents rewards from Ethereum PoS
    if (withdrawalSummary.amountToSendGwei != 0) {
        _sendETH_AsDelayedWithdrawal(podOwner, withdrawalSummary.amountToSendGwei * GWEI_TO_WEI);
    }

    // ...
}

This works same when validators exit and full withdrawals happen, the rewards amount that exceeds 32ETH will be sent to the delayed withdrawal router.
These rewards sent to the delayed withdrawal router can be only claimed by the pod owner, which is OperatorDelegator contract in case of Renzo protocol, which is missing feature in the contract.

Tools Used

Manual Review

Recommended Mitigation Steps

There has to be a function added in OperatorDelegator contract so that it claims rewards from delayed router and re-deposit as it is designed in Renzo protocol.

Assessed type

Context

Risk of Storage Collision in OptimismMintableXERC20 Upgrades

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/optimism/OptimismMintableXERC20.sol#L11

Vulnerability details

Note on Risk Classification:

While automated tools classify the absence of a storage gap in upgradeable contracts as a low-risk issue, I believe that the specific circumstances and potential consequences in our case warrant a higher risk classification. This reassessment is based on several factors:

  1. Contract Value and Functionality: The contracts in question manage significant assets and facilitate critical functionalities within their respective ecosystems. Any disruption caused by storage collisions could therefore lead to disproportionately high impacts.

  2. Complexity of Inheritance: Given that XERC20 serves as a base for other contracts such as OptimismMintableXERC20, the propagation effect of this vulnerability could affect multiple derived contracts, thus amplifying potential security threats.

  3. Future Upgradeability: The expected frequency and complexity of future upgrades increase the likelihood that the absence of a storage gap could result in serious issues, making proactive mitigation crucial.

Impact

The lack of explicit storage gaps in XERC20, which is a base contract for OptimismMintableXERC20, may lead to storage collisions when new state variables are added to XERC20 in future upgrades. This issue can cause data corruption or incorrect data mapping, severely impacting the contract's integrity and functionality.

Proof of Concept

The XERC20 contract is inherited by OptimismMintableXERC20. If XERC20 has new state variables added, without reserved storage spaces (gaps), these new variables would displace the storage mapping of OptimismMintableXERC20.

// XERC20.sol
contract XERC20 is Initializable, ERC20Upgradeable, OwnableUpgradeable, IXERC20, ERC20PermitUpgradeable {
    // Storage variables
    address public FACTORY;
    address public lockbox;
    mapping(address => Bridge) public bridges;
    ...
}

// OptimismMintableXERC20.sol
contract OptimismMintableXERC20 is ERC165Upgradeable, XERC20, IOptimismMintableERC20 {
    address public l1Token;
    ...
}

Test Case (Foundry)

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.4 <0.9.0;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "contracts/Bridge/xERC20/contracts/XERC20.sol";
import "contracts/Bridge/xERC20/contracts/optimism/OptimismMintableXERC20.sol";

// Mock versions of the contracts to simulate an upgrade that adds new storage variables.
contract MockXERC20V2 is XERC20 {
    uint256[1] value;
}

// This contract simulates an upgraded version of OptimismMintableXERC20 including changes from MockXERC20V2.
contract MockOptimismMintableXERC20V2 is ERC165Upgradeable, MockXERC20V2, IOptimismMintableERC20 {
    /**
     * @notice The address of the l1 token (remoteToken)
     */
    address public l1Token;

    /**
     * @notice The address of the optimism canonical bridge
     */
    address public optimismBridge;

    /// @dev Prevents implementation contract from being initialized.
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    /**
     * @notice Constructs the initial config of the XERC20
     *
     * @param _name The name of the token
     * @param _symbol The symbol of the token
     * @param _factory The factory which deployed this contract
     */
    function initialize(
        string memory _name,
        string memory _symbol,
        address _factory,
        address _l1Token,
        address _optimismBridge
    ) public initializer {
        __ERC165_init();
        __XERC20_init(_name, _symbol, _factory);
        l1Token = _l1Token;
        optimismBridge = _optimismBridge;
    }

    function supportsInterface(
        bytes4 interfaceId
    ) public view override(ERC165Upgradeable) returns (bool) {
        return
            interfaceId == type(IOptimismMintableERC20).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    function remoteToken() public view override returns (address) {
        return l1Token;
    }

    function bridge() public view override returns (address) {
        return optimismBridge;
    }

    function mint(address _to, uint256 _amount) public override(XERC20, IOptimismMintableERC20) {
        XERC20.mint(_to, _amount);
    }

    function burn(address _from, uint256 _amount) public override(XERC20, IOptimismMintableERC20) {
        XERC20.burn(_from, _amount);
    }
}

// Contract to run tests demonstrating the storage collision.
contract PoC is Test {
    OptimismMintableXERC20 oxERC20Impl;
    MockOptimismMintableXERC20V2 oxERC20V2Impl;

    TransparentUpgradeableProxy oxERC20;

    address proxyAdmin;
    address mockL1Token;
    address mockOptimismBridge;


    function setUp() public {
        // xERC20Impl = new XERC20();
        oxERC20Impl = new OptimismMintableXERC20();
        oxERC20V2Impl = new MockOptimismMintableXERC20V2();

        mockL1Token = makeAddr("l1Token");
        mockOptimismBridge = makeAddr("optimismBridge");
        proxyAdmin = makeAddr("proxyAdmin");

        // Deploying the proxy for OptimismMintableXERC20 using the initialize function with initial parameters.
        oxERC20 = new TransparentUpgradeableProxy(
            address(oxERC20Impl),
            proxyAdmin,
            abi.encodeCall(
                OptimismMintableXERC20.initialize,
                ("name", "symbol", address(this), mockL1Token, mockOptimismBridge)
            )
        );
    }

    function test_addingNewStorageVariableToXERC20CouldCorrupteStateVariablesOfOptimismMintableXERC20() public {
        // Verifying the state before upgrading to new version
        assertEq(getRemoteToken(address(oxERC20)), mockL1Token);

        // Upgrading the proxy to the new contract version that includes additional storage variables.
        vm.prank(proxyAdmin);
        (bool success, ) = address(oxERC20).call(
            abi.encodeWithSelector(
                ITransparentUpgradeableProxy.upgradeTo.selector,
                address(oxERC20V2Impl)
            )
        );
        require(success);

        // Checking the state corruption by verifying if the remoteToken address has changed unexpectedly.
        assertNotEq(getRemoteToken(address(oxERC20)), mockL1Token);
    }

    function getRemoteToken(address _oxERC20) private returns(address addr) {
        (, bytes memory result) = _oxERC20.call(abi.encodeWithSignature("remoteToken()"));
        return abi.decode(result, (address));
    }
}

Test output

2024-04-renzo main* 3s
❯ forge test -vvvv
[⠊] Compiling...
[⠘] Compiling 1 files with 0.8.19
[⠃] Solc 0.8.19 finished in 1.80s
Compiler run successful!

Ran 1 test for test/XERC20.t.sol:PoC
[PASS] test_addingNewStorageVariableToXERC20CouldCorrupteStateVariablesOfOptimismMintableXERC20() (gas: 37486)
Traces:
  [37486] PoC::test_addingNewStorageVariableToXERC20CouldCorrupteStateVariablesOfOptimismMintableXERC20()
    ├─ [9498] TransparentUpgradeableProxy::remoteToken()
    │   ├─ [2398] OptimismMintableXERC20::remoteToken() [delegatecall]
    │   │   └─ ← [Return] l1Token: [0x2d7dC9F6B280314624bf3d5c5adc5bF24753B5ad]
    │   └─ ← [Return] l1Token: [0x2d7dC9F6B280314624bf3d5c5adc5bF24753B5ad]
    ├─ [0] VM::assertEq(l1Token: [0x2d7dC9F6B280314624bf3d5c5adc5bF24753B5ad], l1Token: [0x2d7dC9F6B280314624bf3d5c5adc5bF24753B5ad]) [staticcall]
    │   └─ ← [Return]
    ├─ [0] VM::prank(proxyAdmin: [0x129d58674d5511922A08169F6965b6958A3D07b0])
    │   └─ ← [Return]
    ├─ [7700] TransparentUpgradeableProxy::upgradeTo(MockOptimismMintableXERC20V2: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   ├─ emit Upgraded(implementation: MockOptimismMintableXERC20V2: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   └─ ← [Return]
    ├─ [2998] TransparentUpgradeableProxy::remoteToken()
    │   ├─ [2398] MockOptimismMintableXERC20V2::remoteToken() [delegatecall]
    │   │   └─ ← [Return] optimismBridge: [0xBa2ee9789e3A6e7dE5027E409B7b393C350b8619]
    │   └─ ← [Return] optimismBridge: [0xBa2ee9789e3A6e7dE5027E409B7b393C350b8619]
    ├─ [0] VM::assertNotEq(optimismBridge: [0xBa2ee9789e3A6e7dE5027E409B7b393C350b8619], l1Token: [0x2d7dC9F6B280314624bf3d5c5adc5bF24753B5ad]) [staticcall]
    │   └─ ← [Return]
    └─ ← [Stop]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.58ms (101.92µs CPU time)

Ran 1 test suite in 117.70ms (1.58ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

  • Solidity Compiler
  • Foundry for Testing
  • OpenZeppelin Contracts (Upgradeable)

Recommended Mitigation Steps

Strategic Structural Changes

  • Consider Independent Upgradeability: Evaluate the feasibility of designing OptimismMintableXERC20 as an independent upgradeable contract, separate from XERC20. This approach reduces dependency risks and isolates upgrades, significantly mitigating the potential for storage collisions across dependent contracts.

Technical Improvements

  • Introduce Storage Gaps: Ensure future-proofing by modifying the XERC20 contract to include reserved storage slots that accommodate additional variables in future upgrades: uint256[50] private __gap; // Reserve 50 storage slots for future use

  • Specify Gap Usage: Clearly define how these gaps should be utilized in future contract versions to prevent misuse or misalignment of the storage structure.

  • Best Practices Manual: Create a comprehensive guide on best practices for upgrading contracts within your ecosystem, focusing on maintaining the integrity of the storage layout.

Assessed type

Upgradable

Protocol implements a push over pull method when withdrawing which could cause users `wBETH` withdrawal requests to be indefinitely stuck in protocol

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L279-L312
https://www.contractreader.io/contract/mainnet/0xa2E3356610840701BDf5611a53974510Ae27E2e1/0xfe928a7d8be9c8cece7e97f0ed5704f4fa2cb42a#L122

Vulnerability details

Proof of Concept

Take a look at how withdrawal requests are being claimed https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L279-L312

    function claim(uint256 withdrawRequestIndex) external nonReentrant {
        // check if provided withdrawRequest Index is valid
        if (withdrawRequestIndex >= withdrawRequests[msg.sender].length)
            revert InvalidWithdrawIndex();

        WithdrawRequest memory _withdrawRequest = withdrawRequests[msg.sender][
            withdrawRequestIndex
        ];
        if (block.timestamp - _withdrawRequest.createdAt < coolDownPeriod) revert EarlyClaim();

        // subtract value from claim reserve for claim asset
        claimReserve[_withdrawRequest.collateralToken] -= _withdrawRequest.amountToRedeem;

        // delete the withdraw request
        withdrawRequests[msg.sender][withdrawRequestIndex] = withdrawRequests[msg.sender][
            withdrawRequests[msg.sender].length - 1
        ];
        withdrawRequests[msg.sender].pop();

        // burn ezETH locked for withdraw request
        ezETH.burn(address(this), _withdrawRequest.ezETHLocked);

        // send selected redeem asset to user
        if (_withdrawRequest.collateralToken == IS_NATIVE) {
            payable(msg.sender).transfer(_withdrawRequest.amountToRedeem);
        } else {
            IERC20(_withdrawRequest.collateralToken).transfer(
                msg.sender, //@audit 
                _withdrawRequest.amountToRedeem
            );
        }
        // emit the event
        emit WithdrawRequestClaimed(_withdrawRequest);
    }

We can see that this includes multiple checks to ensure that the index provided is validly in range and also that the cooldown period has passed, however issue with the current implementation is the fact that when transferring the assets the msg.sender has been hardcoded as the recipient as hinted by the @Audit tag.

Now protocol has explicitly stated that they support and would use the wBETH token and are welcoming bug ideas attached to it, this can be hinted from this snippet in the readMe, https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/README.md#L299-L301

| Question                                | Answer                       |
| --------------------------------------- | ---------------------------- |
| ERC20 used by the protocol              |       ezETH, stETH, wBETH             |

From here: https://coinmarketcap.com/ru/currencies/wrapped-beacon-eth/ we can see that the proxy contract address for wBETH is 0xa2E3356610840701BDf5611a53974510Ae27E2e1.

Now navigating to https://www.contractreader.io/contract/mainnet/0xa2E3356610840701BDf5611a53974510Ae27E2e1, we can see that the current implementation is specified at 0xfe928a7d8be9c8cece7e97f0ed5704f4fa2cb42a.

Going to the contract and it's implementation on here https://www.contractreader.io/contract/mainnet/0xa2E3356610840701BDf5611a53974510Ae27E2e1/0xfe928a7d8be9c8cece7e97f0ed5704f4fa2cb42a we can see that the implementation is infact blacklistable, i.e if a user is to get blacklisted then all attempts at transfers/approvals would always revert as shown in the inherited FiatTokenV1.sol this then means that if any user gets blacklisted all their withdrawal requests are effectively stuck in the protocol since they can't claim them due to the revert that would always occur in this attempt to transfer the collateral.

Impact

Users funds are effectively stuck in the protocol, additionally core functionality of protocol would be unavailable to some users considering they can queue their withdrawals but they can't claim them, keep in mind that queueing their withdrawals meaning they are going to send their ezETH to the WithdrawQueue contract meaning they lose out on any upside the token might have while they can't access their tokens due to the being blacklisted since the amountToRedeem calculated for their asset value would be very stale by the time they _(if ever) _ get unblacklisted.

Tool used

Recommended Mitigation Steps

Consider implementing a pull over push pattern as generally recommended when processing withdrawals, i.e allow msg.sender to provide a fresh recipient address to finalize their claim and then this attempt to transfer the collateral would be done to the fresh address if provided, this way a user can specify themselves as the recipient if they are not blacklisted.

Assessed type

Token-Transfer

Withdrawers can skip Slashing due to cached `amountToRedeem`

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L227-L250

Vulnerability details

Impact

ezETH calculates withdrawal value via the following:

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L227-L250

       if (_assetOut != IS_NATIVE) {
            // Get ERC20 asset equivalent amount
            amountToRedeem = renzoOracle.lookupTokenAmountFromValue(
                IERC20(_assetOut),
                amountToRedeem
            ); /// @audit Arbitrage here
        }

This is a spot value conversion of the ETH to LST value.

In the case of stETH, a slashing would reduce the balance of each token holder and as such it would reduce the total amount of tokens in Renzo.

Because the amount of stETH to withdraw is cached as amountToRedeem, in case of a slashing withdrawers stake will not be slashed, which will:

  • Socialize the loss to other depositors
  • Make the withdrawer avoid the loss completely

If this were to be done to a sufficient scale, this would cause ezETH to be undercollateralized, as all available withdrawals would be used to escape slashing mechanism

Code explanation

The logic to compute the ETH value of an LRT in Renzo is as follows:

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L227-L250

       if (_assetOut != IS_NATIVE) {
            // Get ERC20 asset equivalent amount
            amountToRedeem = renzoOracle.lookupTokenAmountFromValue(
                IERC20(_assetOut),
                amountToRedeem
            ); /// @audit Arbitrage here
        }

        // revert if amount to redeem is greater than withdrawBufferTarget
        if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer();

        // increment the withdrawRequestNonce
        withdrawRequestNonce++;

        // add withdraw request for msg.sender
        withdrawRequests[msg.sender].push(
            WithdrawRequest(
                _assetOut,
                withdrawRequestNonce,
                amountToRedeem, /// @audit Can prevent slashing from here -> Insolvency
                _amount,
                block.timestamp
            )
        );

This amount is then subtracted in claim

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L279-L290

    function claim(uint256 withdrawRequestIndex) external nonReentrant {
        // check if provided withdrawRequest Index is valid
        if (withdrawRequestIndex >= withdrawRequests[msg.sender].length)
            revert InvalidWithdrawIndex();

        WithdrawRequest memory _withdrawRequest = withdrawRequests[msg.sender][
            withdrawRequestIndex
        ];
        if (block.timestamp - _withdrawRequest.createdAt < coolDownPeriod) revert EarlyClaim();

        // subtract value from claim reserve for claim asset
        claimReserve[_withdrawRequest.collateralToken] -= _withdrawRequest.amountToRedeem;

The issue with this logic is that in between the queueing of a withdrawal and the claiming

If a slashing happens, then the value of the LRT withdrawable would need to be decreased by a commesurate amount

In lack of that, withdrawers that are being slashed are protected from slashing as Renzo is taking on the Loss by socializing it to all depositors that do not withdraw

POC

  • See stETH getting slashed
  • Realized oracle in Renzo has yet to update with the slashing
  • Queue a withdrawal
  • I successfully avoided the slashing
  • If a lot of people do this, then ezETH may be unable to repay all withdrawals

Mitigation

I believe there's 2 ways to handle mitigation

One requires ad hoc customizations for each token, by computing the delta index in the exchange rate at the time of withdrawal

The alternative is to denominate withdrawals in ETH, and make changes to make all LSTs withdrawals be done back to ETH

Each token would need to have it's index tracked, and slashing must account for a discount that is reflects the loss that happened

While the codes bases are different, a good starting point is to check how Blast handles discount:
https://github.com/blast-io/blast/blob/04ed82f54a627622f82bea2217a090106d1ca2c2/blast-optimism/packages/contracts-bedrock/src/mainnet-bridge/withdrawal-queue/WithdrawalQueue.sol#L411-L441

In my opinion, since Renzo separates different tokens at a conceptual level, then each token should have an index for the share value

e.g.

    uint256 withdrawalIndex = stETH.getPooledEthByShares(1e18); // Retrieve index at time of withdrawal
    uint256 currentIndex = stETH.getPooledEthByShares(1e18);
    uint256 impreciseDiscountFactor = currentIndex > withdrawalIndex 
        ? 0 /// @audit No discount
        : withdrawalIndex - currentIndex /// @audit Discount
    
    /// @audit TODO: Fuzz tests and invariant test for precision loss
    uint256 safeWithdrawalAmount = _withdrawRequest.amountToRedeem * (1e18 - impreciseDiscountFactor);

By doing this, the system should maintain the property that withdrawals do not lower the PPFS

Assessed type

MEV

Incorrect withdraw queue balance in TVL calculation

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L318

Vulnerability details

Impact

When calculating TVL it iterates over all the operator delegators and inside it iterates over all the collateral tokens. Code fragment:

        for (uint256 i = 0; i < odLength; ) {
            ...

            // Iterate through the tokens and get the value of each
            uint256 tokenLength = collateralTokens.length;
            for (uint256 j = 0; j < tokenLength; ) {
                ...

                // record token value of withdraw queue
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }

                unchecked {
                    ++j;
                }
            }

            ...

            unchecked {
                ++i;
            }
        }

However, the balance of withdrawQueue is incorrectly fetched, specifically this line:

                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );

It uses an incorrect index of the outer loop i to access the collateralTokens. i belongs to the operator delegator index, thus the returned value will not represent the real value of the token. For instance, if there is 1 OD and 3 collateral tokens, it will add the balance of the first token 3 times and neglect the other 2 tokens. If there are more ODs than collateral tokens, the the execution will revert (index out of bounds).

This calculation impacts the TVL which is the essential data when calculating mint/redeem and other critical values. A miscalculation in TVL could have devastating results.

Proof of Concept

A simplified version of the function to showcase that the same token (in this case address(1) is emitted multiple times and other tokens are untouched:

contract RestakeManager {

    address[] public operatorDelegators;

    address[] public collateralTokens;

    event CollateralTokenLookup(address token);

    constructor() {
        operatorDelegators.push(msg.sender);

        collateralTokens.push(address(1));
        collateralTokens.push(address(2));
        collateralTokens.push(address(3));
    }

    function calculateTVLs() public {
        // Iterate through the ODs
        uint256 odLength = operatorDelegators.length;

        for (uint256 i = 0; i < odLength; ) {
            // Iterate through the tokens and get the value of each
            uint256 tokenLength = collateralTokens.length;
            for (uint256 j = 0; j < tokenLength; ) {
                emit CollateralTokenLookup(collateralTokens[i]);

                unchecked {
                    ++j;
                }
            }

            unchecked {
                ++i;
            }
        }
    }
}

Tools Used

Manual review.

Recommended Mitigation Steps

Change to collateralTokens[j].

Assessed type

Math

Sweepers can keep all the bridge fees while providing low relayer fee (msg.value), causing failed token bridging on destination chain

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L434
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L445

Vulnerability details

Impact

Sweepers can keep all the bridge fees while providing low relayer fees (msg.value), causing failed token bridging on destination chain.

Proof of Concept

Sweepers are not trusted. Current xRenzoDeposit::sweep implementation allows callers to pass any amount of msg.value as relayer fees and transfer all the bridge fees to the caller in the same tx.

//contracts/Bridge/L2/xRenzoDeposit.sol
    function sweep() public payable nonReentrant {
...
        connext.xcall{ value: msg.value }(
            bridgeDestinationDomain,
            bridgeTargetAddress,
            address(collateralToken),
            msg.sender,
            balance,
            0, // Asset is already nextWETH, so no slippage will be incurred
            bridgeCallData
        );
        // send collected bridge fee to sweeper
        _recoverBridgeFee();
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L445)

There are two vulnerabilities here:
(1) msg.value is not checked:
connext.xcall will not verify the passed msg.value and will initiate the cross-chain tx. The caller(xRenzoDeposit.sol)'s responsible for estimating relayer fee and ensuring that msg.value is sufficient to cover the destination chain relayer fee.

Since gas conditions are impossible to predict, transactions can potentially stay pending on destination if fees aren't high enough...

Based on connext doc, when the relayer fee passed is too low, then users can only bump relayer fee on the destination chain to avoid tx fail on the destination.

If the estimated relayer fee paid was too low, then users may have to increase the relayer fee after the xcall has been sent.

(2)All bridge fees will be transferred to the caller(sweeper) atomically before crosschain tx succeeds.

In sweep(), _recoverBridgeFee() transfers the entire bridgeFeeCollected to caller.

//contracts/Bridge/L2/xRenzoDeposit.sol
    function _recoverBridgeFee() internal {
        uint256 feeCollected = bridgeFeeCollected;
...
        (bool success, ) = payable(msg.sender).call{ value: feeCollected }("");
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L403)

As a result, the caller can pass a low msg.sender and get all the bridgeFee atomically without being held responsible for bridging.

In addition, gas price changes might also cause crosschain tx to fail. And no other sweepers have any incentive to bridge token or bump relayer fee on the destination chain.

Tools Used

Manual

Recommended Mitigation Steps

Consider using a two-step bridge fee recovery process, only when bridge fund succeeds on the destination chain, will the sweeper be allowed to claim all the bridge fees.

Assessed type

Other

Improper operator delegator selecting mechanism in `RestakeManager.chooseOperatorDelegatorForDeposit()` function.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L362-L393

Vulnerability details

Impact

Depositing could be reverted due to improper choosing of the operator delegator, even though there may be another appropriate one available.

Proof of Concept

Some strategies of EigenLayer have deposit limitation variables maxPerDeposit and maxTotalDeposits. So, if the depositing amount exceeds maxPerDeposit or causes an overflow of the strategy's total deposit amount, then the transaction will be reverted.

However, the chooseOperatorDelegatorForDeposit() function does not consider the amount to be deposited. So, if the strategy of the operator delegator chosen by the chooseOperatorDelegatorForDeposit() function cannot accommodate the given deposit amount, the transaction will be reverted. In this case, it would be better for the function to choose another, more suitable operator delegator.

Especially in a scenario where there is no delegator that doesn't exceed it's allocation limit, the function will only choose the first delegator. This increases the likelihood of the above problem occurring, as all deposits will be concentrated on the first delegator.

    function chooseOperatorDelegatorForDeposit(
        uint256[] memory tvls,
        uint256 totalTVL
    ) public view returns (IOperatorDelegator) {
        // Ensure OperatorDelegator list is not empty
        if (operatorDelegators.length == 0) revert NotFound();

        // If there is only one operator delegator, return it
        if (operatorDelegators.length == 1) {
            return operatorDelegators[0];
        }

        // Otherwise, find the operator delegator with TVL below the threshold
        uint256 tvlLength = tvls.length;
        for (uint256 i = 0; i < tvlLength; ) {
            if (
                tvls[i] <
                (operatorDelegatorAllocations[operatorDelegators[i]] * totalTVL) /
                    BASIS_POINTS /
                    BASIS_POINTS
            ) {
                return operatorDelegators[i];
            }

            unchecked {
                ++i;
            }
        }

        // Default to the first operator delegator
        return operatorDelegators[0];
    }

Tools Used

Manual review

Recommended Mitigation Steps

The mechanism for choosing the operator delegator for a deposit should be improved to account for the deposit limitation variables of the strategies.

Assessed type

Context

QA Report

See the markdown file with the details of this report here.

Any collateral token's stale price will DOS multiple flows due to vulnerable oracle implementation

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L76

Vulnerability details

Impact

Any collateral token's stale price will DOS multiple flows due to vulnerable oracle implementation.

Proof of Concept

In RenzoOracle.sol, chainlink oracle price is used to calculate deposit token value or redeem token amount. However, current oracle stale price implementation will directly revert and DOS multiple flows if any oracle price becomes stale.

//contracts/Oracle/RenzoOracle.sol
    function lookupTokenValue(IERC20 _token, uint256 _balance) public view returns (uint256) {
        AggregatorV3Interface oracle = tokenOracleLookup[_token];
        if (address(oracle) == address(0x0)) revert OracleNotFound();

        (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData();
        //@audit if any collateral token's chainlink price is stale, lookupTokenValue(token) will revert.
|>      if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired();
        if (price <= 0) revert InvalidOraclePrice();

        // Price is times 10**18 ensure value amount is scaled
        return (uint256(price) * _balance) / SCALE_FACTOR;
    }

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L76)

As an example lookupTokenValue() is used in calcualteTVLs() which iteration over each collateral tokens to calculate totalTVLs. Any lookupTokenValue() revert will revert calcualteTVLs() which will DOS deposit flows(StakeManager.sol), withdraw flows(WithdrawQueue.sol) and the sendPrice (L1->L2) flow(xRenzoBridge.sol).

Tools Used

Manual

Recommended Mitigation Steps

Instead of revert, in lookupTokenValue()/ lookupTokenAmountFromValue(), consider adding a fall-back oracle when any chainlink oracle becomes stale.

Assessed type

Oracle

L2 Oracle Update Minting Delay causes incorrect accounting of xezETH and may cause undercollateralization

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L245-L246

Vulnerability details

Impact

xRenzoDeposit uses the L2 Oracle Data to determine how much xezETH to Mint

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L244-L245

        // Fetch price and timestamp of ezETH from the configured price feed
        (uint256 _lastPrice, uint256 _lastPriceTimestamp) = getMintRate();

It will then receive a WETH amount to deposit into ezETH and it will then proceed to mint ezETH for the corresponding amount

This logic has flaws, which will cause undercollateralization of xezETH in the Lockbox, this is because:

  • xezETH to ezETH is 1:1
  • L2 will mint a bit more xezETH using a stale exchange rate
  • L1 will mint a bit less ezETH using the current exchange rate
  • Some people will be unable to withdraw their ezETH due to this

Investigation

From my review, the lockbox is currently over-collateralized

https://docs.google.com/spreadsheets/d/11YceC1tdweSHn4zqsVJoCsOBYp8X_bmQ6-Z6_5usCeM/edit?usp=sharing

I attribute this to the fact that the stETH Feed is a Market Rate Feed

Which means that over time, the stETH feed is causing a slight reduction in the price of ezETH, making it so that the lockbox is minting more than intended

Empirically we have prove that the accounting is incorrect

That said the incorrect accounting currently ensures that withdrawers will be able to exit, while some ETH will be indefinitely stuck in the Renzo Contracts

Rationale around risk of insolvency

To keep things simple, we can think of ezETH as a stETH Wrapper

Depositing 1 stETH over time, will entitle to more stETH

The rate between ezETH and stETH is not dictated by it's TVL but rather the conversion of stETH to ETH via a Chainlink Price feed, which has at most 1 day of delay

The same logic applies between ezETH and xezETH, a feed with another day of delay is used.

This means that we can have 3 scenarios:

  • All feeds are synchronized (Optimistic case)
  • Sometimes one of the feed is delayed (Realistic case)
  • Sometimes both feeds are delayed, and minting on L2 gives more xezETH than intended (Pessimistic Case)

From this, assuming the pessimistic case can happen, we derive the following POC

POC

Let's assume a 1% Price change to simplify (a more likely price change would be betwee 1.5 BPS and 3 BPS)

  • Oracle is slow by definition
  • Minting on L1 will require 1 ETH
  • Minting on L2 will require 0.99 ETH
  • Mint on L2 -> For size that is above 5 times the 32 ETH cap
  • You pay less there
  • Go back to L1
  • You receive less than intended
  • Someone else will be unable to withdraw as the ratio of xEZETH to ezETH is incorrect

Mitigation

I believe that it would be best for Renzo to use fees as part of a mechanism to ensure solvency.

At 4% per year, we get 4/365 = 0.0109589041 APR

Meaning that in the average case, xezETH should lose around 1 BPS to oracle drift

Given that xezETH fees are 5 BPS, the fees should be able to cover this in most cases

Assessed type

MEV

The current idea of ​​creating ezETH and accepting several different assets in it exposes users to losses

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L491
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L206

Vulnerability details

Vulnerability details

Consider the following scenario (values used for ease of calculation and to illustrate the attack, real values will be presented later in this description):

Protocol supports two assets (cbETH and native ETH).

  • 200 ETH is deposited inside protocol by users and 200 ezETH were minted.

  • The attacker (cbETH staker) has 100 cbETH (price is e.g. 1 cbETH = 2 ETH, their cbETH is worth 200 ETH)

The attacker knows through monitoring slashing events and big withdrawalas that price will drop soon.

  • The attacker deposit their 100 cbETH to protocol to get 200 ezETH (as current price is still 1 cbETH = 2 ETH)
    Total value locked on protocol will increase from 200 ETH to 400 ETH (200 eth and 100 cbETH)

Price of cbETH now drops by 50% (so now 1 cbETH = 1 ETH)

Total value locked on protocol will decrease from 400 ETH to 300 ETH (as 200cbETH is now worth only 100 ETH).

  • The attacker decides to request withdraw all of their cbETH by burning only 150 ezETH and they also request to withdraw 50 ETH by burning another 100 ezETH.

  • Attacker gets 200 cbETH back (current price is 100 ETH) and additional 50 ETH.

  • Attacker buys additional cbETH for their additional 50 ETH, so know they have 250 cbETH (from another source)

Now price recover, so its again 1 cbETH = 2 ETH.

Attacker now have 250 cbETH worth 500 ETH, and users have 150 ETH (lost 50 ETH, as attacker delegeted their risk to users).

Example in the real life:

  1. cbETH (https://coinmarketcap.com/currencies/coinbase-wrapped-staked-eth/)
  1. wstETH (https://coinmarketcap.com/currencies/lido-finance-wsteth/)

Impact

When price of token drop, attacker can share lost for users in the protocol

Tools Used

Manual review

Recommended Mitigation Steps

Users could be allowed to withdraw only the type of assets they deposit

Assessed type

Context

ETH withdrawal from EigenLayer fails because of improper use of reentrancy guard.

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L501

Vulnerability details

Impact

ETH withdrawal does not work, withdrawn ETH assets are stuck in EigenLayer.

Proof of Concept

When withdrawals happen, completeQueuedWithdrawal is called to claim withdrawn assets from EigenLayer, which calls completeQueuedWithdrawal function of EigenLayer as well.
If native tokens(ETH) are included in the withdrawal batch, the EigenLayer tries to send ETH to the OperatorDelegator contract, which will call receive function of the contract.

The problem here is that both completeQueuedWithdrawal function and receive function has nonReentrant modifier, thus receiving ETH through receive function will revert.

function completeQueuedWithdrawal(
    IDelegationManager.Withdrawal calldata withdrawal,
    IERC20[] calldata tokens,
    uint256 middlewareTimesIndex
@> ) external nonReentrant onlyNativeEthRestakeAdmin {
    // ...
}

@> receive() external payable nonReentrant {
    // ...
}

Tools Used

Manual Review

Recommended Mitigation Steps

nonReentrant modifier should not be used in receive function.

Assessed type

DoS

Protocol supports `stETH` but doesn't consider its unique transfer logic which would lead to not only a DOS of the depositing/withdrawal channel for this collateral token but also a flaw in multiple other core protocol logic

Lines of code

ttps://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491-L576
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L664-L665
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Deposits/DepositQueue.sol#L134-L145
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L125-L152

Vulnerability details

Proof of Concept

NB: This bug report contains two sides of the coin on how the 1-2 wei corner case problem could affect renzo, 50% builds on the OOS safeApprove() from the bug report, however the second part of the report is in scope.

First, would be key to note that stETH is a special token when it comes to it's transfer logic, navigating to lido's official docs we can see that there is a special section that talks about it's unique concept, i.e the "1-2 wei corner case", see https://docs.lido.fi/guides/lido-tokens-integration-guide/#1-2-wei-corner-case, quoting them:

stETH balance calculation includes integer division, and there is a common case when the whole stETH balance can't be transferred from the account while leaving the last 1-2 wei on the sender's account. The same thing can actually happen at any transfer or deposit transaction. In the future, when the stETH/share rate will be greater, the error can become a bit bigger. To avoid it, one can use transferShares to be precise.

That's to say at any transfer tx there is a possibility that the amount that actually gets sent is up to 2 wei different, a minute value you might think, however when we couple this with the fact that protocol heavily uses safeApprove() to pass on approvals for collateral tokens before depositing or withdrawing, this corner case could then brick the protocol.

Now see OpenZeppelin's implementation of safeApprove() and how it will revert if the current allowance is non-zero and the approval attempt is also passing a non-zero value.

    function safeApprove(
        IERC20 token,
        address spender,
        uint256 value
    ) internal {
        // safeApprove should only be called when setting an initial allowance,
        // or when resetting it to zero. To increase and decrease it, use
        // 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
        require(
            //@audit
            (value == 0) || (token.allowance(address(this), spender) == 0),
            "SafeERC20: approve from non-zero to non-zero allowance"
        );
        _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
    }

Consider a minimalistic generic scenario:

  • Allowance is set by user A for user B to 1e18 "wei" stETH tokens.

  • User B attempts to transfer these tokens, however due to the corner case, stETH balance gets converted to shares, integer division happens and rounding down applies, the amount of tokens that are actually transferred would be 1e18 - 2 "wei" tokens.

  • Now user A assumes that user A has expended their allowance and attempts granting them a new allowance of a fresh 1e18 "wei" stETH tokens, doing this with the normal ERC20.approve() is going to go through, however user A attempts to do this with SafeERC20.safeApprove() which would fail cause SafeERC20.safeApprove() reverts on non-zero to non-zero approvals and user B is currently being approved of 2 wei tokens which they've not spent yet.

    More can be read on the "1-2 wei corner case" issue from here: lidofinance/lido-dao#442

The scenario above is quite generic but this exact idea can be applied to current protocol's logic of passing on allowances around contracts in scope, for example see RestakeManager.deposit() at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491-L576

    function deposit(
        IERC20 _collateralToken,
        uint256 _amount,
        uint256 _referralId
    ) public nonReentrant notPaused {
        // Verify collateral token is in the list - call will revert if not found
        uint256 tokenIndex = getCollateralTokenIndex(_collateralToken);

        // Get the TVLs for each operator delegator and the total TVL
        (
            uint256[][] memory operatorDelegatorTokenTVLs,
            uint256[] memory operatorDelegatorTVLs,
            uint256 totalTVL
        ) = calculateTVLs();

        // Get the value of the collateral token being deposited
        uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);

        // Enforce TVL limit if set, 0 means the check is not enabled
        if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) {
            revert MaxTVLReached();
        }

        // Enforce individual token TVL limit if set, 0 means the check is not enabled
        if (collateralTokenTvlLimits[_collateralToken] != 0) {
            // Track the current token's TVL
            uint256 currentTokenTVL = 0;

            // For each OD, add up the token TVLs
            uint256 odLength = operatorDelegatorTokenTVLs.length;
            for (uint256 i = 0; i < odLength; ) {
                currentTokenTVL += operatorDelegatorTokenTVLs[i][tokenIndex];
                unchecked {
                    ++i;
                }
            }

            // Check if it is over the limit
            if (currentTokenTVL + collateralTokenValue > collateralTokenTvlLimits[_collateralToken])
                revert MaxTokenTVLReached();
        }

        // Determine which operator delegator to use
        IOperatorDelegator operatorDelegator = chooseOperatorDelegatorForDeposit(
            operatorDelegatorTVLs,
            totalTVL
        );

        // Transfer the collateral token to this address
        _collateralToken.safeTransferFrom(msg.sender, address(this), _amount);

        // Check the withdraw buffer and fill if below buffer target
        uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit(
            address(_collateralToken)
        );
        if (bufferToFill > 0) {
            bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill;
            // update amount to send to the operator Delegator
            _amount -= bufferToFill;

            // safe Approve for depositQueue @audit
            _collateralToken.safeApprove(address(depositQueue), bufferToFill);

            // fill Withdraw Buffer via depositQueue
            depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill);
        }
        //@audit
        // Approve the tokens to the operator delegator
        _collateralToken.safeApprove(address(operatorDelegator), _amount);

        // Call deposit on the operator delegator
        operatorDelegator.deposit(_collateralToken, _amount);

        // Calculate how much ezETH to mint
        uint256 ezETHToMint = renzoOracle.calculateMintAmount(
            totalTVL,
            collateralTokenValue,
            ezETH.totalSupply()
        );

        // Mint the ezETH
        ezETH.mint(msg.sender, ezETHToMint);

        // Emit the deposit event
        emit Deposit(msg.sender, _collateralToken, _amount, ezETHToMint, _referralId);
    }

As hinted by the two @Audit tags, protocol uses safeApprove() to grant approval in this case to both the depositQueue and the operator delegator, note that the implementations of both operatorDelegator.deposit() and depositQueue.fillERC20withdrawBuffer() include transfers of the allowances they've already been given from the execution of RestakeManager.deposit() considering the transfers is going to get rounded down, then minute part of the allowance is going to be left untransferred and in consequent calls to safeApprove() when depositing stETH the call to safeApprove is going revert (as shown in the attached openZeppelin's safeApprove() snippet above) and throw an error since an attempt is being made to approve from a non-zero to a non-zero value, effectively bricking/DOS'ing the depositing logic for the supported stETH collateral token.


Now, parallel to the already explained issue with safeApprovals, this 1-2 wei corner case is going to also cause protocol to make a wrong assumption on the amount of tokens that were really transferred, would be key to note that the amount of ezETH that get minted to the msg.sender is directly proportional to the collateral value of the amount of tokens that were considered to be "transferred" in to the RestakeManager during the deposit attempt.

Evidently, since during deposits, protocol assumes the amount of tokens that was specified in the safeTransferFrom() is actually the amount of tokens that end up getting transferred in, the amount of ezETH that gets minted for users is going to be inflated as more than what was transferred in is going to be considered as the amount transferred in when calculating the collateral token being deposited https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L506-L508

        // Get the value of the collateral token being deposited
        uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);

The logic from the last two paragraphs hint that the stETH token does somewhat behave like the popular Fee-On-Transfertokens , albeit in this case the discrepancy in the amount of tokens being recieved is due to rounding down and not fees, also this could lead to the depositing/withdrawing logic of the XERC20Lockbox to also work with flawed data.


NB: This report hints other instances where protocol's logic could be flawed due to not considering the transfer logic attached to stETH, however the report mainly focuses on only RestakeManager#deposit() &RestakeManager#depositTokenRewardsFromProtocol() as these instances are enough to prove the bug case (keep in mind that this function is always queried whenever there is a need to sweep any accumulated ERC20 tokens in the DepositQueue to the RestakeManager, other instances still exist in scope however, like depositQueue.fillERC20withdrawBuffer() & operatorDelegator.deposit(), but in short this bug case can be applied to all instances where protocol attempts to query safeApprove() on the collateral token as can be pinpointed using this search command, i.e the DepositQueue#fillERC20withdrawBuffer() could now encounter a failure making it impossible fill the up the stETHbuffer in the withdraw queue, extensively this subtly affects all the collateral token transfer logic too.

Impact

This bug cases leads to multiple issues and the root cause is the fact that protocol does not take the 1-2 wei corner case of stETH into mind, a few noteworthy impacts would be:

  • When the allowance of the supported stETH token is non-zero in instances where protocol thinks it's already zero, all attempts to safeApprove() on this collateral token (stETH) is going to fail DOSing the depositing attempts, making protocol's core functionality unavailable to some users.

  • Additionally, the accounting of the backed collateral for minted ezETH could now be flawed since it's going to assume the wrong amount of collaterals are backing already minted assets which covers the main window under the requested bug windows/attack ideas since the integrity on the TVL calculations (ezETH Pricing) is now going to be slightly flawed, i.e users are now going to mint & withdraw at slightly invalid prices considering the stETH is a core integrated token and with multiple transfers this minute differences could amount to quite a reasonable sum.

  • So, this means that depositing into the strategy manager for the stETH collateral token would also be broken.

  • This bug case also makes it impossible to complete queued withdrawals for stETH from OperatorDelegator.sol, since the channel is going to be DOS'd when the residual amount of approval already made to deposit queue causes this attempt at a new approval to fail, showcasing how the withdrawal channel is also going to be DOS'd.

  • Another subtle one, would be the Inability to fill up the withdrawal queue buffer when needed for the stETHtoken, (albeit in this case as hinted by protocol the admins can manually unstake to ensure the buffer is at where it needs to be).

  • Finally, there seems to be a subtle edge case, where, if every user is to attempt withdrawing their deposited assets back, the last set of users might not receive their assets it due to protocol not having enough assets backed for ezETH already minted to send to back to users.

And extensively many more ways where/how this bug case could impact protocol, just depends on the context in which safeApprove() is being applied or instances where an assumption is being made that the amount specified in the transfer is actually what's been received.

Recommended Mitigation Steps

First consider scraping the idea of safeApprove() for stETH, since the all assets being supported right now (ezETH, stETH, wBETH) are standard tokens then the normal ERC20.approve() can be used which wouldn't revert on non-zero to non-zero approvals.

For the parallel case in regards to the amount specified in the transferral attempt not being the amount that gets transferred in, then the differences in balance could be used to see the real amount that was transferred in and then use this value to calculate the amount of ezETH to be minted.

Alternatively, protocol can just consider integrating wstETH instead of stETH as suggested by the official Lido docs for more ease in regards to DEFI integration, see how this can be done here.

Assessed type

Token-Transfer

There is currently no max amount of operators that can be added which can break contract's functionalities by having users withdrawal being Dos'd to faulting cross chain functionalities

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L216-L224
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RateProvider/BalancerRateProvider.sol#L31
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L131-L157

Vulnerability details

Proof of Concept

Take a look at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L131-L157

    function addOperatorDelegator(
        IOperatorDelegator _newOperatorDelegator,
        uint256 _allocationBasisPoints
    ) external onlyRestakeManagerAdmin {
        // Ensure it is not already in the list
        uint256 odLength = operatorDelegators.length;
        for (uint256 i = 0; i < odLength; ) {
            if (address(operatorDelegators[i]) == address(_newOperatorDelegator))
                revert AlreadyAdded();
            unchecked {
                ++i;
            }
        }

        // Verify a valid allocation
        if (_allocationBasisPoints > (100 * BASIS_POINTS)) revert OverMaxBasisPoints();

        // Add it to the list
        operatorDelegators.push(_newOperatorDelegator);

        emit OperatorDelegatorAdded(_newOperatorDelegator);

        // Set the allocation
        operatorDelegatorAllocations[_newOperatorDelegator] = _allocationBasisPoints;

        emit OperatorDelegatorAllocationUpdated(_newOperatorDelegator, _allocationBasisPoints);
    }

This is the function used by restake manager admin to add an OperatorDelegator to the list of ODs, would be key to note that there is no check whatsoever on the amount of already added operators meaning no limit to the amount of operators that could be added and with time a lot more operators would be added.

Now would be key to note that the RestakeManager.calculateTVLs() is a hefty gas consuming function since it loops through all the existing operators and having the list be extensively long would lead this to an OOG if coupled with the gas already burnt on the side by the core functionality calling this in some instance.

Now this function is called whenever withdrawing via the withdrawal queue, i,e https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Withdraw/WithdrawQueue.sol#L216-L224

    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
(...snip)
        // calculate totalTVL
        (, , uint256 totalTVL) = restakeManager.calculateTVLs();

        // Calculate amount to Redeem in ETH
        uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(
            _amount,
            ezETH.totalSupply(),
            totalTVL
        );
(...snip)
}

And also when getting the current rate of ezETH in ETH via the BalancerRateProvider.

Impact

  • Withdrawal attempts would be bricked, leaving user funds stuck in the protocol in the case of the WithdrawQueue.withdraw().
  • For BalancerRateProvider.getRate() however this function is heavily used across protocol, with implementations in the xRenzoBridge which would then mean that functionalities that need price to be sent to the CCIP destination via xRenzoBridge.sendPrice() would all fail faulting the cross chain functionality of protocol.

Recommended Mitigation Steps

Consider having a max accepted figure for the operator delegators.

Assessed type

DoS

Vulnerable L1/L2 timestamp check might randomly DOS price update

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L350

Vulnerability details

Impact

Vulnerable L1/L2 timestamp check might randomly DOS price update.

Proof of Concept

When sending L1 ezETH price to L2, xRenzoDeposit::updatePrice will be called to record new price. In _updatePrice(), a check is ensuring the timestamp of L1 price will not be greater than current L2 timestamp (block.timestamp).

//contracts/Bridge/L2/xRenzoDeposit.sol
    function _updatePrice(uint256 _price, uint256 _timestamp) internal {
...
        // Do not allow future timestamps
|>      if (_timestamp > block.timestamp) {
            revert InvalidTimestamp(_timestamp);
        }
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L350)

This check is vulnerable because L2 block implementation might result in a slight delay of L2 block.timestamp compared to L1 timestamp. For example, Arbitrum will normally update L2 block and L2 timestamp every minute to catch up with L1. This mechanism in normal condition cause L1 timestamp (updated every 12s) to be greater than the L2 timestamp (updated every minute). This delay might revert a valid price update.

If a valid price update is reverted and if no back-up oracle is set up, an outdated ezETH price will be used in xRenzoDeposit::deposit.(deposit()->getMintRate()->lastPrice)

Tools Used

Manual

Recommended Mitigation Steps

Consider relaxing this timestamp check account for possible L2 timestamp delay.

Assessed type

Other

OperatorDelegator contract stops working when the operator of EigenLayer un-delegates the stake through EigenLayer

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L117-L130

Vulnerability details

Impact

OperatorDelegator stops working and assets are locked in EigenLayer.

Proof of Concept

The Renzo protocol's OperatorDelegator contract is designed to set delegate address only once:

function setDelegateAddress(...) external nonReentrant onlyOperatorDelegatorAdmin {
    // ...
@>  if (address(delegateAddress) != address(0x0)) revert DelegateAddressAlreadySet();
    // ...
}

However in EigenLayer, once a staker delegates the assets to an operator, either staker or the operator can undelegate the assets from the operator, as shown in the code snippet of EigenLayer:

function undelegate(address staker) external onlyWhenNotPaused(PAUSED_ENTER_WITHDRAWAL_QUEUE) returns (bytes32[] memory withdrawalRoots) {
    // ...

    address operator = delegatedTo[staker];
    require(
        msg.sender == staker ||
@>          msg.sender == operator ||
            msg.sender == _operatorDetails[operator].delegationApprover,
        "DelegationManager.undelegate: caller cannot undelegate staker"
    );

    // ...
}

When the operator of EigenLayer un-delegates the assets from a OperatorDelegator contract, all delegated assets are queued for withdrawal.
The only way to have the assets back to OperatorDelegator contract is to call completeQueuedWithdrawal function by an admin. However, this reverts because of unavailable tracking of queuedShares which should have been tracked from queueWithdrawals function, as shown in the code snippet below:

function completeQueuedWithdrawal(
    IDelegationManager.Withdrawal calldata withdrawal,
    IERC20[] calldata tokens,
    uint256 middlewareTimesIndex
) external nonReentrant onlyNativeEthRestakeAdmin {
    // ...
    for (uint256 i; i < tokens.length; ) {
        // ...

        // @audit revert here because queuedShares is zero
        queuedShares[address(tokens[i])] -= withdrawal.shares[i];

        // ...
    }

    // ...
}

As a result, the assets can't be withdrawn and remains in EigenLayer.

Tools Used

Manual Review

Recommended Mitigation Steps

When undelegate happens by EigenLayer's operator, there should be a function to complete the withdrawal without affecting queuedShares, as well as there should be a feature to re-delegate to another operator.

Assessed type

DoS

Vulnerable price check implementation cause revert and DOS based on current ezETH price conditions

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/Oracle/RenzoOracleL2.sol#L55

Vulnerability details

Impact

Vulnerable price check implementation will cause revert and DOS in current ezETH price conditions.

Proof of Concept

ezETH oracle price check in RenzoOracleL2::getMintRate assumes ezETH is always valued more then ETH, due to ezETH is supposed to be yield-bearing.

//contracts/Bridge/L2/Oracle/RenzoOracleL2.sol
    function getMintRate() public view returns (uint256, uint256) {
        (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData();
...
|>      if (_scaledPrice < 1 ether) revert InvalidOraclePrice();
        return (_scaledPrice, timestamp);

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/Oracle/RenzoOracleL2.sol#L55)

However, this check will cause revert and DOS in L2 because the current ezETH/ETH price is 0.98. ezETH is currently valued less than ETH in chainlink price feed.
Screenshot 2024-05-08 at 9 06 00 AM (2)

It can be argued that although ezETH is intended to be yield-bearing, the actual oracle price of ezETH can be dependent on many factors: initial supply(current ezETH totalSupply is 1050483 ether), DEX trading, etc. As such, strictly assuming ezETH/ETH < 1 at all time is nonfactual. Although over time with increased rewards and staking, ezETH price should be expected to be valued more than ETH, this is likely not the case at the time of new contract deployment.

As a result, RenzoOracleL2::getMintRate revert will DOS xRenzoDeposit::deposit flow(->xRenzoDeposit::getMintRate). In xRenzoDeposit::getMintRate, RenzoOracleL2's price feed will be called first, which reverts deposit tx.

Tools Used

Manual

Recommended Mitigation Steps

Consider relaxing the range in RenzoOracleL2::getMintRate price check to account for the current ezETH price condition.

Assessed type

Other

stETH/ETH Feed being used opens up to 2 way deposit<->withdrawal arbitrage

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L70-L81

Vulnerability details

Impact

The stETH/ETH oracle is not a exchange rate feed, it's a Market Rate Feed

While other feeds are exchange rate feeds

This opens up ezETH to be vulnerable to:

  • Market Rate Manipulations
  • Sentiment based Price Action
  • Duration based discounts

POC

This opens up to arbitrage anytime stETH trades at a discount (see Liquidations on the 13th of April)

Had withdrawals been open, the following could have been possible:

  • Deposit stETH before the Depeg (front-run oracle update)
  • Get ezETH
  • Withdraw stETH after the depeg (1% a day, around 365% APR)

As well as:

  • stETH market depegs
  • Deposit ETH for ezETH
  • Withdraw stETH at premium (about 1% arbitrage, around 365% APR)

Mitigation

I believe the withdrawal logic needs to be rethought to be denominated in ETH

The suggested architecture would look like the following:

  • Deposit of ETH or LSTs, estimated via a pessimistic exchange rate
  • Withdraw exclusively ETH, while pricing in slashing, discounts and operative costs

Assessed type

Oracle

Missing whenNotPaused modifier in withdraw/claim function, users can still withdraw/claim even when paused

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L206
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L279

Vulnerability details

Impact

Missing whenNotPaused modifier in withdraw/claim function, users' can withdraw/claim even when paused.

Proof of Concept

In WithdrawQueue.sol, withdrawQueueAdmin is intended to pause withdraw/claim when necessary. Note that pause() /unpause() are implemented. Also, whenNotPaused is defined from the parent PausableUpgradeable contract.

However, whenNotPaused modifier is not used in any function, notably missing in permissionless withdraw/claim, which invalidates the pausing feature and allow users to freely withdraw and claim when deposit/withdraw is paused
(1)

//contracts/Withdraw/WithdrawQueue.sol
    //@audit missing whenNotPaused modifier and no check on paused in function body
    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L206)
(2)

//contracts/Withdraw/WithdrawQueue.sol
    //@audit missing whenNotPaused modifier and no check on paused in function body
    function claim(uint256 withdrawRequestIndex) external nonReentrant {
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L279)

Tools Used

Manual

Recommended Mitigation Steps

Add whenNotPaused modifier to withdraw/claim.

Assessed type

Other

Incorrect array index cause multiple flow DOS due to out-of-bound error

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L318

Vulnerability details

Impact

Incorrect array index cause multiple flow DOS due to out-of-bound error.

Proof of Concept

In restakeManager::calculateTVLs, incorrect array index is used for collateralTokens[] in the second for-loop.

When calculating totalWithdrawlQueueValue, collateralTokens[i] is paired with collateralTokens[j].balanceOf(withdrawQueue).i is the index based on operatorDelegators.length. This means that if operatorDelegators.length > collateralTokens.length, collateralTokens[i] will cause out-of-bound error and revert calculateTVLs tx.

//contracts/RestakeManager.sol
    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
...
        // Iterate through the ODs
        uint256 odLength = operatorDelegators.length;
...
        for (uint256 i = 0; i < odLength; ) {
...
            // Iterate through the tokens and get the value of each
            uint256 tokenLength = collateralTokens.length;
            for (uint256 j = 0; j < tokenLength; ) {
...
                if (!withdrawQueueTokenBalanceRecorded) {
//@audit When operatorDelegators.length > collateralTokens.length,  collateralTokens[i] will cause out-of-bound error
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
|>                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue)
                    );
                }
...

(https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L318)

As a result, any flow that consumes calculateTVLs will be DOSsed, which include restakeManager::deposit, restakeManger::depsotiETH, estakeManager::depositTokenRewardsFromProtocol, RenzoBridge::sendPrice,etc.

Tools Used

Manual

Recommended Mitigation Steps

Change into renzoOracle.lookupTokenValue( collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue) );

Assessed type

Error

QA Report

See the markdown file with the details of this report here.

`calculateTVLs()` function return wrong total value in case of multiple token supported

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L274-#L358

Vulnerability details

Vulnerability details

Function calculateTVLs() is used to calculate TVL of protocol. It also get tokens from withdrawQueue contract:

    bool withdrawQueueTokenBalanceRecorded = false;
    .  .  .  .  .  .  .  .
    for (uint256 i = 0; i < odLength; ) {
    .  .  .  .  .  .  .  .
        for (uint256 j = 0; j < tokenLength; ) {
        .  .  .  .  .  .  .  .
            if (!withdrawQueueTokenBalanceRecorded) {
                totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                    collateralTokens[i],   // <--
                    collateralTokens[j].balanceOf(withdrawQueue)
                );
            }
        .  .  .  .  .  .
        }
        .  .  .  .  .  .
        withdrawQueueTokenBalanceRecorded = true;
    }

The problem is it use wrong collateral token address to look up value, i variable only can equal to 0, while j can be equal to 1, 2, 3, 4, ..., lead to wrong collateral address be used, which will lead to wrong TVL calculated because each token have different price

Impact

TVL is wrongly calculated lead to massive issue

Tools Used

Manual review

Recommended Mitigation Steps

Update calculateTVLs() function to:

            if (!withdrawQueueTokenBalanceRecorded) {
                totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
            -       collateralTokens[i],
            +       collateralTokens[j],
                    collateralTokens[j].balanceOf(withdrawQueue)
                );
            }

Assessed type

Context

1 day staleness check is too tight as that's the update threshold for stETH/ETH

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L26-L27
https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L248-L249

Vulnerability details

Impact

The update threshold is the time after which a new round will be started

That is not the time at which the next round will end

Meaning that a 1 hour check should cause DOS for a few minutes every day

The RenzoOracle uses the following staleness window

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Oracle/RenzoOracle.sol#L26-L27

    uint256 constant MAX_TIME_WINDOW = 86400 + 60; // 24 hours + 60 seconds

Similarly xRenzoDeposit use the following staleness window:

https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Bridge/L2/xRenzoDeposit.sol#L248-L249

        if (block.timestamp > _lastPriceTimestamp + 1 days) {

These will cause temporary DOSses during time of high congestion as well as during most daily updates

POC

  • Oracle updates starts after 24 hours
  • ezETH Oracle Consumer will fail the staleness check
  • After a few minutes the round will end
  • And the ezETH Oracle Consumer will be able to operate again

Mitigation

Change the threshold to 24 hours + 1 hour at least

Assessed type

Oracle

Operator delegator cannot fill withdraw buffer

Lines of code

https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L300-L303
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Deposits/DepositQueue.sol#L137

Vulnerability details

Impact

In completeQueuedWithdrawal it withdraws from Eigenlayer and then should fill the withdrawal buffer deficit if necessary:

    function completeQueuedWithdrawal(
        IDelegationManager.Withdrawal calldata withdrawal,
        IERC20[] calldata tokens,
        uint256 middlewareTimesIndex
    ) external nonReentrant onlyNativeEthRestakeAdmin {
        ...

        // complete the queued withdrawal from EigenLayer with receiveAsToken set to true
        delegationManager.completeQueuedWithdrawal(withdrawal, tokens, middlewareTimesIndex, true);

        IWithdrawQueue withdrawQueue = restakeManager.depositQueue().withdrawQueue();
        for (uint256 i; i < tokens.length; ) {
            ...

            // check if token is not Native ETH
            if (address(tokens[i]) != IS_NATIVE) {
                // Check the withdraw buffer and fill if below buffer target
                uint256 bufferToFill = withdrawQueue.getBufferDeficit(address(tokens[i]));

                // get balance of this contract
                uint256 balanceOfToken = tokens[i].balanceOf(address(this));
                if (bufferToFill > 0) {
                    ...

                    // safe Approve for depositQueue
                    tokens[i].safeApprove(address(restakeManager.depositQueue()), bufferToFill);

                    // fill Withdraw Buffer via depositQueue
                    restakeManager.depositQueue().fillERC20withdrawBuffer(
                        address(tokens[i]),
                        bufferToFill
                    );
                }

                ...
            }
            unchecked {
                ++i;
            }
        }

        ...
    }

However, the fillERC20withdrawBuffer function should revert in runtime because it has an access modifier so that only the restake manager can call it:

    function fillERC20withdrawBuffer(
        address _asset,
        uint256 _amount
    ) external nonReentrant onlyRestakeManager

With the current version, it would be impossible to complete the withdrawal but because the contracts are upgradeable, I have given this issue a Medium severity because assets are only temporarily inaccessible.

Tools Used

Manual review.

Recommended Mitigation Steps

Maybe anyone should be able to fill the withdrawal buffer no matter where it comes from. A permissionless function that does not care where the tokens come from as long as the balance is increased.

Assessed type

Access Control

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.