Kelp DAO | rsETH - T1MOH'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: 37/185

Findings: 4

Award: $130.28

QA:
grade-b

🌟 Selected for report: 1

🚀 Solo Findings: 0

Awards

46.8436 USDC - $46.84

Labels

bug
3 (High Risk)
high quality report
primary issue
satisfactory
selected for report
sponsor confirmed
upgraded by judge
H-02

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTDepositPool.sol#L136-L141

Vulnerability details

Impact

Price of rsETH is calculated as totalLockedETH / rsETHSupply. rsETH price is used to calculate rsETH amount to mint when user deposits. Formulas are following:

  • rsethAmountToMint = amount * assetPrice / rsEthPrice
  • rsEthPrice = totalEthLocked / rsETHSupply

Problem is that it transfers deposit amount before calculation of rsethAmountToMint. It increases totalEthLocked. As a result rsethAmountToMint is less than intended because rsEthPrice is higher.

For example:

  1. Suppose totalEthLocked = 10e18, assetPrice = 1e18, rsETHSupply = 10e18
  2. User deposits 30e18. He expects to receive 30e18 rsETH
  3. However actual received amount will be 30e18 * 1e18 / ((30e18 * 1e18 + 10e18 * 1e18) / 10e18) = 7.5e18

Proof of Concept

Here you can see that it firstly transfers asset to address(this), then calculates amount to mint:

    function depositAsset(
        address asset,
        uint256 depositAmount
    )
        external
        whenNotPaused
        nonReentrant
        onlySupportedAsset(asset)
    {
        ...

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

        // interactions
        uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);

        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }

There is long chain of calls:

_mintRsETH()
    getRsETHAmountToMint()
        LRTOracle().getRSETHPrice()
            getTotalAssetDeposits()
                getTotalAssetDeposits()

Finally getTotalAssetDeposits() uses current balanceOf(), which was increased before by transferrign deposit amount:

    function getAssetDistributionData(address asset)
        public
        view
        override
        onlySupportedAsset(asset)
        returns (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer)
    {
        // Question: is here the right place to have this? Could it be in LRTConfig?
@>      assetLyingInDepositPool = IERC20(asset).balanceOf(address(this));

        uint256 ndcsCount = nodeDelegatorQueue.length;
        for (uint256 i; i < ndcsCount;) {
            assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]);
            assetStakedInEigenLayer += INodeDelegator(nodeDelegatorQueue[i]).getAssetBalance(asset);
            unchecked {
                ++i;
            }
        }
    }

Tools Used

Manual Review

Transfer tokens in the end:

    function depositAsset(
        address asset,
        uint256 depositAmount
    )
        external
        whenNotPaused
        nonReentrant
        onlySupportedAsset(asset)
    {
        ...

+       uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);

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

-       // interactions
-       uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);
-
        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }

Assessed type

Oracle

#0 - c4-pre-sort

2023-11-15T21:17:38Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-15T21:17:43Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2023-11-15T21:21:51Z

This vulnerability is worse than a donation attack.

#3 - c4-pre-sort

2023-11-17T06:53:34Z

raymondfam marked the issue as high quality report

#4 - c4-sponsor

2023-11-24T09:03:00Z

gus-staderlabs (sponsor) confirmed

#5 - c4-judge

2023-11-29T21:17:26Z

fatherGoose1 marked the issue as satisfactory

#6 - c4-judge

2023-12-01T19:00:05Z

fatherGoose1 changed the severity to 2 (Med Risk)

#7 - c4-judge

2023-12-01T19:01:04Z

fatherGoose1 marked the issue as selected for report

#8 - aslanbekaibimov

2023-12-03T10:51:58Z

@fatherGoose1

Thank you for judging!

I believe this issue meets the criteria for High severity:

Due to this bug, the rsETH/ETH price depends on the amount of LST the user is depositing. Because of that, each user would get a different amount of rsETH per unit of deposited LST (as every user may deposit a different amount), and rsETH is then expected to be redeemable back for LSTs. Thus, essentially, those who deposited more LSTs are directly losing funds to those who deposited less.

#9 - xAriextz13

2023-12-03T15:16:11Z

I agree with @aslanbekaibimov that this should be a High Risk issue. As I explain in https://github.com/code-423n4/2023-11-kelp-findings/issues/636 a user can deposit 100 ETH and receive less rsETH than a user who deposits only 1 ETH. I think that this is loss of funds for all the users who deposit assets, specially affecting large deposits as shown in the example above, where a user has a 99% loss of his deposited funds.

I want to emphasize that this issue arises everytime users deposit funds into the protocol, creating a big loss of funds for them.

#10 - fatherGoose1

2023-12-04T15:31:33Z

I also agree that this is a HIGH severity issue. The miscalculation causes direct fund loss to users. The downgrade to medium was a mistaken action. I appreciate the QA.

#11 - c4-judge

2023-12-04T15:31:59Z

fatherGoose1 changed the severity to 3 (High Risk)

#12 - PaperParachute

2023-12-11T16:55:56Z

Comment added at sponsor request:

This is a legitimate issue and has been fixed in commit 3b4e36c740013b32b78e93b00438b25f848e5f76 to separately have rsETH price calculators and read value from state variables, which also helped reduce gas cost to a great extent as well. We thank the warden for alerting us to this issue.

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTDepositPool.sol#L79-L88

Vulnerability details

Impact

Attacker can perform classic first depositor attack:

  1. Deposit 1 wei of LST
  2. Make big donation to LRTDepositPool or NodeDelegator
  3. Price of rsETH is too high, it leads to high rounding of minted rsETH amount to new depositors

Proof of Concept

  1. Amount to mint is calculated dividing by rsETH price
    function getRsETHAmountToMint(
        address asset,
        uint256 amount
    )
        public
        view
        override
        returns (uint256 rsethAmountToMint)
    {
        // setup oracle contract
        address lrtOracleAddress = lrtConfig.getContract(LRTConstants.LRT_ORACLE);
        ILRTOracle lrtOracle = ILRTOracle(lrtOracleAddress);

        // calculate rseth amount to mint based on asset amount and asset exchange rate
@>      rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
    }
  1. To calculate rsETH price, it divides total locked value by rsETHSupply
    function getRSETHPrice() external view returns (uint256 rsETHPrice) {
        address rsETHTokenAddress = lrtConfig.rsETH();
@>      uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply();

        ...

        address[] memory supportedAssets = lrtConfig.getSupportedAssetList();
        uint256 supportedAssetCount = supportedAssets.length;

        for (uint16 asset_idx; asset_idx < supportedAssetCount;) {
            address asset = supportedAssets[asset_idx];
            uint256 assetER = getAssetPrice(asset);

@>          uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset);
            totalETHInPool += totalAssetAmt * assetER;

            unchecked {
                ++asset_idx;
            }
        }

@>      return totalETHInPool / rsEthSupply;
    }
  1. getTotalAssetDeposits() uses balanceOf to get locked value
    function getTotalAssetDeposits(address asset) public view override returns (uint256 totalAssetDeposit) {
        (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) =
            getAssetDistributionData(asset);
        return (assetLyingInDepositPool + assetLyingInNDCs + assetStakedInEigenLayer);
    }

So attacker can deposit 1 wei of LST, therefore supply of rsETH will be 1 wei. Then he donates 1_000e18 LST to Pool - it makes rsETH price to be 1_000e36. Hence every deposit of amount less than 1_000 ETH will round down to 0 minted rsETH

Tools Used

Manual Review

Perform initial deposit in initialize(). Deposit around 0.5 ETH, it will be enough

Assessed type

ERC4626

#0 - c4-pre-sort

2023-11-15T21:15:42Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-15T21:15:57Z

raymondfam marked the issue as duplicate of #42

#2 - c4-judge

2023-12-01T16:53:55Z

fatherGoose1 marked the issue as satisfactory

Awards

76.0163 USDC - $76.02

Labels

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

External Links

Lines of code

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

Vulnerability details

Impact

How currently rsETH price is calculated? totalEthLocked is divided by rsETH supply. How totalEthLocked is calculated? It sums 3 amounts per every asset: 1) balance of LRTDepositPool.sol, 2) balance of all node delegators, 3) already deposited amount of asset to EigenLayer's Strategy. We are interested in the 3rd point.

EigenLayer has 2 types of functions to fetch the user's deposited amount: 1) view, which doesn't update state, 2) function that potentially modifies state. Here's problem, now only view function is used. Future version of Strategy contract can contain specific logic which affects user's balance calculation, like interest accruing for example.

As a result rsETH price can be incorrect in protocol. It affects deposits and most likely withdrawals.

Proof of Concept

That's callback order:

LRTOracle.getRSETHPrice() LRTDepositPool.getTotalAssetDeposits() LRTDepositPool.getAssetDistributionData() NodeDelegator.getAssetBalance() IStrategy.userUnderlyingView()

NodeDelegator in its turn calls view version of function:

    function getAssetBalance(address asset) external view override returns (uint256) {
        address strategy = lrtConfig.assetStrategy(asset);
        return IStrategy(strategy).userUnderlyingView(address(this));
    }

That's EigenLayer documentation: https://github.com/Layr-Labs/eigenlayer-contracts/blob/master/src/contracts/strategies/StrategyBase.sol#L246-L259 https://github.com/Layr-Labs/eigenlayer-contracts/blob/master/src/contracts/strategies/StrategyBase.sol#L193-L217

    function underlyingToShares(uint256 amountUnderlying) external view virtual returns (uint256) {
        return underlyingToSharesView(amountUnderlying);
    }

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

    /**
     * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy.
     * @notice In contrast to `sharesToUnderlying`, this function guarantees no state modifications
     * @param amountShares is the amount of shares to calculate its conversion into the underlying token
     * @return The amount of underlying tokens corresponding to the input `amountShares`
@>   * @dev Implementation for these functions in particular may vary significantly for different strategies
     */
    function sharesToUnderlyingView(uint256 amountShares) public view virtual override returns (uint256) {
        ...
    }

    /**
     * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy.
     * @notice In contrast to `sharesToUnderlyingView`, this function **may** make state modifications
     * @param amountShares is the amount of shares to calculate its conversion into the underlying token
     * @return The amount of underlying tokens corresponding to the input `amountShares`
@>   * @dev Implementation for these functions in particular may vary significantly for different strategies
     */
    function sharesToUnderlying(uint256 amountShares) public view virtual override returns (uint256) {
        ...
    }

Tools Used

Manual Review

Create 2 versions of function getRSETHPrice(): view and state modifying. In state modifying version use Strategy.userUnderlying() instead of Strategy.userUnderlyingView(). And utilize state modifying version in deposit.

Assessed type

Other

#0 - c4-pre-sort

2023-11-16T23:39:45Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T23:39:56Z

raymondfam marked the issue as duplicate of #197

#2 - c4-judge

2023-12-01T17:24:51Z

fatherGoose1 marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2023-12-08T17:26:50Z

fatherGoose1 marked the issue as satisfactory

Awards

2.7592 USDC - $2.76

Labels

bug
grade-b
QA (Quality Assurance)
sufficient quality report
Q-123

External Links

1. Low. getAssetCurrentLimit() will revert if admin sets new limit to less than deposited now

Vulnerability Details

https://github.com/code-423n4/2023-11-kelp/blob/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTDepositPool.sol#L56-L58

Limit from config can be lower than actually deposited, therefore function will revert instead of returning 0

    function getAssetCurrentLimit(address asset) public view override returns (uint256) {
        return lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset);
    }

Return 0 instead of revert

    function getAssetCurrentLimit(address asset) public view override returns (uint256) {
-       return lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset);
+       return lrtConfig.depositLimitByAsset(asset) > getTotalAssetDeposits(asset)
+           ? lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset)
+           : 0;
    }

2. Low. Protocol assumes that all LSTs have 18 decimals

Vulnerability Details

https://github.com/code-423n4/2023-11-kelp/blob/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTOracle.sol#L52-L79

Protocol assumes that all LSTs have 18 decimals, it can be different in the future. Here function expects that totalAssetAmt has 1e18 precission. Otherwise it will over/under-estimate asset:

    function getRSETHPrice() external view returns (uint256 rsETHPrice) {
        address rsETHTokenAddress = lrtConfig.rsETH();
        uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply();

        if (rsEthSupply == 0) {
            return 1 ether;
        }

        uint256 totalETHInPool;
        address lrtDepositPoolAddr = lrtConfig.getContract(LRTConstants.LRT_DEPOSIT_POOL);

        address[] memory supportedAssets = lrtConfig.getSupportedAssetList();
        uint256 supportedAssetCount = supportedAssets.length;

        for (uint16 asset_idx; asset_idx < supportedAssetCount;) {
            address asset = supportedAssets[asset_idx];
            uint256 assetER = getAssetPrice(asset);

@>          uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset);
@>          totalETHInPool += totalAssetAmt * assetER;

            unchecked {
                ++asset_idx;
            }
        }

        return totalETHInPool / rsEthSupply;
    }

Normalize asset amount to 18 decimals:

        for (uint16 asset_idx; asset_idx < supportedAssetCount;) {
            address asset = supportedAssets[asset_idx];
            uint256 assetER = getAssetPrice(asset);

            uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset);
+           uint256 decimals = IERC20(asset).decimals();
+           totalAssetAmt = decimals < 18 ? totalAssetAmt * (10 ** (18 - decimals)) : totalAssetAmt / (10 ** (decimals - 18));
            totalETHInPool += totalAssetAmt * assetER;

            unchecked {
                ++asset_idx;
            }
        }

3. Low. It is dangerous to give max approve to external contracts

Vulnerability Details

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

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

Consider giving approval before deposit:

    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));
+
+       token.approve(eigenlayerStrategyManagerAddress, balance);

        emit AssetDepositIntoStrategy(asset, strategy, balance);

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

#0 - raymondfam

2023-11-18T01:05:15Z

Possible upgrade:

  1. --> #479

#1 - c4-pre-sort

2023-11-18T01:05:20Z

raymondfam marked the issue as sufficient quality report

#2 - c4-judge

2023-12-01T16:26:29Z

fatherGoose1 marked the issue as grade-b

#3 - fatherGoose1

2023-12-01T18:59:28Z

Not upgrading as it's not a dupe of #479. 479 discusses the different decimals in Chainlink price feeds. LSTs are assumed to mirror ETH's 18 decimals.

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