Kelp DAO | rsETH - Aamir'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: 10/185

Findings: 4

Award: $946.02

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
duplicate-584

Awards

902.5718 USDC - $902.57

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/oracles/ChainlinkPriceOracle.sol#L17

Vulnerability details

ChainlinkPriceOracle fetches prices from the Chainlink contracts. But the price feeds in the consideration has a very long price heartbeat and deviation rate which might lead to wrong price calculation and loss of token to the user.

Impact

According to the chainlink docs, following are the Price feeds info for the assets that will be used initially:

FeedHeartbeatDeviationDocs link
RETH / ETH2%86400sDocs
CBETH / ETH1%86400sDocs
RETH / ETH0.5%86400sDocs

For Info, Heartbeat and Deviation are variables on which the update of the prices for price feeds depends. That mean the current price feed price will be updated for the token only if one of them is met. That means if we take the example of RETH here, the price feed will be update only when the market price for the RETH fluctuates by 2% or time equal to 86400s(1 day) has passed since last price update. This could be a very problematic thing. Let's assume a following scenario:

-> Let's say price feeds was updated at time T1 and price for RETH / ETH is [1]. And since then 43200s(half day) has passed and price has become [1.018] (up by 1.8 %). -> Now Bob comes and decides to deposit 50 RETH by calling LSTDepositPool::depositAsset(...). -> But the amount of RSETH token he will get is equal to:

depositAmount = 500 RETH

// balances
bob = 500 RETH
LRTDepositPool = 0
EigenStrategy = 0
NodeDelegator = 0

// RETH Price
RETHPrice = 1e18 // hasn't updated because heartbeat and deviation hasn't reached


// total ETH (assuming zero for the sake of simplicity)
totalETHInPool = 0
RSETHSupply = 0


///////////////////////////////////////////////////////////////
// functions calculations according to chainlink price feeds //
///////////////////////////////////////////////////////////////

rsETHPrice(...) = if -> 
                      (supply = 0) 1 ETH // this condition will met
                  else -> 
                      totalETHInPool / rsETHTotalSupply

rsEthAmountToMint(...) = depositAmount * RETHPrice / rsETHPrice()
                  = 500 * 1e18 / 1e18 = 500 rsETH


/////////////////////////////////////////////////////////
/// functions calculations according to market price ////
/////////////////////////////////////////////////////////

rsETHPrice(...) = if -> 
                      (supply = 0) 1 ETH // this condition will met
                  else -> 
                      totalETHInPool / rsETHTotalSupply

rsEthAmountToMint(...) = depositAmount * RETHPrice / rsETHPrice()
                  = 500 * (1.018 * 1e18) / 1e18 = ~509 rsETH

As you can see according to the market price, 9 rsETH should be minted extra to the user. But it will not happen as the price feed deviation and heartbeat threshold hasn't passed. And that is why the prices will not be updated. Even if the checks are added in the future for staleness of price, this mechanism will not let anybody deposit token for a whole day because of staleness checks like maxTimestamp etc.

This is one of the valid issue founded in a sherlock audit. Here is a link: Link

Proof of Concept

Calculation and Link given in the above section.

Tools Used

  • Solodit
  • Manual Review

It is recommended to use price feeds with less heartbeat and deviation rate. Although the deviation can be handled the heartbeat is still a thing that needs to be considered. Since the time of writing this, Chainlink only support few price feeds for the above tokens with high heartbeat and deviation rate. It is recommended to go for other price oracles or combine more than one together to get the average price.

Assessed type

Oracle

#0 - c4-pre-sort

2023-11-17T00:03:34Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-17T00:03:51Z

raymondfam marked the issue as duplicate of #609

#2 - c4-judge

2023-12-01T17:43:14Z

fatherGoose1 marked the issue as unsatisfactory: Invalid

#3 - Aamirusmani1552

2023-12-02T14:15:04Z

Hey @c4-judge , Sorry but I don't agree with the decision. I have gone through the other duplicate at #609 but not satisfied with the answer. I think it's not a good idea to assume that the rate remains stable and it will never go near that deviation rate. The LST to ETH conversion rate will affect the original amount of rsETH to mint a lot even if it changes little bit. And going with 2% deviation rate is going to be a loss for the user. So if cbETH / ETH conversion is 1. And it changed to 1.01. The RSETH that user get will be equal to ~2106 usd according to current price. But it should be ~2124 usd with the right conversion rate. And that is when we consider only 1 percent deviation. I don't know why team is ready to go with this. And even bad thing is the oracle will keep sending the same price until the 24 hour heartbeat has passed.

#4 - c4-judge

2023-12-08T17:32:48Z

fatherGoose1 marked the issue as duplicate of #584

#5 - c4-judge

2023-12-08T17:49:18Z

fatherGoose1 marked the issue as satisfactory

#6 - c4-judge

2023-12-08T18:55:06Z

fatherGoose1 changed the severity to 3 (High Risk)

#7 - d3e4

2023-12-08T22:12:43Z

It is not an issue that the user is minted more or less rsETH as described here. Whether it is 500 or 509 it will grant the depositor the right to withdraw the same amount as he deposited. It is simply a different conversion factor. The issue in #584 arsises when there are multiple underlying assets. Here no such argument is provided, and the scenario described here leads to no loss.

#8 - Aamirusmani1552

2023-12-09T05:48:23Z

Hey @d3e4, first of all I am aware of that there will be 3 underlying assets in the beginning. I have just given an example above for a single asset for the sake of simplicity. Secondly as you said in your issue there will be arbitrage opportunity, tell me how it would be possible. Wouldn't it be because of the price deviation? What you have shown in your issue is just little bit more detailed version of the same Issue I mentioned here. You took an example of profit from it whereas I stated loss. There is no difference here. You have given good examples of how this price deviation could be an issue and that is why your issue has been selected for the final report.

#9 - d3e4

2023-12-09T10:15:21Z

Why then did you not mention the fact that there will be three assets? This is critical. When there is one asset there is no loss. So this is not a valid example and as such your argument demonstrates no loss.

(Also, #584 is not my report)

#10 - Aamirusmani1552

2023-12-09T11:19:23Z

Dude just have a look at LRTConfig::initialize() Screenshot_2023-12-09-16-42-40-25

It's the first thing that anyone will look at. Everybody knows these will be the initial ones. I don't even have to give any other example here. But here are some others:

First of all this is the first thing they tells in the docs that these three will be the used in the beginning. So mentioning them again doesn't make any sense. Secondly all of the tests are written with these tokens. And thirdly why would i even mention the deviation and heartbeat for all three of them in the above issue. I should've just mentioned the one that is in the examples. don't know why are you so desperate to make other ones invalid. Don't want to argue anymore. I rest my case here. I am sure judges will make the right decision.

#11 - d3e4

2023-12-09T13:05:56Z

Even something evident must be explicitly invoked if it is to form the premise of an issue. You cannot just quote the code in retrospect and say you didn't have to mention something because it is clearly there in the code. Isn't the point of a report to point out which specific parts lead to which specific impact, and how? The argument you provided did not include, either implicitly or explicitly, this premise and is therefore not valid. You would have to both include the premise and amend your argument accordingly to show a loss.

#12 - Aamirusmani1552

2023-12-09T13:40:56Z

That means if we take the example of RETH here, the price feed will be update only when the market price for the RETH fluctuates by 2% or time equal to 86400s(1 day) has passed since last price update. This could be a very problematic thing. Let's assume a following scenario:

First of all read this. I didn't say that only RETH price feed will cause this issue. I have clearly mentioned that it was an example. And let's just keep this thing aside that we haven't mentioned all of the assets for now. If we just take an example of RETH here, the issue still exist. It doesn't depend on all of the assets prices. The calculation above clearly shows this as the dollar value of the assets will be used to calculate the price of RSETH to mint. And it will cause loss when the deviation and heartbeat hasn't met. And also the core issue is same. Let's just avoid this conversation further and wait for the judge's reply.

#13 - d3e4

2023-12-09T13:59:58Z

You calculations show the following. Let's say there are two deposits of 500 rETH. If the price is 1 but is incorrectly given as 1.018 then the first deposit will mint 509 rsETH. But the second deposit will also mint 509 rsETH because depositAmount * RETHPrice / (totalETHInPool / rsETHTotalSupply) = 500 * 1.018 / (1.018 * 500 / 509) = 509. The price cancels out. This means both depositors have equal shares of the pool, which is correct since they both deposited equal amounts. So there is no loss. It doesn't matter whether they are minted 500 or 509 rsETH when there is only a single asset. When there are multiple assets the price does not cancel out, and this must be shown.

#14 - Aamirusmani1552

2023-12-09T14:09:42Z

You have stated the example in wrong way. I said if the actual price 1.018 but the update of price feed will not happen due to set deviation. It will only change after 24 hours in that case. So if the old price is 1 then it is surely the loss to the user. After the heartbeat the price will become normal again and giving other users tokens at 1.018 that should be also the case for previous depositor. And I never said that this will happen during first and second deposit. It can happen anytime. Now the opposite is also true. If the price has become less and the price feed update hasn't done then the update will not happen again and user will get RSETH at less price than actual giving the opportunity for arbitrage(in your case). And this price will change after 24 hours so it surely is same issue as yours.

#15 - d3e4

2023-12-09T16:58:18Z

It seems you are thinking of the issue in #443 then? That is valid, but a different issue. But in that case it is critical to mention backrunning the price update.

#16 - Aamirusmani1552

2023-12-10T06:19:58Z

Although it doesn't make sense to again comment on the issue since rewards has been announced. I would do it one last time just to answer the question. The issue is not similar to 443 at all. In that issue he talks about getting benefits by frontrunning the price update of price oracle. First of all It's only possible when you know when exactly the price is going to change and that is the tricky part. And once it is changed there is no way to front-run it. So I don't think frontrunning is possible. What my issue is can be summarized like this:

  • Old price of asset = 1 and new price of asset = 1.018
  • assuming the heartbeat hasn't met, the price oracle price update will not happen. so the oracle will show price of asset = 1.
  • Now if a person deposits some LSTs into the pool, the amount of RSETH he will get will be less than what he should have get with original price. So if he decides to go with the deposit he will get less RSETH. And your argument above saying that the issue will only occur when there are three underlying assets is not true. The price of the RSETH depends on the combined price of LSTs in term of ETH. So if any one of the price feed is showing the wrong data then the impact would be on overall price. So even if there is only one token, the same is true.
  • One more thing that you said above saying it would cancel out the effect once other user deposits their LST is also not true. The wrong price will be there only when the update of price hasn't happen. After 24 there will be price change and user's will get RSETH at correct prices.
  • And this is not just a one time thing. The chances are the price can again change in the similar way and it again gives the opportunity of profit from arbitrage(according to other issue) or loss(in my case).

That is all what I meant to say above. Now why I say that selected issue is similar like mine is because, in that the other auditor went with the arbitrage. That is when the actual price is low but the price feed is showing high LST prices. That mean a user can deposit more LSTs and get more RSETH than the actual price giving the opportunity for arbitrage. This arbitrage opportunity is only there until the price update hasn't happened. My example and his example are not different in any sense. They are complementary. I have shown an example of loss while he has shown for the profit.

Please refrain from commenting any further, while I and you may not have lot on our plates, but judges and other team members do have a lot. And posting these comments only sending them unnecessary notifications.

#17 - d3e4

2023-12-10T12:39:25Z

I see what you mean but I think what you are missing is the distinction between a direct profit and a regular trading profit. If a profit is made because of a price increase then this is a legitimate trading profit. The only thing that must not happen with regards to how much is minted is such that one could not immediately withdraw for a profit, otherwise it is just a normal trading profit, or at least not any more exploitable than the normal market mechanics.

If the price change is due to an inaccurate oracle, then this still effects everyone equally, so it is not exploitable. It does cancel out, if you also take into account the other holders who equally can choose to withdraw.

But you say in regards to #443 that it would be possible to frontrun the price update only if you know exactly when it is going to happen, and that this is not possible and that such a frontrunning is impossible. This is not how frontrunning works. The mempool is continuously monitored and the transaction will be spotted before it is executed, so one can indeed know exactly when it will happen in the sense that one makes sure one's own transaction is executed just before. The loss due to the price deviation you describe is the same as the loss described in #443 except that in your case it does not happen in connection to a front/backrunning.

What you describe is only exploitable, in the sense that anyone gets an unfair advantage, if you can perform this knowing about the impending price update. The only way this can be executed is through front/backrunning, or perhaps predicting the heartbeat update. So there may be a slight distinction in that sense between your issue and #443, but they are sufficiently similar, and still distinct from #584.

Again, and this is the main point, a price deviation does imply the loss you say, but it is indistinguishable from a genuine price change, and is thus fair and may be taken for the real price, except by means of an exploit like #443.

Awards

36.0335 USDC - $36.03

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
edited-by-warden
duplicate-62

External Links

Lines of code

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

Vulnerability details

Users will receive less RSETH tokens when depositing into the contract using LRTDepositPool::deposit(...). The function transfers the deposit tokens to itself and then makes calculation for the amount of RSETH to be minted for the user. Because of this token transfer to self before the calculation for the mint, the users will get less tokens then actual amount.

Impact

The LRTDepositPool::deposit(...) function makes call to different functions from the same contract that again calls other contracts to calculate the amount of token to mint for the user. Here is a high level flow:

LRTDepositPool::deposit() |_ LRTDepositPool::_mintRsETH() |_ LRTDepositPool::getRsETHAmountToMint() |_ LRTOracle::getAssetPrice() |_ LRTOracle::getRSETHPrice() |_ LRTOracle::getSupportedAssetList() |_ LRTOracle::getAssetPrice() |_ LRTDepositPool::getTotalAssetDeposits() @> |_ LRTDepositPool::getAssetDistributionData() |_ NodeDelegator::getAssetBalance()

This function makes a call to LRTDepositPool::getAssetDistributionData()(indirectly) to fetch the total amount of tokens deposited in LRTDepositPool, NodeDelegator and Eigen Layer strategies. To fetch the assets deposited in LRTDepositPool this LRTDepositPool::getAssetDistributionData() function perform the following operation:

File: Breadcrumbs2023-11-kelp/src/LRTDepositPool.sol

        assetLyingInDepositPool = IERC20(asset).balanceOf(address(this));

Github: 79

This just reads the balance of the asset that LRTDepositPool has. Now the problem is, when user calls LRTDepositPool::deposit(), this function first transfer the tokens to itself and then calls _mintRsETH() to calculate the amount of RSETH to mint to the user.

2023-11-kelp/src/LRTDepositPool.sol

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

        // this will increase the balance before any calculation
@>        if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) {
            revert TokenTransferFailed();
        }

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

        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }

Github: 136-138,141

But when a call reaches to LRTDepositPool::getAssetDistributionData(), the function calculate the assetLyingInDepositPool using the above operation that just reads the balance of token that is stored in LRTDepositPool using ERC20::balanceOf() function. But this balance now also stores the newly added tokens as they were transferred to this contract in the beginning. That means the data in assetLyingInDepositPool will be more than it should be. This amount will be returned back to LRTOracle::getRSETHPrice() after going through some other functions that will add this amount to balance of token stored in NodeDelegator and Eigen Layer strategies.


File: 2023-11-kelp/src/LRTOracle.sol

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

        // expensive?
        return totalETHInPool / rsEthSupply;
    }

Github: 70-71

This totalAssetAmt contains all amount of a token that is stored in different contracts that also includes the newly deposited amount. Then this amount will be multiplied with corresponding ETH price feed and will be stored in totalETHInPool. Now when the RSETH price is returned from the function it will be in ratio of total eth deposited in pools to the total supply of RSETH. And since the RSETH supply will be less than the total deposits of eth stored in totalETHInPool because the corresponding RSETH has not been minted yet. the ratio returned will be more than expected. This ratio will increase if the amount provided to LRTDepositPool::deposit(...) is increased.

Now this ratio will be returned to the LRTDepositPool::getRsETHAmountToMint() and it will perform the following calculation:

File: 2023-11-kelp/src/LRTDepositPool.sol
    
    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
        // @audit what if the tokens has different decimals?

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

Github: 95-110

This ratio returned is in the denominator of the calculation that means the bigger this ratio is and the lesser will be rsethAmountToMint. And this rsethAmountToMint is the actual amount of RSETH token that will be minted to the user. So bigger the deposit by the user, the lesser the tokens he will receive.

Proof of Concept

Test for Poc
    function test_depositToPoolMultipleTimes() public {
        uint256 amount = 500 ether;

        for (uint256 i; i < 5; i++) {
            // minting tokens to user to make sure he has enough tokens
            cbETH.mint(alice, amount);

            uint256 userBalanceBefore = IERC20(cbETH).balanceOf(alice);
            (uint256 rsEthMinted) = depositAssetToPool(address(cbETH), amount, alice, i + 1);
            uint256 userBalanceAfter = IERC20(cbETH).balanceOf(alice);

            assertEq(userBalanceBefore - userBalanceAfter, amount, "amount is not same");
        }
    }

Link to the original test: Test

Output

 ------------------------ Run 1:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 0
  LRTDepositPool Balance After Deposit: 500000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 500000000000000000000
  TotalSupply of RSETH: 500000000000000000000

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



  ------------------------ Run 2:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 500000000000000000000
  LRTDepositPool Balance After Deposit: 1000000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 250000000000000000000
  TotalSupply of RSETH: 750000000000000000000

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



  ------------------------ Run 3:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 1000000000000000000000
  LRTDepositPool Balance After Deposit: 1500000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 250000000000000000000
  TotalSupply of RSETH: 1000000000000000000000

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



  ------------------------ Run 4:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 1500000000000000000000
  LRTDepositPool Balance After Deposit: 2000000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 250000000000000000000
  TotalSupply of RSETH: 1250000000000000000000

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



  ------------------------ Run 5:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 2000000000000000000000
  LRTDepositPool Balance After Deposit: 2500000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 250000000000000000000
  TotalSupply of RSETH: 1500000000000000000000

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

As you can see the RSETH minted was equal to the token deposited in the first deposit only because the RSETH supply was 0 at that time and LRTOracle::getRSETHPrice(...) function has this check that ensures that the ratio will be 1 ether in case the supply of RSETH is zero:

File: 2023-11-kelp/src/LRTOracle.sol

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

Github: 56

So the ratio of token minted by token deposited in second transaction becomes = 250 / 500 = 0.5. Or we can say for each token deposit we got back 0.5 RSETH.

The mint amount get lesser when bigger amount of tokens are deposited. Here is a test that proves that:

    function test_depositToPoolMultipleTimesDifferentAmount() public {
        uint256 amount = 500 ether;
        uint256 amount2 = 20_000 ether;


        // minting tokens to user to make sure he has enough tokens
        cbETH.mint(alice, amount + amount2);


        uint256 userBalanceBefore = IERC20(cbETH).balanceOf(alice);
        (uint256 rsEthMinted) = depositAssetToPool(address(cbETH), amount, alice, 1);
        uint256 userBalanceAfter = IERC20(cbETH).balanceOf(alice);
        assertEq(userBalanceBefore - userBalanceAfter, amount, "amount is not same");


        userBalanceBefore = IERC20(cbETH).balanceOf(alice);
        (rsEthMinted) = depositAssetToPool(address(cbETH), amount2, alice, 2);
        userBalanceAfter = IERC20(cbETH).balanceOf(alice);


        assertEq(userBalanceBefore - userBalanceAfter, amount2, "amount is not same");
    }

Link to original Test: 373

Output:

PS E:\kelp-audit> forge test --mt test_depositToPoolMultipleTimesDifferentAmount -vv  
[â °] Compiling...
No files changed, compilation skipped

Running 1 test for test/Integration.t.sol:BaseIntegrationTest
[PASS] test_depositToPoolMultipleTimesDifferentAmount() (gas: 413133)    
Logs:


  ------------------------ Run 1:  Deposit Info  ------------------------

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 0
  LRTDepositPool Balance After Deposit: 500000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 500000000000000000000
  TotalSupply of RSETH: 500000000000000000000

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



  ------------------------ Run 2:  Deposit Info  ------------------------

  Deposit Amount: 20000000000000000000000
  LRTDepositPool Balance Before Deposit: 500000000000000000000
  LRTDepositPool Balance After Deposit: 20500000000000000000000
  Change in Balance of LRTDepositPool: 20000000000000000000000
  RSETH Minted to User: 487804878048780487804
  TotalSupply of RSETH: 987804878048780487804

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


Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.11ms

Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
PS E:\kelp-audit> 

As you can can see for 500 tokens we got 500 RSETH initially and for 20000 tokens we got only 487 RSETH approx. So the ratio of token minted by token deposited becomes = 487 / 20000 = 0.02 approx. Or we can say for each token deposit we got 0.02 RSETH.

Tools Used

  • Manual Review
  • Foundry

It is recommended to add the following check after the minting function:

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

        // this will increase the balance before any calculation
-        if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) {
-            revert TokenTransferFailed();
-        }

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

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

        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }

Assessed type

Other

#0 - c4-pre-sort

2023-11-16T02:48:10Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T02:48:28Z

raymondfam marked the issue as duplicate of #62

#2 - c4-judge

2023-11-29T21:20:34Z

fatherGoose1 marked the issue as satisfactory

#3 - c4-judge

2023-12-01T19:00:05Z

fatherGoose1 changed the severity to 2 (Med Risk)

#4 - c4-judge

2023-12-04T15:31:41Z

fatherGoose1 changed the severity to 3 (High Risk)

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L63 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L141-L141 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L79

Vulnerability details

The calculation for amount of RSETH to be minted to the user for the deposit made depends upon the balances of different tokens deposited in the LRTDepositPool, NodeDelegator and EigenLayer strategies. But for the calculation of these deposit it uses IERC20(asset).balanceOf(address(this)). This simply means the amount of tokens that these contracts has not the actual amount deposited by the user. Now anyone can transfer the asset directly to the contracts and completely mess up the calculation which will result in less tokens to be minted to the users.

Impact

The LRTDepositPool::depositAsset(...)) function uses LRTDepositPool::_mintRsETH(...) function to calculate the amount of RSETH to be minted to the user. Here is a high level flow of the calls for the function:

LRTDepositPool::deposit() |_ LRTDepositPool::_mintRsETH() |_ LRTDepositPool::getRsETHAmountToMint() |_ LRTOracle::getAssetPrice() |_ LRTOracle::getRSETHPrice() |_ LRTOracle::getSupportedAssetList() |_ LRTOracle::getAssetPrice() |_ LRTDepositPool::getTotalAssetDeposits() @> |_ LRTDepositPool::getAssetDistributionData() |_ NodeDelegator::getAssetBalance()

When the call reaches to LRTDepositPool::getAssetDistributionData(), this function does the following calculation to check the total deposits made by the user:

File: 2023-11-kelp/src/LRTDepositPool.sol

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

Github: 71

It just uses IERC20(asset).balanceOf(address(this)) to get the balances stored in the contracts. But this balance doesn't mean that the balance returned is the actual deposit made. It is just the amount of tokens the contracts hold. Now anybody can come and mess this calculation up by transferring LRTDepositPool and NodeDelegator some tokens directly without going through the LRTDepositPool::deposit(...). Because of this transfer there will be no corresponding RSETH minted and the amount of tokens stored in the contract will be increased. So assetLyingInDepositPool and assetLyingInNDCs will return incorrect deposit data because of the amount transferred. Now when this amount is returned back to the caller function that is LRTDepositPool::getTotalAssetDeposits(...) it will be combined together and returned back to LRTOracle::getRSETHPrice() function:

File: 2023-11-kelp/src/LRTDepositPool.sol

    function getTotalAssetDeposits(address asset) public view override returns (uint256 totalAssetDeposit) {
        (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) =
            getAssetDistributionData(asset);
@>        return (assetLyingInDepositPool + assetLyingInNDCs + assetStakedInEigenLayer);
    }

Github: 47-51

When this amount is received by LRTOracle::getRSETHPrice(), it will multiply the amount will it's corresponding ETH conversion price. And will store it in totalETHInPool. Since the wrong info was returned from the previous function, the amount stored in totalETHInPool will be wrong(will be more if we say more precisely) as well.

File: 2023-11-kelp/src/LRTOracle.sol

    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;
            }
        }
        // @audit can we manipulate the price and get more rsEth? or can we manipulate the price and make the rsEth more
        // expensive?
        return totalETHInPool / rsEthSupply;
    }

Github: 52

And in the end this totalETHInPool is used to return the ratio of total Eth deposited to total RSETH supply. Or we can say for a single RSETH minted, how much ETH(or token in terms of eth) is deposited. since totalETHInPool holds more amount than the actual, the ratio returned will be more than the actual ratio. Now this ratio will be returned back to LRTDepositPool::getRsETHAmountToMint() and it will perform the following calculation:

File: 2023-11-kelp/src/LRTDepositPool.sol

    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
        // @audit what if the tokens has different decimals?

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

Github: 95

As you can see lrtOracle::getRSETHPrice(...) is in the denominator, that means bigger the value returned from the function is the lesser will be rsethAmountToMint which is the actual amount of RSETH tokens that user will get. And that is what happening actually in this case. That means the amount of tokens minted to the user will be less than it should be. Hence user will be in loss.

Now this is not it, this problem will become even bigger if the amount transferred to NodeDelegator or LRTDepositPool is transferred to EigenLayer strategy. Because then we will not have any control of it in our hand. So that means the function that does the deposit to EigenLayer should never deposit any extra amount rather than the deposit made through LRTDepositPool::depositAsset(...). But that is not what's happening in the contracts. Here is the function that deposit assets to EigenLayer strategy:

File: 2023-11-kelp/src/NodeDelegator.sol

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

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

Github: 51

As you can see, this also has the same issue that we talked earlier. This will also read the amount of token to be transferred to the strategy based on the same calculation token.balanceOf(address(this)). That means this will deposit all amount of tokens that is stored in the contracts into the strategy and the following line of code will also fetch the wrong balance in LRTDepositPool::getAssetDistributionData(...) that we cannot even change(because now we loose the control of assets) as this line only reads the amount of tokens deposited in the strategy from the strategy itself by using NodeDelegator::getAssetBalance(...) function:

File: 
    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;
            }
        }
    }

Github: 71

Now the amount of assets returned will always be more than the corresponding RSETH minted and the ratio will go higher and the calculation will calculate less tokens to be minted for the user.

Proof of Concept

Here is a test for PoC
File: 2023-11-kelp/src/LRTDepositPool.sol

   function test_TheRSETHAmountToBeMintedCanBeChangedByAnyoneEasily() public {
        uint256 amount = 500 ether;
        uint256 biggerAmount = 10_000 ether;

        // minting tokens to user to make sure he has enough tokens
        cbETH.mint(alice, 20_000 ether);

        uint256 userBalanceBefore = IERC20(cbETH).balanceOf(alice);
        (uint256 rsEthMinted) = depositAssetToPool(address(cbETH), amount, alice, 1);
        uint256 userBalanceAfter = IERC20(cbETH).balanceOf(alice);

        assertEq(userBalanceBefore - userBalanceAfter, amount, "amount is not same");

        // depositing tokens to node delegator. This is neccessary to make this work.
        // Also it will be very normal that some amount of tokens will remain deposited in the node delegator
        vm.startPrank(manager);
        lrtDepositPoolP.transferAssetToNodeDelegator(0, address(cbETH), amount);
        vm.stopPrank();

        // getting the RSETH amount to be minted for `amount` of tokens
        // the actual amount minted will be less than `rsEthMintedForAmount` because there is
        // another bug in the code that will make the amount less than `rsEthMintedForAmount`
        uint256 rsEthMintedForAmount = lrtDepositPoolP.getRsETHAmountToMint(address(cbETH), amount);

        console2.log("> Getting RSETH Amount to be Minted After the token deposit through `depositAsset(...)`:");
        console2.log("\tAmount To Deposit: %s", amount);
        console2.log("\tAmount of RSETH Tokens to Recieve: %s\n", rsEthMintedForAmount);

        // alice transfer tokens directly to node delegator without going through the deposit pool
        vm.startPrank(alice);
        IERC20(cbETH).transfer(address(nodeDelP), amount);
        vm.stopPrank();

        // again fetching the RSETH amount to be minted for `amount` of tokens
        rsEthMintedForAmount = lrtDepositPoolP.getRsETHAmountToMint(address(cbETH), amount);

        console2.log("> Getting RSETH Amount to be Minted After the direct token deposit of %s amount:", amount);
        console2.log("\tAmount To Deposit: %s", amount);
        console2.log("\tAmount of RSETH Tokens to Recieve: %s", rsEthMintedForAmount);
    }

Link to Original Test: Test

Output:

PS E:\kelp-audit> forge test --mt test_TheRSETHAmountToBeMintedCanBeChangedByAnyoneEasily -vv
[â °] Compiling...
No files changed, compilation skipped

Running 1 test for test/Integration.t.sol:BaseIntegrationTest
[PASS] test_TheRSETHAmountToBeMintedCanBeChangedByAnyoneEasily() (gas: 460785)
Logs:


  ------------------------ Run 1:  Deposit Info  ------------------------     

  Deposit Amount: 500000000000000000000
  LRTDepositPool Balance Before Deposit: 0
  LRTDepositPool Balance After Deposit: 500000000000000000000
  Change in Balance of LRTDepositPool: 500000000000000000000
  RSETH Minted to User: 500000000000000000000
  TotalSupply of RSETH: 500000000000000000000

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

  > Getting RSETH Amount to be Minted After the token deposit through `depositAsset(...)`:
        Amount To Deposit: 500000000000000000000
        Amount of RSETH Tokens to Recieve: 500000000000000000000

  > Getting RSETH Amount to be Minted After the direct token deposit of 500000000000000000000 amount:
        Amount To Deposit: 500000000000000000000
        Amount of RSETH Tokens to Recieve: 250000000000000000000

Note The amount shown in the output is not the actual amount that will be minted to the user when called depositAsset(...) as there is another vulnerability in the contract. The getter function does not account for that vulnerability but it is present in deposit function.

As you can see when alice called LRTDepositPool::getRsETHAmountToMint(...) first time after making a deposit, we were getting 500e18 RSETH for 500e18 cbETH because the deposit made by user before this minted the corresponding RSETH. And that's why the ratio didn't change. But when alice sent token directly, the cbETH amount inside the contracts increased but RSETH supply remained same. which is why the ratio become more than expected. Now we are getting 250e18 RSETH for 500e18 cbETH that make the eth to rsETH ratio 500 / 250 = 2 which was 500 / 500 = 1 in previous case.

Tools Used

  • Manual Review
  • Foundry

Not able to think of a good scenario because of the complexity involved. This is what i could think of:

It is recommended to create a state inside the NodeDelegator and LRTDepositPool that keeps the track of the token deposited and use that state instead of IERC20(token).balanceOf(address(this)). But there is one more problem with this approach. In order for this to work, the protocol will have to store corresponding shares amount(for tokens that has this mechanism) in that state not the balanceOf() amount. Because if the protocol chooses to use balance directly then this recommendation wouldn't work. Because LSTs are rebasing tokens(mostly) that means the prices of these goes up and down because of rewards distribution and changes in the total supply. Infact stETH token that will be added initially into the pool is a rebasing token. And this token has mechanism to change the balances of the addresses after every few hours [Source: Lido Github]. Lido docs doesn't even recommend to use stETH directly. They recommend to use shares of stETH an address hold instead. More info can be gathered on this from here. So if protocol use stETH balance directly with the current recommendation, it wouldn't work because there are chances that the shares prices of stETH goes down or we can say negative rebase happen (and it has happened before) due to slashing or for some other reason. So this created state (that is totalTokensDeposit in below example) holds the amount that is more than the actual balances of the contracts. And this will cause the transfer calls to revert. And all tokens might get stuck. So instead of storing the stETH balance, use either shares(recommended by Lido) or wstETH which is non-rebasing wrapper around stETH. More info on wstETH is available here.

But using shares can cause some other issues like converting it back to stETH etc. So it is recommended to use wstETH. Now we can do the following changes

File: 2023-11-kelp/src/LRTDepositPool.sol

+   mapping(address token => uint256 amount) public totalTokensDeposit;

    function depositAsset(
        address asset,
        uint256 depositAmount
    )
        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);
+       totalTokensDeposit[asset] += depositAmount;
        emit AssetDeposit(asset, depositAmount, rsethAmountMinted);
    }
File: 2023-11-kelp/src/LRTDepositPool.sol

+   mapping(address token => uint256 amount) public totalTokensDeposit;
    
    function transferAssetToNodeDelegator(
        uint256 ndcIndex,
        address asset,
        uint256 amount
    )
        external
        nonReentrant
        onlyLRTManager
        onlySupportedAsset(asset)
    {
        address nodeDelegator = nodeDelegatorQueue[ndcIndex];
+       totalTokensDeposit[asset] -= amount;

        if (!IERC20(asset).transfer(nodeDelegator, amount)) {
            revert TokenTransferFailed();
        }
    }
File: 2023-11-kelp/src/LRTDepositPool.sol
+   mapping(address token => uint256 amount) public totalTokensDeposit;

    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));
+        assetLyingInDepositPool = totalTokensDeposit[asset];

        uint256 ndcsCount = nodeDelegatorQueue.length;
        for (uint256 i; i < ndcsCount;) {
-            assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]);
+            assetLyingInNDCs += INodeDelegator(nodeDelegatorQueue[i]).totalTokensDeposit(asset);

            assetStakedInEigenLayer += INodeDelegator(nodeDelegatorQueue[i]).getAssetBalance(asset);
            unchecked {
                ++i;
            }
        }
    }

Similar kind of changes needs to be done in all contracts.

It is also recommended to create a withdraw(...) function that can be used to withdraw any access balance by owner or manager that might serve as a profit.

Assessed type

Other

#0 - c4-pre-sort

2023-11-16T03:24:30Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T03:24:39Z

raymondfam marked the issue as duplicate of #42

#2 - c4-judge

2023-12-01T17:02:33Z

fatherGoose1 marked the issue as satisfactory

Awards

2.7592 USDC - $2.76

Labels

bug
disagree with severity
downgraded by judge
grade-b
high quality report
primary issue
QA (Quality Assurance)
satisfactory
sponsor confirmed
edited-by-warden
Q-34

External Links

Lines of code

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

Vulnerability details

Chainlink Price Feeds does not maintain a consistent pattern for the number of decimals in returned prices. While some feeds provide token prices with 8 decimals, others may have 18 decimals or more. However, the contracts assume that prices will always be provided with 18 decimals. This could lead to discrepancies in the amount of tokens minted to users, potentially resulting in losses for them. While the price feeds for the initially supported token rETH, cbETH and stETH does have 18 decimals price feeds, but there are chances that in future new tokens are added with different decimals price feeds.

Impact

Some Chainlink price feeds return prices in different decimal places, such as 8 or 18, which may not align with the token's decimal representation. For example, USDC follows 6 decimal places, but the price feeds for USDC/USD return prices in 8 decimal places. You can check the information using the following links:

So there are good chances that the price feed for the newly added LST follows different decimal representation than the decimal representation of the LST itself.

This inconsistency can result in the amount of tokens minted to the user being less than the expected amount. The incorrect result is calculated by the following functions:

File: 2023-11-kelp/src/LRTDepositPool.sol

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

Github: 109

File: 2023-11-kelp/src/LRTOracle.sol

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

Github: 71

Proof of Concept

Here is a test for PoC
    function test_ProtocolWillNotWorkWithChainlinkPriceFeedsWithDifferentDecimalValues() public {
        // adding new LSToken
        addNewLST();

        uint256 amount = 500 ether;
        // updating decimals of the mock price aggregator.
        // this is not possible to do in original price aggregator. It's just a mock funciton to
        // simulate the condition. This stil represents 1 ETH : 1 LST
        mockPriceAggregator.updateDecimals(1e8);

        // checking if the decimals are updated
        assertEq(mockPriceAggregator.decimals(), 1e8, "decimals are not same");

        // getting the RSETH amount to be minted for `amount` of tokens
        // the actual amount minted will be less than `rsEthMintedForAmount` because there is
        // another bug in the code that will make the amount less than `rsEthMintedForAmount`
        uint256 rsEthMintedForAmount = lrtDepositPoolP.getRsETHAmountToMint(address(mLst), amount);

        console2.log("> Details for the Amount to be Mint for LST deposit");
        console2.log("\tAmount To Deposit: %s", amount);
        console2.log("\tAmount of RSETH Tokens to Recieve: %s\n", rsEthMintedForAmount);
    }

Link to the Original Test: Test

Outupt:

Running 1 test for test/Integration.t.sol:BaseIntegrationTest
[PASS] test_ProtocolWillNotWorkWithChainlinkPriceFeedsWithDifferentDecimalValues() (gas: 425074)
Logs:
  > Details for the Amount to be Mint for LST deposit
        Amount To Deposit: 500000000000000000000
        Amount of RSETH Tokens to Recieve: 50000000000


Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.15ms

Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Note The actual amount minted will be different as there is another vulnerability in the function

As you can see in the output, for 500e18 LST tokens we are only receiving 0.00000005 RSETH.

Tools Used

  • Manual Review
  • Foundry

To address the issue of inconsistent decimals in the RSETH minting calculation, it is recommended to do the following changes in the contracts:

  1. Add a mapping in the contracts to represent the price feeds decimals corresponding to each asset.
  2. Modify the LRTDepositPool::getRsETHAmountToMint(...) and LRTOracle::getRSETHPrice(...) functions to use the price feed decimals for the asset when calculating the amount of RSETH to be minted.

Here's an example of how the changes could be implemented:

File: 2023-11-kelp/src/LRTDepositPool.sol

+    mapping(address asset => uint256 priceFeedDecimals) public priceFeedDecimals

    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) * (18 - priceFeedDecimals(asset))) / lrtOracle.getRSETHPrice();
    }
File: 2023-11-kelp/src/LRTOracle.sol
    
    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;
+            totalETHInPool += totalAssetAmt * assetER * (18 - ILRTDepositPool(lrtDepositPoolAddr).priceFeedDecimals(asset));

            unchecked {
                ++asset_idx;
            }
        }
        // @audit can we manipulate the price and get more rsEth? or can we manipulate the price and make the rsEth more
        // expensive?
        return totalETHInPool / rsEthSupply;
    }

By adding the priceFeedDecimals mapping and modifying the calculation, we ensure that the RSETH amount is calculated correctly based on the decimals provided by the price feeds. This mitigates the potential issue of inconsistent decimals and ensures that users receive the correct amount of RSETH for their deposits.

The changes also needs to be done in other functions as well that are using this data.

Assessed type

Decimal

#0 - c4-pre-sort

2023-11-16T07:32:36Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T07:32:54Z

raymondfam marked the issue as duplicate of #97

#2 - c4-pre-sort

2023-11-17T08:01:07Z

raymondfam marked the issue as high quality report

#3 - c4-pre-sort

2023-11-17T08:01:17Z

raymondfam marked the issue as not a duplicate

#4 - c4-pre-sort

2023-11-17T08:01:23Z

raymondfam marked the issue as primary issue

#5 - c4-sponsor

2023-11-24T08:34:42Z

gus-staderlabs marked the issue as disagree with severity

#6 - gus-stdr

2023-11-24T08:37:23Z

LSTs are ETH derived tokens which we have in mind it is 1e18. The USDC example used though it illustrates the issue, it is not pertinent in the context of the KelpDao. Thus we agreed internally that this is a medium to low severity issue. Medium should suffice.

#7 - c4-sponsor

2023-11-24T10:20:16Z

manoj9april (sponsor) confirmed

#8 - c4-judge

2023-12-01T18:01:03Z

fatherGoose1 changed the severity to 2 (Med Risk)

#9 - fatherGoose1

2023-12-01T18:01:54Z

Confirming as Medium. This issue highlights the potential for miscalculation for LSTs added in the future.

#10 - c4-judge

2023-12-01T18:05:59Z

fatherGoose1 marked the issue as satisfactory

#11 - c4-judge

2023-12-01T19:01:42Z

fatherGoose1 marked the issue as selected for report

#12 - thangtranth

2023-12-02T00:28:14Z

@fatherGoose1 : The price feeds with ETH as quote tokens (X/ETH) always have 18 decimals. It would be valid if the warden can show a not-yet-supported LST/ETH priceFeed with with a decimal count differing from 18.

image

The example in the issue is incorrect (USDC/USD pricefeed). The issue is purely speculative and has not yet been observed in practice, so it is appropriate to classify as QA.

#13 - ShaheenRehman

2023-12-02T05:23:20Z

I agree @thangtranth. Actually, I think this is not even a QA finding. USDC example is completely out of the box. If it will be added in the future then it should be in-scope of the future audits. This is clearly OOS. The fix will only gonna increase code complexity unnecessarily.

#14 - AtanasDimulski

2023-12-02T08:27:20Z

I completely agree with @thangtranth and @DevABDee, I would also like to add that tokens that can interact with the Kelp DAO, are first whitelisted by the team. First, there is no guarantee that new tokens will be added at all, secondly, the possibility of Chainlink deciding to add a price feed for new LST/ETH with different decimals is highly unlikely. Most importantly issues of possible future upgrades have never been accepted, otherwise saying that the protocol may add a function that transfers all funds to my address should also be considered a valid finding. In my opinion, this is NC, Low at best.

#15 - manoj9april

2023-12-03T06:19:03Z

Yes I agree with other wardens. We can assign it a low severity.

#16 - PlamenTSV

2023-12-03T16:22:35Z

This is one of the reasons I didn't submit this - I couldn't find an LST with different than 18 decimals and it would be highly unlikely that one would arise and gain enough popularity. So I agree this is QA

#18 - fatherGoose1

2023-12-04T17:24:40Z

Thank you @thangtranth. This will be downgraded to QA despite sponsor's agreement with medium severity. After looking at Chainlink's available price feeds, I confirm that 18 decimals are the standard and it would not make sense for them to support an LST price feed with other than 18 decimals.

#19 - c4-judge

2023-12-04T17:24:51Z

fatherGoose1 changed the severity to QA (Quality Assurance)

#20 - c4-judge

2023-12-08T18:56:40Z

fatherGoose1 marked the issue as grade-b

#21 - c4-judge

2023-12-08T18:57:02Z

fatherGoose1 marked the issue as not selected for report

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