Renzo - eeshenggoh's results

A protocol that abstracts all staking complexity from the end-user and enables easy collaboration with EigenLayer node operators and a Validated Services (AVSs).

General Information

Platform: Code4rena

Start Date: 30/04/2024

Pot Size: $112,500 USDC

Total HM: 22

Participants: 122

Period: 8 days

Judge: alcueca

Total Solo HM: 1

Id: 372

League: ETH

Renzo

Findings Distribution

Researcher Performance

Rank: 31/122

Findings: 3

Award: $260.01

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Labels

bug
3 (High Risk)
insufficient quality report
satisfactory
sufficient quality report
:robot:_42_group
duplicate-87

Awards

257.3101 USDC - $257.31

External Links

Lines of code

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

Vulnerability details

Renzo has a buffer that acts as a pool of assets to support withdrawal requests. There is a need for buffer in order to support withdrawal of assets.

The withdrawQueue contract get filled by 3 ways ->

  1. New Deposits
  2. Daily Rewards Coming in the Protocol.
  3. Manual withdrawal from EigenLayer. Permissioned call from OperatorDelegator.

If options 1 and 2 are not sufficient to fulfill withdraw requests of Users then admin accounts will manually unstake from EigenLayer through 2 step process:

  1. OperatorDelegator.queueWithdrawals
  2. OperatorDelegator.completeWithdrawals
// OperatorDelegator.sol
    function completeQueuedWithdrawal(
        IDelegationManager.Withdrawal calldata withdrawal,
        IERC20[] calldata tokens,
        uint256 middlewareTimesIndex
    ) external nonReentrant onlyNativeEthRestakeAdmin {
    -- SNIP --
                    restakeManager.depositQueue().fillERC20withdrawBuffer( //@audit
                        address(tokens[i]),
                        bufferToFill
                    );
    -- SNIP --

The fillERC20withdrawBuffer is called to fill up ERC20 withdraw buffer. However the function call will revert because that function required to be called by the restake manager admin. In Renzo, each and every roles are unique and have their own functionalities to perform. This means that whenever there is a need to fill up the buffer, the 3rd option will not be able to do so, bricking the protocol if there isn't enough assets to cover the withdrawal requests.

Furthermore, the Operator will not be able to unstake delegated funds from Eigenlayer. Assets remain frozen due to the inability to complete the steps required for withdrawal.

// DepositQueue.sol
    /// @dev Allows only the RestakeManager address to call functions
    modifier onlyRestakeManager() {
        if (msg.sender != address(restakeManager)) revert NotRestakeManager();
        _;
    }

    function fillERC20withdrawBuffer(
        address _asset,
        uint256 _amount
    ) external nonReentrant onlyRestakeManager { //@audit
    -- SNIP --

Impact

  1. Buffer will never be filled in the case of supporting withdrawal requests.
  2. Operator unable to unstake delegated assets from Eigenlayer, causing assets to be frozen.

Tools Used

Manual Review

Remove the access control for only restake manager, leaving the function similar to a donation function.

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

Assessed type

Access Control

#0 - c4-judge

2024-05-20T04:07:53Z

alcueca marked the issue as satisfactory

Lines of code

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

Vulnerability details

The calculateTVLs() function in RestakeManager calculates TVLs for each operator delegator per token, total per OD, and total for the protocol.

It employs two for loops using elements i and j, where i is used for the outer loop and j for the inner loop. The function calls RenzoOracle::lookupTokenValue() to get the asset value in the underlying currency based on a single token and balance.

However, due to always using the outer loop element i, it only loops through the first collateral token, resulting in the return value for that token. This affects the totalWithdrawalQueueValue and the overall asset value locked.

For example, with whitelisted tokens [ezETH, stETH, wBETH], using i for ezETH means the loop always uses the ezETH price feed. This can lead to incorrect calculations when considering the amounts and price feeds of other tokens like stETH and wBETH.

Whenever user submit withdraw request, it will always produce the wrong amount converted to underlying asset.

Impact

This can produce false results in the total value locked calculations, impacting crucial state variables like the amount to redeem in ETH/LST.

Proof of Concept

    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
-- SNIP --
                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
                        collateralTokens[i],
                        collateralTokens[j].balanceOf(withdrawQueue) //@audit redundant since it was already added in getTokenBalanceFromStrategy
                    );
                }
-- SNIP --
        // Add native ETH help in withdraw Queue and totalWithdrawalQueueValue to totalTVL
        totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);

Tools Used

Manual Review

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

                if (!withdrawQueueTokenBalanceRecorded) {
                    totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
--                      collateralTokens[i],
++                      collateralTokens[j],
                        collateralTokens[j].balanceOf(withdrawQueue) //@audit redundant since it was already added in getTokenBalanceFromStrategy
                    );
                }

Assessed type

Loop

#0 - c4-judge

2024-05-16T10:31:18Z

alcueca marked the issue as satisfactory

#1 - c4-judge

2024-05-16T10:38:47Z

alcueca changed the severity to 2 (Med Risk)

#2 - c4-judge

2024-05-16T10:39:08Z

alcueca changed the severity to 3 (High Risk)

#3 - c4-judge

2024-05-20T04:26:26Z

alcueca changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-05-23T13:47:20Z

alcueca changed the severity to 3 (High Risk)

Awards

2.6973 USDC - $2.70

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
:robot:_06_group
duplicate-569

External Links

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

The protocol can be paused by the admin DEPOSIT_WITHDRAW_PAUSER and WITHDRAW_QUEUE_ADMIN for DepositQueue.sol and WithdrawalQueue.sol. It allows the admin to pause/unpause deposits and withdraws. Users are not allowed to deposit or withdraw when paused. However, the user is still able to withdraw even if protocol is paused.

The WithdrawalQueue::withdraw() and WithdrawalQueue::claim() did not enforce the Openzeppelins whenNotPaused modifier. This means that if the admin were to call the WithdrawalQueue::pause(), it does not affect the withdraw flow. Hence, users are still able to submit withdrawal request and withdraw their underlying assets after 7 days.

    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
    function claim(uint256 withdrawRequestIndex) external nonReentrant {

Impact

Users can withdraw underlying assets even if the Renzo is paused.

Tools Used

Manual Review

Include the whenNotPaused modifier to vital functions.

--    function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
++    function withdraw(uint256 _amount, address _assetOut) external nonReentrant whenNotPaused{
--    function claim(uint256 withdrawRequestIndex) external nonReentrant {
++    function claim(uint256 withdrawRequestIndex) external nonReentrant whenNotPaused{

Assessed type

Access Control

#0 - c4-judge

2024-05-16T10:48:13Z

alcueca changed the severity to 2 (Med Risk)

#1 - c4-judge

2024-05-16T10:50:31Z

alcueca marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter