Renzo - FastChecker'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: 51/122

Findings: 6

Award: $17.75

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

13.5262 USDC - $13.53

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
edited-by-warden
:robot:_97_group
duplicate-395

External Links

Lines of code

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

Vulnerability details

Impact

Incorrect token address specified in OperatorDelegator.sol#getTokenBalanceFromStrategy() function As a result, TVL is calculated incorrectly and the ezETH token can be mint or withdraw at invalid prices.

Proof of Concept

The OperatorDelegator.sol#getTokenBalanceFromStrategy() function used to get the underlying token amount from the amount of shares + queued withdrawal shares of the OperatorDelegator in the RestakeManager.sol#calculateTVLs() function.

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

        for (uint256 i = 0; i < odLength; ) {
            // Track the TVL for this OD
            uint256 operatorTVL = 0;

            // Track the individual token TVLs for this OD - native ETH will be last item in the array
            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

302:            uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(
303:                collateralTokens[j]
304:            );

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

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

                SNIP...
            }

            SNIP...

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

            SNIP...
        }

        SNIP...

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }

As you can see, the underlying token amount from the amount of shares + pending withdrawal shares of the operatorDelegator is calculated in #L302 and then added to totalTVL in #L338. However, an error occurs in the calculation because the wrong token address is used in the getTokenBalanceFromStrategy() function.

    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)]
                    );
    }

The queuedShares is mapping of the token shares in the EigenLayer's withdrawal queue in here. As you can see, the address used in #L329 is not the address of the specified token, but address(this), that is, the address of the OperatorDelegator itself. Therefore, Always queuedShares[address(this)] = 0 and as a result, the underlying token amount from the amount of shares is not included. That is, totalTVL becomes much smaller in the calculateTVLs() function. As a result, the ezETH token can be mint or withdraw at invalid prices.

Tools Used

Manual Review

Add the following line in the OperatorDelegator.sol#getTokenBalanceFromStrategy() function.

    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

Invalid Validation

#0 - c4-judge

2024-05-16T10:44:33Z

alcueca marked the issue as satisfactory

Lines of code

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

Vulnerability details

Impact

The ezETH token can be mint or withdraw at invalid prices because the index of collateralToken is specified incorrectly when calculating the token value of withdraw queue in the RestakeManager.sol#calculateTVLs() function.

Proof of Concept

The RestakeManager.sol#calculateTVLs() function is a function that calculates TVL.

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

        // withdrawalQueue total value
283:    uint256 totalWithdrawalQueueValue = 0;

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

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

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

                unchecked {
                    ++j;
                }
            }

            SNIP...

            // Set withdrawQueueTokenBalanceRecorded flag to true
344:        withdrawQueueTokenBalanceRecorded = true;

            unchecked {
                ++i;
            }
        }

        SNIP...

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

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }

In here, The code snippet in #L316~#L321 is the part that calculates the token value of the withdraw queue. Also, this code snippet is triggered only when i=0 by #L283 and #L344. Therefore, collateralTokens[i] in #L318 always specifies the first token in the token array. On the other hand, let's look at RenzoOracle.sol#lookupTokenValue().

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

As you can see on the right, the token value is calculated based on the price of the specified token. As a result, the value of all tokens in the withdraw queue is incorrectly calculated as the price of collateralToken[0] by #L318. Therefore, the ezETH token can be mint or withdraw at invalid prices.

Tools Used

Manual Review

Modify the code snippet of #L316~#L321 as follows.

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

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

        SNIP...
    }

Assessed type

Other

#0 - c4-judge

2024-05-16T10:36:56Z

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)
satisfactory
sufficient quality report
:robot:_06_group
duplicate-569

External Links

Lines of code

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

Vulnerability details

Impact

If the protocol pauses withdrawal in an emergency situation due to a lack of modifier in the WithdrawQueue.sol#withdraw() function, the protocol may suffer a unexpected loss of funds.

Proof of Concept

In WithdrawQueue, the protocol can pause withdrawals in emergency situations.

    function pause() external onlyWithdrawQueueAdmin {
        _pause();
    }

However, the whenNotPaused() modifier is not defined in the WithdrawQueue.sol#withdraw() function.

    function withdraw(uint256 _amount, address _assetOut) external nonReentrant 

Therefore, even if the protocol pauses withdraw() in an emergency situation, the withdraw() function is executed. Ultimately, this may result in unexpected loss of funds in the protocol.

Tools Used

Manual Review

Add the whenNotPaused() modifier to the WithdrawQueue.sol#withdraw() function.

Assessed type

Other

#0 - c4-judge

2024-05-16T10:51:00Z

alcueca marked the issue as satisfactory

Awards

1.479 USDC - $1.48

Labels

bug
2 (Med Risk)
satisfactory
sponsor disputed
sufficient quality report
:robot:_primary
:robot:_19_group
duplicate-484

External Links

Lines of code

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

Vulnerability details

Impact

The lack of slippage control for deposit(), withdraw() function can lead to a loss of assets for the affected users.

Proof of Concept

The RestakeManager.sol#deposit() and WithdrawQueue.sol#withdraw() functions call the RenzoOracle.sol#calculateMintAmount() and RenzoOracle.sol#calculateRedeemAmount() functions to calculate the amount of shares to be minted or assets to be withdrawed. RestakeManager.sol#deposit():

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

        // Calculate how much ezETH to mint
565:    uint256 ezETHToMint = renzoOracle.calculateMintAmount(
566:        totalTVL,
567:        collateralTokenValue,
568:        ezETH.totalSupply()
569:    );

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

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

And then, the formula that used in RenzoOracle.sol#calculateMintAmount() as follows.

ezETHToMint = collateralTokenValue * ezETH.totalSupply() / totalTVL

WithdrawQueue.sol#withdraw():

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

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

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

        // Calculate amount to Redeem in ETH
220:    uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(
221:        _amount,
222:        ezETH.totalSupply(),
223:        totalTVL
224:    );

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

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

        SNIP...
    }

And then, the formula that used in RenzoOracle.sol#calculateRedeemAmount() as follows.

amountToRedeem = _amount * totalTVL / ezETH.totalSupply()

When deposit, the user will deposit underlying assets (e.g., ETH) to the DepositQueue contract, and the nativeRestakeAdmin will restake to the EigenLayer. The number of shares minted is depending on the current mint rate of the protocol(i.e. totalTVL / ezETH.totalSupply). The current mint rate can increase or decrease at any time, depending on the current on-chain condition when the transaction is executed. For instance, if the transaction that deposit/withdraw mass amount before the transaction is excuted, the current mint rate can increase or decrease. Thus, one cannot ensure the result from the off-chain simulation will be the same as the on-chain execution. Having said that, the number of shared minted will vary (larger or smaller than expected) if there is a change in the current mint rate. Assuming that Alice determined off-chain that depositing 100 ETH would issue $x$ amount of ezETH. When she executes the TX, the scale increases, leading to the amount of PT/YT issued being less than $x$. The slippage is more than what she can accept. This slippage also applies to withdrawals. In summary, the deposit/withdraw function lacks the slippage control that allows the users to revert if the amount of ezETH or underlying assets they received is less than the amount they expected.

Tools Used

Manual Review

Implement slippage control in RestakeManager.sol#deposit() and WithdrawQueue.sol#withdraw() functions.

Assessed type

MEV

#0 - jatinj615

2024-05-14T15:46:34Z

Expected Behaviour.

#1 - C4-Staff

2024-05-15T14:38:46Z

CloudEllie marked the issue as primary issue

#2 - alcueca

2024-05-17T13:25:40Z

I disagree that this can be expected behaviour. I assume that the sponsor means that they believe that the users will find it acceptable, and the sponsor acknowledges the issue without intending to fix it.

#3 - c4-judge

2024-05-17T13:29:11Z

alcueca marked the issue as satisfactory

#4 - c4-judge

2024-05-17T13:29:14Z

alcueca marked the issue as selected for report

#5 - c4-judge

2024-05-17T13:44:25Z

alcueca marked issue #484 as primary and marked this issue as a duplicate of 484

Awards

0.0402 USDC - $0.04

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
:robot:_20_group
duplicate-198

External Links

Lines of code

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

Vulnerability details

Impact

Assets amount smaller than the empty withdraw buffer amount cannot be deposited.

Proof of Concept

The RestakeManager.sol#deposit() function checks the restake buffer and fills it if it is lower than the buffer target.

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

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

        // Check the withdraw buffer and fill if below buffer target
543:    uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit(
            address(_collateralToken)
        );
        if (bufferToFill > 0) {
547:        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
559:    _collateralToken.safeApprove(address(operatorDelegator), _amount);

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

        SNIP...
    }

In #L543, bufferToFill represents an empty withdrawal buffer for a specific asset. If the deposit amount (_amount) is less than bufferTofill in #L547, bufferTofill returns _amount. As a result, _amount returns 0 in #L549. Therefore, OperatorDelegator.sol#deposit() function will be reverted in #L562.

    function deposit(
        IERC20 token,
        uint256 tokenAmount
    ) external nonReentrant onlyRestakeManager returns (uint256 shares) {
147:    if (address(tokenStrategyMapping[token]) == address(0x0) || tokenAmount == 0)
148:        revert InvalidZeroInput();

        // Move the tokens into this contract
        token.safeTransferFrom(msg.sender, address(this), tokenAmount);

        return _deposit(token, tokenAmount);
    }

As a result, Assets amount smaller than the empty withdraw buffer amount cannot be deposited.

Tools Used

Manual Review

Add following lines in the RestakeManager.sol#deposit() function.

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

        // 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
            _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
---     operatorDelegator.deposit(_collateralToken, _amount);

+++     if(_amount > 0)
+++         operatorDelegator.deposit(_collateralToken, _amount);

        SNIP...
    }

Assessed type

DoS

#0 - c4-judge

2024-05-20T05:02:35Z

alcueca marked the issue as satisfactory

Awards

0 USDC - $0.00

Labels

bug
downgraded by judge
grade-b
QA (Quality Assurance)
satisfactory
sponsor disputed
sufficient quality report
:robot:_primary
:robot:_114_group
duplicate-383
Q-41

External Links

Lines of code

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

Vulnerability details

Impact

In the RestakeManager.sol#calculateTVLs() function, the ERC20 balance of the deposit queue is not included in the TVL calculation. Therefore, an attacker can hold ezETH at a low price by issuing ezETH before the DepositQueue.sol#sweepERC20() function is executed.

Proof of Concept

ReadME states:

ERC20 rewards earned in EigenLayer will be sent to the DepositQueue address. The protocol can then sweep the ERC20 fee percentage to the target address and deposit the remaining ERC20 tokens into the protocol through an Operator Delegator.

That is, ERC20_REWARD_ADMIN sweeps any accumulated ERC20 tokens in DepositQueue contract to the RestakeManager by using DepositQueue.sol#sweepERC20() function.

    function sweepERC20(IERC20 token) external onlyERC20RewardsAdmin {
        ...
    }

As a result, ERC20 rewards are locked in the DepositQueue contract until the sweepERC20() function is called. However, when calculating TVL in the calculateTVLs() function, only the ERC20 token balance of the WithdrawQueue contract is included, and the balance of the DepositQueue contract is not included.

    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
        ...
        for (uint256 i = 0; i < odLength; ) {
            ...
            for (uint256 j = 0; j < tokenLength; ) {
                ...

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

                ...
            }

            ...
        }

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

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }

Therefore, an attacker can hold ezETH at a low price by issuing ezETH before the DepositQueue.sol#sweepERC20() function is executed.

Tools Used

Manual Review

Add following lines in the RestakeManager.sol#calculateTVLs() function.

```solidity
    function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
        ...
        for (uint256 i = 0; i < odLength; ) {
            ...
            for (uint256 j = 0; j < tokenLength; ) {
                ...

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

+++                 totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
+++                     collateralTokens[j],
+++                     collateralTokens[j].balanceOf(depositQueue)
+++                 );
                }

                ...
            }

            ...
        }

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

        return (operatorDelegatorTokenTVLs, operatorDelegatorTVLs, totalTVL);
    }
## Assessed type Other

#0 - jatinj615

2024-05-13T23:00:03Z

EigenLayer does not provide rewards in ERC20 as the payments system is not finalised. The sweepERC20 function is present to allow admin to sweep any supported ERC20 collateral periodically that is sent to depositQueue intentionally/unintentionally which can then be considered as rewards.

#1 - C4-Staff

2024-05-15T14:42:50Z

CloudEllie marked the issue as primary issue

#2 - alcueca

2024-05-16T05:56:07Z

The sweepERC20 function exists, and the attack vector is doable, even if infrequent. Downgrading to Medium since the source of funds is not defined.

#3 - c4-judge

2024-05-16T05:56:54Z

alcueca changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-05-16T05:58:01Z

alcueca marked the issue as satisfactory

#5 - c4-judge

2024-05-16T06:00:13Z

alcueca marked issue #383 as primary and marked this issue as a duplicate of 383

#6 - c4-judge

2024-05-27T09:30:28Z

alcueca changed the severity to QA (Quality Assurance)

#7 - c4-judge

2024-05-28T11:01:24Z

alcueca marked the issue as grade-b

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