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: 21/185
Findings: 2
Award: $196.33
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: max10afternoon
Also found by: 0xMilenov, 0xbrett8571, 0xhacksmithh, 0xmystery, Aymen0909, Bauer, Daniel526, PENGUN, Pechenite, Shaheen, adriro, anarcheuz, btk, ck, ge6a, glcanvas, hals, turvy_fuzz
140.2525 USDC - $140.25
https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L151-L157 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L109
In LRTDepositPool.depositAsset
function : when a user deposits a supported LST asset in the deposit pool, he will be minted share rsETH
tokens proportional to the deposited amount and the asset price.
And as per the the price calculations, the depositor will almost get an amount of share tokens (rsETH
) equivalent to the amount of the deposited asset:
LRTDepositPool.getRsETHAmountToMint function/L109
rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
where lrtOracle.getRSETHPrice()
is totalETHInPool / rsEthSupply
.
But this function is missing the slippage parameter (minimum amount of share tokens to be minted) that ensures the user gets a fair amount of shares (as the rsETH
price calculation might be affected if the deposit pool receives a direct LST transfers without going through the depositing process-minting of rsETH
).
LRTDepositPool._mintRsETH function
function _mintRsETH(address _asset, uint256 _amount) private returns (uint256 rsethAmountToMint) { (rsethAmountToMint) = getRsETHAmountToMint(_asset, _amount); address rsethToken = lrtConfig.rsETH(); // mint rseth for user IRSETH(rsethToken).mint(msg.sender, rsethAmountToMint); }
LRTDepositPool.getRsETHAmountToMint function/L109
rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
Manual Review.
In depositAsset
function: add a _minShareAmount
parameter as a slippage protection:
function depositAsset( address asset, uint256 depositAmount, + uint256 _minShareAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { // checks if (depositAmount == 0) { revert InvalidAmount(); } if (depositAmount > getAssetCurrentLimit(asset)) { revert MaximumDepositLimitReached(); } if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); + require(rsethAmountMinted > _minShareAmount,"calculated less than required by the user"); emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
Context
#0 - c4-pre-sort
2023-11-16T06:21:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T06:21:46Z
raymondfam marked the issue as duplicate of #39
#2 - c4-pre-sort
2023-11-17T06:43:11Z
raymondfam marked the issue as duplicate of #148
#3 - c4-judge
2023-11-29T19:10:18Z
fatherGoose1 marked the issue as satisfactory
🌟 Selected for report: max10afternoon
Also found by: 0xMilenov, 0xbrett8571, 0xhacksmithh, 0xmystery, Aymen0909, Bauer, Daniel526, PENGUN, Pechenite, Shaheen, adriro, anarcheuz, btk, ck, ge6a, glcanvas, hals, turvy_fuzz
140.2525 USDC - $140.25
The main purpose of the node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp
protocol if the EigenLayer paused the deposit operations.
First the LST assets are transferred to a nodeDelegator
contract by the deposit pool manager, then transferred to eigenStrategyManager
contract by calling NodeDelegator.depositAssetIntoStrategy
function that will in turn calls the IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);
.
The IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance)
function deposits balance
of token
into the specified strategy
, with the resultant shares credited to msg.sender
which is the nodeDelegator contract that initiated the call.
And this function will return the amount of new shares in the strategy
created as part of the action.
But it was noted that this returned share value is not checked against any minimum value determined by the node delegator nor against zero, which might result in protocol losing from its LST balance without getting enough/equivalent shares to withdraw from EigenLayer protocol.
NodeDelegator.depositAssetIntoStrategy function/L67
IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);
Manual Review.
In depositAssetIntoStrategy
function: add a _minShareAmount
parameter as a slippage protection, and check this value against the returned shares by the strategy manager:
- function depositAssetIntoStrategy(address asset) + function depositAssetIntoStrategy(address asset,uint256 _minShareAmount) external override whenNotPaused nonReentrant onlySupportedAsset(asset) onlyLRTManager { address strategy = lrtConfig.assetStrategy(asset); IERC20 token = IERC20(asset); address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); uint256 balance = token.balanceOf(address(this)); emit AssetDepositIntoStrategy(asset, strategy, balance); - IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance); + uint256 shares=IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance); + require(shares >= _minShareAmount); }
Context
#0 - c4-pre-sort
2023-11-16T06:23:37Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T06:24:08Z
raymondfam marked the issue as duplicate of #230
#2 - c4-judge
2023-12-01T18:36:32Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - DevHals
2023-12-01T21:48:13Z
Hi @fatherGoose1,
this issue was invalidated based on the fact that Eigen layer has an implementation to mitigate inflation attack: as per the sponsors reply on the primary issue:
Please check the eigen layer latest code here. They have implemented proper mitigation for inflated shares attack.
while checking the StrategyBase.deposit function
, it was noticed that the check made for the minted shares (newShares
) is to be != 0 :
require(newShares != 0, "StrategyBase.deposit: newShares cannot be zero");
but this doesn't fully protect the nodeDelegator contract against slippage, as the minted shares by the strategy could be very low, and NodeDelegator.depositAssetIntoStrategy
function doesn't have any minimum value determined by the manager to check the minted shares against, nor does it have a deadline to prevent executing the transaction after specific time- that might affect the minted shares as it will fluctuates with time (since the protocol is going to be deployed on the mainnet which suffers from congestion and delays of transactions executions at some times).
I kindly ask if you could take another look at the specifics of this issue and provide a re-evaluation?
Thanks!
#4 - c4-judge
2023-12-04T16:45:14Z
fatherGoose1 marked the issue as duplicate of #148
#5 - c4-judge
2023-12-04T16:45:54Z
fatherGoose1 marked the issue as satisfactory
#6 - DevHals
2023-12-04T17:32:43Z
Hi @fatherGoose1 , this issue is not a duplicate of 148, as the vulnerability introduced here is a result of not validating the slippage introduced by integrating with a 3rd party, and it has nothing to do with the shares minted by the Kelp protocol!
also to add: issue 148 introduces a loss for the users only (the protocol will not be adversely affected) while this issue here introduces a loss of protocol funds (which will affect users in return if the protocol becomes insolvent),
Could you please have a second look?
Thanks!
#7 - DevABDee
2023-12-05T01:42:42Z
I agree with @DevHals that this issue is not a dup of 148, specifically because fixing this issue will not fix 148 & 148's fix will not fix this issue. The only dup of this issue is #230. Pls re-evaluate Sir. Thanks!
#8 - fatherGoose1
2023-12-05T17:57:15Z
@DevHals @DevABDee I understand the difference in root causes of slippages between this report and #148. While the issues originate in different parts of the code and for different protocol functions entirely, I am leaving this report and #230 as duplicates of #148, simply because slippage will most likely not cause issue to any significant degree through this implementation. It is a necessary check to have to ensure the safety of Kelp's users and Kelp's held assets when referring to shares minted by Eigenlayer, but it is not likely that these stable assets will incur more than minimal slippages.
#9 - DevHals
2023-12-05T23:27:42Z
@fatherGoose1,,
the reason provided to duplicate this issue with 148 is not convincing (LSTs are not stable assets to assume that minimal slippage might be incurred), an example of Lido Staked ETH
(stETH/ETH) price fluctuation all time as per https://coinmarketcap.com/currencies/steth/steth/eth/
(where it hit 0.9394 on 20th of June/2022)
but the final say is for the judge no matter what!
anyway, I have another submitted issue #409 that is marked as a duplicate of 148; so if you see that this issue is still a duplicate of 148; then this issue should be nullified,
Thanks for your time!
#10 - DevABDee
2023-12-09T10:55:59Z
Hey Sir @fatherGoose1, I wanted to kindly request your reconsideration of our escalation following the validation of issue #584. You initially marked this issue & #230, as a duplicate of #148 due to concerns about LSTs stability. However, with the acceptance of #584, it seems evident that LSTs may not be as stable as initially perceived, aligning with the observations highlighted by @DevHals in his comment above. Both of us maintain the perspective that this is a medium issue and not a duplicate of #148. We will appreciate your final re-evaluation of this issue. Thanks!
🌟 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-
56.0791 USDC - $56.08
ID | Title | Severity |
---|---|---|
L-01 | NodeDelegator contract doesn't have a mechanism to remove LST asset approval from eigenStrategyManager contract | Low |
L-02 | LRTConfig.setRSETH function: updating the rsETH contract address will result in loss of users deposits | Low |
L-03 | LRTConfig contract: no mechanism implemented to remove supported LST assets | Low |
L-04 | LRTDepositPool.addNodeDelegatorContractToQueue function is accessed by the LRTAdmin while it should be accessed by the LRTManager | Low |
L-05 | LRTDepositPool.transferAssetToNodeDelegator function doesn't check if the nodeDelegator exists before sending it LST assets | Low |
NodeDelegator
contract doesn't have a mechanism to remove LST asset approval from eigenStrategyManager
contract <a id="l-01" ></a>The main purpose of the node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp
protocol if the EigenLayer paused the deposit operations.
First the LST assets are transferred to a nodeDelegator
contract by the deposit pool manager, then transferred to eigenStrategyManager
contract by calling NodeDelegator.depositAssetIntoStrategy
function that will in turn calls the IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance);
, but before that, the nodeDelegator should approve the eigenStrategyManager
contract on the deposited amount.
And for this approval action; the nodeDelegator
contract manager calls NodeDelegator.maxApproveToEigenStrategyManager
function, which will approve the strategy manager on the maximum LST amount.
But if the eigenStrategyManager
contract got compromised/hacked or started to act maliciously, the LST asset balance of the nodeDelegator
contract will be at risk since there's no way to remove LST assets approvals for the comromised strategy manager contract (setting the LST approval/allowance to zero) in cases of emergencies.
NodeDelegator.maxApproveToEigenStrategyManager function
function maxApproveToEigenStrategyManager(address asset) external override onlySupportedAsset(asset) onlyLRTManager { address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max); }
Manual Review.
Remove NodeDelegator.maxApproveToEigenStrategyManager
function, and update NodeDelegator.depositAssetIntoStrategy
function to approve the strategy manager contract on the deposited amount only before depositing in the strategy:
function depositAssetIntoStrategy(address asset) external override whenNotPaused nonReentrant onlySupportedAsset(asset) onlyLRTManager { address strategy = lrtConfig.assetStrategy(asset); IERC20 token = IERC20(asset); address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); uint256 balance = token.balanceOf(address(this)); emit AssetDepositIntoStrategy(asset, strategy, balance); + IERC20(asset).approve(eigenlayerStrategyManagerAddress, balance); IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance); }
- function maxApproveToEigenStrategyManager(address asset) - external - override - onlySupportedAsset(asset) - onlyLRTManager - { - address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); - IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max); - }
LRTConfig.setRSETH
function: updating the rsETH
contract address will result in loss of users deposits <a id="l-02" ></a>The admin of the LRTConfig
can change the address of the rsETH
contract, and this will result in re-setting the rsETH.totalSupply
to zero.
So if the rsETH
contract is updated; users can't redeem/withdraw their deposits as the totalSupply
would be zero (would revert due to underflow if any value is deducted from zero), also the price of the rsETH
share token will drop as it's calculated based on the totalSupply
and the total value of ETH held by the protocol (deposited in the pool + in the node delegators + in the EigenLayer strategies):
return totalETHInPool / rsEthSupply;
function setRSETH(address rsETH_) external onlyRole(DEFAULT_ADMIN_ROLE) { UtilLib.checkNonZeroAddress(rsETH_); rsETH = rsETH_; }
Manual Review.
Either prevent updating the stETH
contract address or implement a mechanism to transfer users balances and totalSupply with the new contract.
LRTConfig
contract: no mechanism implemented to remove supported LST assets <a id="l-03" ></a>The LRTConfig
contract has a function to add LST supported assets to the protocol, but it doesn't have any function or mechanism to unsupport any of these assets.
So if the protocol decided to remove any LST asset from being traded in the system due to financial risks, it will not be able to do so and users will keep depositing these assets and receive share tokens in return.
LRTConfig._addNewSupportedAsset function
function _addNewSupportedAsset(address asset, uint256 depositLimit) private { UtilLib.checkNonZeroAddress(asset); if (isSupportedAsset[asset]) { revert AssetAlreadySupported(); } isSupportedAsset[asset] = true; supportedAssetList.push(asset); depositLimitByAsset[asset] = depositLimit; emit AddedNewSupportedAsset(asset, depositLimit); }
Manual Review.
Add a mechanism to remove/unsupport any of the added LST assets.
LRTDepositPool.addNodeDelegatorContractToQueue
function is accessed by the LRTAdmin while it should be accessed by the LRTManager <a id="l-04" ></a>addNodeDelegatorContractToQueue
function is used to add nodeDelegator
contracts to the system to act as a LST hoders until depositing these assets in EigenLayer protocol, as per the comments on the addNodeDelegatorContractToQueue
function, it should be called only by the LRTManager:
/// @dev only callable by LRT manager
But the function has onlyLRTAdmin
access modifier that only allows the admin from accessing it,; not the manager.
LRTDepositPool.addNodeDelegatorContractToQueue function/L159-L162
/// @notice add new node delegator contract addresses /// @dev only callable by LRT manager /// @param nodeDelegatorContracts Array of NodeDelegator contract addresses function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTAdmin {
Manual Review.
- function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTAdmin { + function addNodeDelegatorContractToQueue(address[] calldata nodeDelegatorContracts) external onlyLRTManager {
LRTDepositPool.transferAssetToNodeDelegator
function doesn't check if the nodeDelegator
exists before sending it LST assets <a id="l-05" ></a>The main purpose of node delegators is to act as temporary LST holders before depositing in EigenLayer protocol (3rd party); mainly to prevent disabling the kelp
protocol if the EigenLayer paused the deposit operations.
Whenever the deposit limit of a specific LST asset is reached, a new nodeDelegator
contract is deployed and added to the protocol to overcome the deposit limit implemented by EigenLayer protocol.
transferAssetToNodeDelegator
function is intended to be called by the deposit pool manager to transfer LST assets to an approved nodeDelegator
contract; and then these assets to be moved to EigenLayer strategy when the strategy is not paused.
but this function doesn't check if the ndcIndex
(the index of the nodeDelegator
in the nodeDelegatorQueue
array) is within the nodeDelegatorQueue
array or not; which might lead to LST assets being transferred to address(0)
if the nodeDelegator
doesn't exist (ndcIndex
is out of bounds).
This will lead to loss of protocol funds and affecting the price of the rsETH
share tokens as the total ETH locked in the protocol will be lowered without burning an equivalenT amount of shares (the price of rsETH
will go up!).
LRTDepositPool.transferAssetToNodeDelegator function
function transferAssetToNodeDelegator( uint256 ndcIndex, address asset, uint256 amount ) external nonReentrant onlyLRTManager onlySupportedAsset(asset) { address nodeDelegator = nodeDelegatorQueue[ndcIndex]; if (!IERC20(asset).transfer(nodeDelegator, amount)) { revert TokenTransferFailed(); } }
Manual Review.
Update transferAssetToNodeDelegator
function to check if the nodeDelegator
address exists bfore transferring LST assets to it:
function transferAssetToNodeDelegator( uint256 ndcIndex, address asset, uint256 amount ) external nonReentrant onlyLRTManager onlySupportedAsset(asset) { address nodeDelegator = nodeDelegatorQueue[ndcIndex]; + require(nodeDelegator != address(0),"nodeDelegator doesn't exist"); if (!IERC20(asset).transfer(nodeDelegator, amount)) { revert TokenTransferFailed(); } }
#0 - c4-pre-sort
2023-11-18T00:44:05Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-12-01T18:49:47Z
fatherGoose1 marked the issue as grade-b
#2 - DevHals
2023-12-01T20:53:21Z
Hi @fatherGoose1,
I think my downgraded issues were not taken into consideration in the grading of my report, I kindly request your reconsideration of these issues in the final grade:
downgraded issues are: #407 #408 #410 #413 (if still considered as QA after resolving the dispute)
Thanks!
#3 - fatherGoose1
2023-12-04T17:15:24Z
I agree that this QA report combined with the multitude of reports downgraded to QA cumulates to an A report. @DevHals
#4 - c4-judge
2023-12-04T17:15:40Z
fatherGoose1 marked the issue as grade-a