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
Rank: 10/185
Findings: 4
Award: $946.02
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: m_Rassska
Also found by: 0xDING99YA, Aamir, Bauchibred, CatsSecurity, HChang26, PENGUN, SBSecurity, adriro, almurhasan, anarcheuz, circlelooper, d3e4, deth, jayfromthe13th
902.5718 USDC - $902.57
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.
According to the chainlink docs, following are the Price feeds info for the assets that will be used initially:
Feed | Heartbeat | Deviation | Docs link |
---|---|---|---|
RETH / ETH | 2% | 86400s | Docs |
CBETH / ETH | 1% | 86400s | Docs |
RETH / ETH | 0.5% | 86400s | Docs |
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
Calculation and Link given in the above section.
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.
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()
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 theRETH
fluctuates by2%
or time equal to86400s
(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:
1
and new price of asset = 1.018
1
.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.RSETH
at correct prices.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.
🌟 Selected for report: T1MOH
Also found by: 0x1337, 0xNaN, 0xepley, 0xluckhu, 0xmystery, 7siech, Aamir, AlexCzm, Aymen0909, DanielArmstrong, GREY-HAWK-REACH, HChang26, Jiamin, Juntao, QiuhaoLi, Ruhum, SBSecurity, Varun_05, Weed0607, adam-idarrha, adriro, ast3ros, ayden, circlelooper, crack-the-kelp, crunch, cryptothemex, deepplus, mahdirostami, max10afternoon, osmanozdemir1, rouhsamad, rvierdiiev, trachev, xAriextz, zhaojie
36.0335 USDC - $36.03
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
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.
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); }
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.
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
.
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); }
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)
🌟 Selected for report: Krace
Also found by: 0xDING99YA, 0xrugpull_detector, Aamir, AlexCzm, Aymen0909, Banditx0x, Bauer, CatsSecurity, GREY-HAWK-REACH, Madalad, Phantasmagoria, QiuhaoLi, Ruhum, SBSecurity, SandNallani, SpicyMeatball, T1MOH, TheSchnilch, adam-idarrha, adriro, almurhasan, ast3ros, ayden, bronze_pickaxe, btk, chaduke, ck, crack-the-kelp, critical-or-high, deth, gumgumzum, jasonxiale, joaovwfreire, ke1caM, m_Rassska, mahdirostami, mahyar, max10afternoon, osmanozdemir1, peanuts, pep7siup, peter, ptsanev, qpzm, rouhsamad, rvierdiiev, spark, twcctop, ubl4nk, wisdomn_, zach, zhaojie
4.6614 USDC - $4.66
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
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.
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.
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.
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.
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
🌟 Selected for report: m_Rassska
Also found by: 0x1337, 0xAadi, 0xHelium, 0xLeveler, 0xblackskull, 0xbrett8571, 0xepley, 0xffchain, 0xluckhu, 0xmystery, 0xrugpull_detector, 0xvj, ABAIKUNANBAEV, Aamir, AerialRaider, Amithuddar, Bauchibred, Bauer, CatsSecurity, Cryptor, Daniel526, Draiakoo, Eigenvectors, ElCid, GREY-HAWK-REACH, Inspecktor, Juntao, King_, LinKenji, Madalad, MaslarovK, Matin, MatricksDeCoder, McToady, Noro, PENGUN, Pechenite, Phantasmagoria, RaoulSchaffranek, SBSecurity, SandNallani, Shaheen, Soul22, Stormreckson, T1MOH, Tadev, TeamSS, TheSchnilch, Topmark, Tumelo_Crypto, Udsen, Yanchuan, ZanyBonzy, _thanos1, adeolu, adriro, alexfilippov314, almurhasan, amaechieth, anarcheuz, ayden, baice, bareli, boredpukar, bronze_pickaxe, btk, cartlex_, catellatech, chaduke, cheatc0d3, circlelooper, codynhat, crack-the-kelp, critical-or-high, debo, deepkin, desaperh, dipp, eeshenggoh, evmboi32, ge6a, gesha17, glcanvas, gumgumzum, hals, hihen, hunter_w3b, jasonxiale, joaovwfreire, ke1caM, leegh, lsaudit, marchev, merlinboii, niser93, osmanozdemir1, paritomarrr, passion, pep7siup, phoenixV110, pipidu83, poneta, ro1sharkm, rouhsamad, rvierdiiev, sakshamguruji, seerether, shealtielanz, soliditytaker, spark, squeaky_cactus, stackachu, supersizer0x, tallo, taner2344, turvy_fuzz, twcctop, ubl4nk, wisdomn_, xAriextz, zach, zhaojie, zhaojohnson, ziyou-
2.7592 USDC - $2.76
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
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.
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:
USDC/USD
: https://docs.chain.link/data-feeds/price-feeds/addresses?network=ethereum&page=1&search=usdc%2FusdSo 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
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
.
To address the issue of inconsistent decimals in the RSETH
minting calculation, it is recommended to do the following changes in the contracts:
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.
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.
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
#17 - radeveth
2023-12-04T16:42:04Z
#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