Kelp DAO | rsETH - hals's results

A collective DAO designed to unlock liquidity, DeFi and higher rewards for restaked assets through liquid restaking.

General Information

Platform: Code4rena

Start Date: 10/11/2023

Pot Size: $28,000 USDC

Total HM: 5

Participants: 185

Period: 5 days

Judge: 0xDjango

Id: 305

League: ETH

Kelp DAO

Findings Distribution

Researcher Performance

Rank: 21/185

Findings: 2

Award: $196.33

QA:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Awards

140.2525 USDC - $140.25

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-148

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L151-L157 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L109

Vulnerability details

Impact

  • In LRTDepositPool.depositAsset function : when a user deposits a supported LST asset in the deposit pool, he will be minted share rsETH tokens proportional to the deposited amount and the asset price.

  • And as per the the price calculations, the depositor will almost get an amount of share tokens (rsETH) equivalent to the amount of the deposited asset: LRTDepositPool.getRsETHAmountToMint function/L109

    rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();

    where lrtOracle.getRSETHPrice() is totalETHInPool / rsEthSupply.

  • But this function is missing the slippage parameter (minimum amount of share tokens to be minted) that ensures the user gets a fair amount of shares (as the rsETH price calculation might be affected if the deposit pool receives a direct LST transfers without going through the depositing process-minting of rsETH).

Proof of Concept

LRTDepositPool._mintRsETH function

  function _mintRsETH(address _asset, uint256 _amount) private returns (uint256 rsethAmountToMint) {
        (rsethAmountToMint) = getRsETHAmountToMint(_asset, _amount);

        address rsethToken = lrtConfig.rsETH();
        // mint rseth for user
        IRSETH(rsethToken).mint(msg.sender, rsethAmountToMint);
    }

LRTDepositPool.getRsETHAmountToMint function/L109

rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();

Tools Used

Manual Review.

In depositAsset function: add a _minShareAmount parameter as a slippage protection:

    function depositAsset(
        address asset,
        uint256 depositAmount,
+       uint256 _minShareAmount
    )
        external
        whenNotPaused
        nonReentrant
        onlySupportedAsset(asset)
    {
        // checks
        if (depositAmount == 0) {
            revert InvalidAmount();
        }
        if (depositAmount > getAssetCurrentLimit(asset)) {
            revert MaximumDepositLimitReached();
        }

        if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) {
            revert TokenTransferFailed();
        }

        // interactions
        uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);
+       require(rsethAmountMinted > _minShareAmount,"calculated less than required by the user");

        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }

Assessed type

Context

#0 - c4-pre-sort

2023-11-16T06:21:38Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T06:21:46Z

raymondfam marked the issue as duplicate of #39

#2 - c4-pre-sort

2023-11-17T06:43:11Z

raymondfam marked the issue as duplicate of #148

#3 - c4-judge

2023-11-29T19:10:18Z

fatherGoose1 marked the issue as satisfactory

Findings Information

Awards

140.2525 USDC - $140.25

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-148

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L67

Vulnerability details

Impact

  • The main purpose of the node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp protocol if the EigenLayer paused the deposit operations.

  • First the LST assets are transferred to a nodeDelegator contract by the deposit pool manager, then transferred to eigenStrategyManager contract by calling NodeDelegator.depositAssetIntoStrategy function that will in turn calls the IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);.

  • The IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance) function deposits balance of token into the specified strategy, with the resultant shares credited to msg.sender which is the nodeDelegator contract that initiated the call.

  • And this function will return the amount of new shares in the strategy created as part of the action.

  • But it was noted that this returned share value is not checked against any minimum value determined by the node delegator nor against zero, which might result in protocol losing from its LST balance without getting enough/equivalent shares to withdraw from EigenLayer protocol.

Proof of Concept

NodeDelegator.depositAssetIntoStrategy function/L67

IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);

Tools Used

Manual Review.

In depositAssetIntoStrategy function: add a _minShareAmount parameter as a slippage protection, and check this value against the returned shares by the strategy manager:

-   function depositAssetIntoStrategy(address asset)
+   function depositAssetIntoStrategy(address asset,uint256 _minShareAmount)
        external
        override
        whenNotPaused
        nonReentrant
        onlySupportedAsset(asset)
        onlyLRTManager
    {
        address strategy = lrtConfig.assetStrategy(asset);
        IERC20 token = IERC20(asset);
        address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER);

        uint256 balance = token.balanceOf(address(this));

        emit AssetDepositIntoStrategy(asset, strategy, balance);

-       IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);

+       uint256 shares=IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);

+        require(shares >= _minShareAmount);
    }

Assessed type

Context

#0 - c4-pre-sort

2023-11-16T06:23:37Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T06:24:08Z

raymondfam marked the issue as duplicate of #230

#2 - c4-judge

2023-12-01T18:36:32Z

fatherGoose1 marked the issue as unsatisfactory: Invalid

#3 - DevHals

2023-12-01T21:48:13Z

Hi @fatherGoose1,

this issue was invalidated based on the fact that Eigen layer has an implementation to mitigate inflation attack: as per the sponsors reply on the primary issue:

Please check the eigen layer latest code here. They have implemented proper mitigation for inflated shares attack.

while checking the StrategyBase.deposit function, it was noticed that the check made for the minted shares (newShares) is to be != 0 :

require(newShares != 0, "StrategyBase.deposit: newShares cannot be zero");

but this doesn't fully protect the nodeDelegator contract against slippage, as the minted shares by the strategy could be very low, and NodeDelegator.depositAssetIntoStrategy function doesn't have any minimum value determined by the manager to check the minted shares against, nor does it have a deadline to prevent executing the transaction after specific time- that might affect the minted shares as it will fluctuates with time (since the protocol is going to be deployed on the mainnet which suffers from congestion and delays of transactions executions at some times).

I kindly ask if you could take another look at the specifics of this issue and provide a re-evaluation?

Thanks!

#4 - c4-judge

2023-12-04T16:45:14Z

fatherGoose1 marked the issue as duplicate of #148

#5 - c4-judge

2023-12-04T16:45:54Z

fatherGoose1 marked the issue as satisfactory

#6 - DevHals

2023-12-04T17:32:43Z

Hi @fatherGoose1 , this issue is not a duplicate of 148, as the vulnerability introduced here is a result of not validating the slippage introduced by integrating with a 3rd party, and it has nothing to do with the shares minted by the Kelp protocol!

also to add: issue 148 introduces a loss for the users only (the protocol will not be adversely affected) while this issue here introduces a loss of protocol funds (which will affect users in return if the protocol becomes insolvent),

Could you please have a second look?

Thanks!

#7 - DevABDee

2023-12-05T01:42:42Z

I agree with @DevHals that this issue is not a dup of 148, specifically because fixing this issue will not fix 148 & 148's fix will not fix this issue. The only dup of this issue is #230. Pls re-evaluate Sir. Thanks!

#8 - fatherGoose1

2023-12-05T17:57:15Z

@DevHals @DevABDee I understand the difference in root causes of slippages between this report and #148. While the issues originate in different parts of the code and for different protocol functions entirely, I am leaving this report and #230 as duplicates of #148, simply because slippage will most likely not cause issue to any significant degree through this implementation. It is a necessary check to have to ensure the safety of Kelp's users and Kelp's held assets when referring to shares minted by Eigenlayer, but it is not likely that these stable assets will incur more than minimal slippages.

#9 - DevHals

2023-12-05T23:27:42Z

@fatherGoose1,, the reason provided to duplicate this issue with 148 is not convincing (LSTs are not stable assets to assume that minimal slippage might be incurred), an example of Lido Staked ETH (stETH/ETH) price fluctuation all time as per https://coinmarketcap.com/currencies/steth/steth/eth/ (where it hit 0.9394 on 20th of June/2022) stETH/ETH price for 05/12/2023

but the final say is for the judge no matter what!

anyway, I have another submitted issue #409 that is marked as a duplicate of 148; so if you see that this issue is still a duplicate of 148; then this issue should be nullified,

Thanks for your time!

#10 - DevABDee

2023-12-09T10:55:59Z

Hey Sir @fatherGoose1, I wanted to kindly request your reconsideration of our escalation following the validation of issue #584. You initially marked this issue & #230, as a duplicate of #148 due to concerns about LSTs stability. However, with the acceptance of #584, it seems evident that LSTs may not be as stable as initially perceived, aligning with the observations highlighted by @DevHals in his comment above. Both of us maintain the perspective that this is a medium issue and not a duplicate of #148. We will appreciate your final re-evaluation of this issue. Thanks!

Awards

56.0791 USDC - $56.08

Labels

bug
grade-a
QA (Quality Assurance)
sufficient quality report
Q-70

External Links

Findings Summary

IDTitleSeverity
L-01NodeDelegator contract doesn't have a mechanism to remove LST asset approval from eigenStrategyManager contractLow
L-02LRTConfig.setRSETH function: updating the rsETH contract address will result in loss of users depositsLow
L-03LRTConfig contract: no mechanism implemented to remove supported LST assetsLow
L-04LRTDepositPool.addNodeDelegatorContractToQueue function is accessed by the LRTAdmin while it should be accessed by the LRTManagerLow
L-05LRTDepositPool.transferAssetToNodeDelegator function doesn't check if the nodeDelegator exists before sending it LST assetsLow

Low

[L-01] NodeDelegator contract doesn't have a mechanism to remove LST asset approval from eigenStrategyManager contract <a id="l-01" ></a>

Impact

  • The main purpose of the node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp protocol if the EigenLayer paused the deposit operations.

  • First the LST assets are transferred to a nodeDelegator contract by the deposit pool manager, then transferred to eigenStrategyManager contract by calling NodeDelegator.depositAssetIntoStrategy function that will in turn calls the IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);, but before that, the nodeDelegator should approve the eigenStrategyManager contract on the deposited amount.

  • And for this approval action; the nodeDelegator contract manager calls NodeDelegator.maxApproveToEigenStrategyManager function, which will approve the strategy manager on the maximum LST amount.

  • But if the eigenStrategyManager contract got compromised/hacked or started to act maliciously, the LST asset balance of the nodeDelegator contract will be at risk since there's no way to remove LST assets approvals for the comromised strategy manager contract (setting the LST approval/allowance to zero) in cases of emergencies.

Proof of Concept

NodeDelegator.maxApproveToEigenStrategyManager function

   function maxApproveToEigenStrategyManager(address asset)
        external
        override
        onlySupportedAsset(asset)
        onlyLRTManager
    {
        address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER);
        IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max);
    }

Tools Used

Manual Review.

Remove NodeDelegator.maxApproveToEigenStrategyManager function, and update NodeDelegator.depositAssetIntoStrategy function to approve the strategy manager contract on the deposited amount only before depositing in the strategy:

   function depositAssetIntoStrategy(address asset)
        external
        override
        whenNotPaused
        nonReentrant
        onlySupportedAsset(asset)
        onlyLRTManager
    {
        address strategy = lrtConfig.assetStrategy(asset);
        IERC20 token = IERC20(asset);
        address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER);

        uint256 balance = token.balanceOf(address(this));

        emit AssetDepositIntoStrategy(asset, strategy, balance);
+       IERC20(asset).approve(eigenlayerStrategyManagerAddress, balance);
        IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);
    }
-   function maxApproveToEigenStrategyManager(address asset)
-        external
-        override
-        onlySupportedAsset(asset)
-        onlyLRTManager
-    {
-        address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER);
-        IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max);
-    }

[L-02] LRTConfig.setRSETH function: updating the rsETH contract address will result in loss of users deposits <a id="l-02" ></a>

Impact

  • The admin of the LRTConfig can change the address of the rsETH contract, and this will result in re-setting the rsETH.totalSupply to zero.

  • So if the rsETH contract is updated; users can't redeem/withdraw their deposits as the totalSupply would be zero (would revert due to underflow if any value is deducted from zero), also the price of the rsETH share token will drop as it's calculated based on the totalSupply and the total value of ETH held by the protocol (deposited in the pool + in the node delegators + in the EigenLayer strategies):

    return totalETHInPool / rsEthSupply;

Proof of Concept

LRTConfig.setRSETH function

    function setRSETH(address rsETH_) external onlyRole(DEFAULT_ADMIN_ROLE) {
        UtilLib.checkNonZeroAddress(rsETH_);
        rsETH = rsETH_;
    }

Tools Used

Manual Review.

Either prevent updating the stETH contract address or implement a mechanism to transfer users balances and totalSupply with the new contract.

--------------------------------

[L-03] LRTConfig contract: no mechanism implemented to remove supported LST assets <a id="l-03" ></a>

Impact

  • The LRTConfig contract has a function to add LST supported assets to the protocol, but it doesn't have any function or mechanism to unsupport any of these assets.

  • So if the protocol decided to remove any LST asset from being traded in the system due to financial risks, it will not be able to do so and users will keep depositing these assets and receive share tokens in return.

Proof of Concept

LRTConfig._addNewSupportedAsset function

 function _addNewSupportedAsset(address asset, uint256 depositLimit) private {
        UtilLib.checkNonZeroAddress(asset);
        if (isSupportedAsset[asset]) {
            revert AssetAlreadySupported();
        }
        isSupportedAsset[asset] = true;
        supportedAssetList.push(asset);
        depositLimitByAsset[asset] = depositLimit;
        emit AddedNewSupportedAsset(asset, depositLimit);
    }

Tools Used

Manual Review.

Add a mechanism to remove/unsupport any of the added LST assets.

[L-04] LRTDepositPool.addNodeDelegatorContractToQueue function is accessed by the LRTAdmin while it should be accessed by the LRTManager <a id="l-04" ></a>

Impact

  • addNodeDelegatorContractToQueue function is used to add nodeDelegator contracts to the system to act as a LST hoders until depositing these assets in EigenLayer protocol, as per the comments on the addNodeDelegatorContractToQueue function, it should be called only by the LRTManager:

    /// @dev only callable by LRT manager
  • But the function has onlyLRTAdmin access modifier that only allows the admin from accessing it,; not the manager.

Proof of Concept

LRTDepositPool.addNodeDelegatorContractToQueue function/L159-L162

    /// @notice add new node delegator contract addresses
    /// @dev only callable by LRT manager
    /// @param nodeDelegatorContracts Array of NodeDelegator contract addresses
    function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTAdmin {

Tools Used

Manual Review.

-   function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTAdmin {

+   function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTManager {

[L-05] LRTDepositPool.transferAssetToNodeDelegator function doesn't check if the nodeDelegator exists before sending it LST assets <a id="l-05" ></a>

Impact

  • The main purpose of node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp protocol if the EigenLayer paused the deposit operations.

  • Whenever the deposit limit of a specific LST asset is reached, a new nodeDelegator contract is deployed and added to the protocol to overcome the deposit limit implemented by EigenLayer protocol.

  • transferAssetToNodeDelegator function is intended to be called by the deposit pool manager to transfer LST assets to an approved nodeDelegator contract; and then these assets to be moved to EigenLayer strategy when the strategy is not paused.

  • but this function doesn't check if the ndcIndex (the index of the nodeDelegator in the nodeDelegatorQueue array) is within the nodeDelegatorQueue array or not; which might lead to LST assets being transferred to address(0) if the nodeDelegator doesn't exist (ndcIndex is out of bounds).

  • This will lead to loss of protocol funds and affecting the price of the rsETH share tokens as the total ETH locked in the protocol will be lowered without burning an equivalenT amount of shares (the price of rsETH will go up!).

Proof of Concept

LRTDepositPool.transferAssetToNodeDelegator function

    function transferAssetToNodeDelegator(
        uint256 ndcIndex,
        address asset,
        uint256 amount
    )
        external
        nonReentrant
        onlyLRTManager
        onlySupportedAsset(asset)
    {
        address nodeDelegator = nodeDelegatorQueue[ndcIndex];
        if (!IERC20(asset).transfer(nodeDelegator, amount)) {
            revert TokenTransferFailed();
        }
    }

Tools Used

Manual Review.

Update transferAssetToNodeDelegator function to check if the nodeDelegator address exists bfore transferring LST assets to it:

    function transferAssetToNodeDelegator(
        uint256 ndcIndex,
        address asset,
        uint256 amount
    )
        external
        nonReentrant
        onlyLRTManager
        onlySupportedAsset(asset)
    {
        address nodeDelegator = nodeDelegatorQueue[ndcIndex];
+       require(nodeDelegator != address(0),"nodeDelegator doesn't exist");
        if (!IERC20(asset).transfer(nodeDelegator, amount)) {
            revert TokenTransferFailed();
        }
    }

#0 - c4-pre-sort

2023-11-18T00:44:05Z

raymondfam marked the issue as sufficient quality report

#1 - c4-judge

2023-12-01T18:49:47Z

fatherGoose1 marked the issue as grade-b

#2 - DevHals

2023-12-01T20:53:21Z

Hi @fatherGoose1,

I think my downgraded issues were not taken into consideration in the grading of my report, I kindly request your reconsideration of these issues in the final grade:

downgraded issues are: #407 #408 #410 #413 (if still considered as QA after resolving the dispute)

Thanks!

#3 - fatherGoose1

2023-12-04T17:15:24Z

I agree that this QA report combined with the multitude of reports downgraded to QA cumulates to an A report. @DevHals

#4 - c4-judge

2023-12-04T17:15:40Z

fatherGoose1 marked the issue as grade-a

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