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: 2/185
Findings: 4
Award: $1,091.39
🌟 Selected for report: 1
🚀 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
stETH/ETH, cbETH/ETH, rETH/ETH chainlink oracle has too long of heartbeat and deviation threshold which can wrong calculations and loss of funds
Kelp is using the chainlink oracle to get the exchange rate for three LST tokens which is evident as there are no two step conversions in the chainlink oracle:
But if we look at the chainlink price feeds all of them have the heartbeat of 24 hours and deviation threshold of 2% for cbEth, 1% for rEth, and 0.5% for stETH which in our case is actually too much and will lead to wrong calculations and mintings due to stale price/exchange rate and will not be updated unless price deviation is 2% or more :
Now imagine the scenario for the sake of example for one asset cbETH for which the deviation threshold is 2% and heartbeat is 24 hours and within that 24 hours deviation of 1.8% occurs in positive and someone tries to deposit via the kelp.
Chainlink Price Feed Data and some common sense plus validation from solodit.
There are two options:
Oracle
#0 - c4-pre-sort
2023-11-16T21:40:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T21:40:49Z
raymondfam marked the issue as duplicate of #609
#2 - c4-judge
2023-12-01T17:43:14Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-12-08T17:32:45Z
fatherGoose1 marked the issue as duplicate of #584
#4 - c4-judge
2023-12-08T17:49:08Z
fatherGoose1 marked the issue as satisfactory
#5 - d3e4
2023-12-08T21:23:47Z
@fatherGoose1 The claim here is invalid with the information provided. If the oracle price is off by 1.8% then the (single) underlying asset will be valued accordingly and Alice will be minted the correct amount after all. In fact, with a single asset the price does not even matter. #584 correctly details the necessary requirement that there be more than one underlying asset, such that an arbitrage appears between them. This is not touched upon at all here.
🌟 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/LRTDepositPool.sol#L95-L109 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTOracle.sol#L52-L79 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L79
The vulnerability is similar to how the well-known first deposit attack works, but in this case since there is no withdraw function, the result is huge loss of minted rsETH
for the 2nd depositor.
The entry-point for depositing into the protocol is LRTDepositPool::depositAsset
which invokes _mintRsETH
, which in turn invokes getRsETHAmountToMint
to calculate how much rsETH to mint for the deposit amount. Taking a closer look at the function we can see that it is calculated by:
// calculate rseth amount to mint based on asset amount and asset exchange rate rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
Inspecting the LRTOracle::getRSETHPrice
function we can see that if the rsETHSupply == 0
it will return 1
:
function getRSETHPrice() external view returns (uint256 rsETHPrice) { address rsETHTokenAddress = lrtConfig.rsETH(); uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); if (rsEthSupply == 0) { return 1 ether; }
Otherwise, the function loops through the suportedAssetCount
storing their combined total value in the totalETHInPool
variable, then divides it by the rsEthSupply
:
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;
Attack order:
rsEthSupply == 0
, Bob receives 0.001 rsETHgetTotalAssetDeposits(asset)
doesn't use internal accounting but balanceOf the totalETHInPool
variable is now 0.101, whilst the rsEthSupply
is still 0.001getRSETHPrice
will return 101
since 0.101 / 0.001
and rsethAmountToMint
will mint 0.099 instead of 10 rsETH
to Alice resulting in a significant loss of value// (10 * 1) / 101 rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
The following example was given with whole numbers to maintain readability and simplicity. In solidity it would be notated under 1eN
value.
Manual review
Using internal accounting for storing deposits is a simple mitigation.
Math
#0 - c4-pre-sort
2023-11-16T21:29:07Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T21:29:17Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T17:06:17Z
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-
56.0791 USDC - $56.08
onlySupportedAsset(asset)
modifier to getAssetBalance()
in NodeDelegator.solThe modifier can be added to the function to catch the mistake earlier instead of reverting letter
function getAssetBalance(address asset) external view override + onlySupportedAsset(asset) returns (uint256) { address strategy = lrtConfig.assetStrategy(asset); return IStrategy(strategy).userUnderlyingView(address(this)); }
updateMaxNodeDelegatorCount
can increase or decrease the maxDelegatorCount but there is no mechanism to remove the extra delegators in case the maxNodeDelegatorCount is decreased from current valueThe following function can either increase or decrease the max number of node delegators, lets say currently there are 10 node delegators, all handling the funds. There is no mechanism to remove extras from nodeDelegatorQueue
in case the max is decreased from current value.
function updateMaxNodeDelegatorCount(uint256 maxNodeDelegatorCount_) external onlyLRTAdmin { maxNodeDelegatorCount = maxNodeDelegatorCount_; emit MaxNodeDelegatorCountUpdated(maxNodeDelegatorCount); }
depositAsset()
This instance was not reported in the bot so adding it here.
depositAsset
function should emit the address of the depositer too along with the other parameters for easy off-chain tracking.
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { // checks if (depositAmount == 0) { revert InvalidAmount(); } if (depositAmount > getAssetCurrentLimit(asset)) { revert MaximumDepositLimitReached(); } // if it returns true anyways, what will happen? it will not revert and transfer will happen if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); emit AssetDeposit(asset, depositAmount, rsethAmountMinted, + msg.sender); }
Deposit limit can be increased or decreased anytime but the contracts implement no mechanism to handle in case it is decreased and current deposits in the system are greater than the deposit limit. There should be a mechanism whenever the limit is decreased below current funds the extra should be immediately taken out of eigen layer
function updateAssetDepositLimit( address asset, uint256 depositLimit ) external onlyRole(LRTConstants.MANAGER) onlySupportedAsset(asset) { depositLimitByAsset[asset] = depositLimit; emit AssetDepositLimitUpdate(asset, depositLimit); }
LRTDepositPool.so
to better handle the node delegator queue using its build in functionsEnumerable sets have a lot of built-in functions to easily add and remove items from such structures and are also tested and secure. Use them to add and remove items from the nodeDelegator queue, will be more readable too.
address[] public nodeDelegatorQueue;
Replace lrtConfigAddr
with lrtConfigAddress
UtilLib.checkNonZeroAddress(lrtConfigAddr); __Pausable_init(); __ReentrancyGuard_init(); maxNodeDelegatorCount = 10; lrtConfig = ILRTConfig(lrtConfigAddr); emit UpdatedLRTConfig(lrtConfigAddr); }
getLstToken
is unnecessary, being not used anywhere, if it is for frontend simply use the supportedAssetList
to get the desired token- function getLSTToken(bytes32 tokenKey) external view override returns (address) { - return tokenMap[tokenKey]; - }
In the deposit function we can keep track of each token an address deposited and the amount deposited, while implementing the withdraw there can be multiple nuances and such structure may help in doing right calculations.
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { // checks if (depositAmount == 0) { revert InvalidAmount(); } if (depositAmount > getAssetCurrentLimit(asset)) { revert MaximumDepositLimitReached(); } // if it returns true anyways, what will happen? it will not revert and transfer will happen if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); + amountDeposited[msg.sender][asset] = depositAmount; emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
#0 - raymondfam
2023-11-17T23:50:29Z
Possible upgrade:
[NC-7] --> #434
#1 - c4-pre-sort
2023-11-17T23:50:49Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-11-18T01:48:48Z
raymondfam marked the issue as high quality report
#3 - c4-judge
2023-12-01T16:44:36Z
fatherGoose1 marked the issue as grade-b
#4 - 0xnirlin
2023-12-02T14:57:01Z
@fatherGoose1 Thanks for the swift judging, Regarding the grade on this submission, I think this is on par if not better with the two QA reports marked as grade-a, nothing from the bot report is included here, and also both grade-a submissions contain 8 total recommendations which is the same number of suggestions I made in my report.
Please have a second look and I agree with whatever decision you make then.
#5 - 0xnirlin
2023-12-04T06:56:32Z
Also just noticed that some medium marked as invalid can be QA, for example the submission about LST tokens with different decimals.
#6 - fatherGoose1
2023-12-04T17:52:30Z
Hi @AhmadDecoded, I agree with the upgrade to A
. Each finding is thoughtful, and I see particular value in NC-4.
#7 - c4-judge
2023-12-04T17:52:37Z
fatherGoose1 marked the issue as grade-a
#8 - 0xcats
2023-12-04T18:19:28Z
Hello @fatherGoose1, thank you for upgrading our QA to Grade-A. I just reviewed the report we provided and compared it to the one that is Selected for Report #581 , and although I'm not trying to throw shade at other competitors that are participating, I believe our qualifies better for a few reasons:
With this in mind, I will respect any decision you decide on, thank you for your time!
🌟 Selected for report: CatsSecurity
Also found by: 0xSmartContract, 0xbrett8571, 0xepley, Bauchibred, KeyKiril, Myd, SAAJ, SBSecurity, Sathish9098, ZanyBonzy, albahaca, clara, digitizeworx, fouzantanveer, foxb868, glcanvas, hunter_w3b, invitedtea, sakshamguruji, unique
128.076 USDC - $128.08
Kelp represents a collaborative DAO with the purpose of enhancing liquidity, DeFi, and maximizing rewards for assets that are restaked. The core of this initiative lies in the integration of a single liquid restaked token made for recognized LSTs, ensuring seamless accessibility. The protocol will allow users to employ their rsETH within EigenLayer strategies, enabling them to generate returns. The ongoing development of rsETH will be carried out within the framework of the Kelp brand.
rsETH
contract address, setting the token address and numerous getter functions.rsETH
, add node delegator's to the queue, transfer assets to node delegators, update the max node delegators count and numerous getter functions.Oracle
to get prices of liquid staking tokens, includes functionality to update the price oracle for supported assets, as well as fetch supported asset and rsETH
prices.rsETH
ERC20 Token the protocol uses and contains all the standard functionality as per OpenZeppelin's requirements.Chainlink Price Oracle
wrapper to integrate their oracles in the LRTOracle and contains all standard Chainlink Oracle functionality.onlyLRTManager
, onlyLRTAdmin
, onlySupportedAsset
, and can also be used to update the LRTConfig contract.R_ETH_TOKEN
, ST_ETH_TOKEN
, CB_ETH_TOKEN
, LRT_ORACLE
, LRT_DEPOSIT_POOL
, EIGEN_STRATEGY_MANAGER
, MANAGER
.The contracts uses the following 4 access control modifiers for different mechanisms within the system:
DEFAULT_ADMIN_ROLE
: Used for core administrative functions within the system that have to do with initial setting of addresses as well as updating asset strategies.onlyLRTADMIN
: Used for functionality within the system that has to do with node delegaotrs and unpausing the contracts.onlyLRTMANAGER
: Used for functionality within the system that has to do with node delegators, transfer of funds between contracts and EigenLayer strategies and price oracle configurations.LRTConstants.MANAGER
: Used for functionality within the system that has to do with supported assets and their deposit limits.DEFAULT_ADMIN_ROLE
: This role has access to administrative functions such as updating asset strategies, setting the rsETH address, setting the token address & setting the contract address.onlyLRTADMIN
: This role has access to functions related to the Node Delegators such as adding new node delegator contracts to the queue, updating the maximum node delegator count and unpausing the various contracts that allow for that.onlyLRTMANAGER
: This role has access to other functions related to the Node Delegators such as transferring assets from the deposit pool to the node delegator contracts, as well as approving assets to the EigenLayer strategy manager, depositing assets from node delegators to the strategy, transferring funds from node delegators back to the deposit pool, updating the price oracle of suported assets, adding or updating the oracle price feed for assets, and pausing the various contracts that allow for that.LRTConstants.MANAGER
: This role has access to functions related to adding new supported assets, and updating the asset deposit limits.LRTConfig: The LRTConfig contract is a critical part of the Kelp protocol. It contains the logic for the system's configuration such as supported assets and their deposit limits and asset strategies.
LRTDepositPool: The LRTDepositPool contract is the main user-facing contract in the protocol responsible for taking in deposits and minting rsETH
for users. Users can also get information related to current asset limits, total asset deposits and the node delegator queue. Restricted roles have access to node delegator and transferring of funds functions inside this contract.
LRTOracle: The LRTOracle contract is the one responsible for fetching prices of LSTs (liquid staking tokens). It works together with the LRTDepositPool to maintain accurate price validity and helps in calculating how much rsETH
to mint for users.
RSETH: The RSETH Contract is the system's ERC20 token implementation. Not much to say here.
ChainlinkPriceOracle: The ChainLinkPriceOracle contract is the wrapper contract used to integrate Chainlink's Oracles. It works together with the LRTOracle contract to supply it's fetched data.
LRTConfigRoleChecker: The LRTConfigRoleChecker contract is the system's implementation for access control modifiers enabling certain functions to only be accessible by privileged roles.
UtilLib: The UtilLib contract is a helper function lib that contains a check for non zero address function.
LRTConstants: The LRTConstants contract contains the constant variables shared between the contracts such as R_ETH_TOKEN
& other supported LSTs, LRT_ORACLE
& other contracts, as well as the MANAGER
role.
The architecture diagram visualizes how the contracts in the system interact with eachother and provides short descriptions of the contracts' intended mechanism.
The flow of funds diagram visualizes the process through which a user goes in order to deposit their assets into the protocol and how they travel to their final destination of the EigenLayer strategies.
The core contracts parameters diagram visualizes the main parameters and/or storage variables in the system and what they represent.
The total nSLOC of the contracts in scope are quite low but it can be seen that the developers have done an excellent job of integrating all the functionality properly. Config, Deposit Pool, Oracles & Node Delegaotrs communicate seamlessly with access control roles functioning as intended wherever needed. The system includes pause
functionality which, in my opinion, is always a good idea.
The documentation provided in the github repo was not extensive, although it's understandable for such a small codebase. There was a link to their blog with some additional information on more in-general subjects such as "What is restaking", "What is EigenLayer" and such. That's all good but I would have liked more techinical documentation about the project.
Although the codebase is small, since this is the protocol's first audit I wouldn't be surprised if a few bugs arise, it is our goal to clear them. I would usually say there is always room for improvement codebase-wise but I belive the developers have done an excellent job here. The owner's have stated that they intend to expand the codebase and conduct subsequent audits for the expansion which is great. Me and my teammate were able to uncover a few vulnerabilities and I am sure other auditors will come up with more stuff.
Loss of value upon mint: Our team uncovered a vulnerability which if exploited could set the system up in such a way that the 2nd depositor's minting value is significantly reduced. This is quite has quite a serious impact although potentially only possible for the 2nd depositor, even if the system currently has no withdraw
functionality, it is still a major loss of value
. The issue arises from the fact that the codebase
does not use internal accounting during a crucial division to calculate the rsETH
amount to mint, but instead takes the contracts' balances. This opens the door for an attacker to simply donate/directly send value instead of going through the deposit
function and sway the results.
Future integration with LSTs with less than 1e18 decimals: Our team uncovered a vulnerability based on the developer's assumption that all LST tokens used as supported assets for the system will use 1e18 decimals. The developers communicated in a private discord thread that they are planning to add more supported LST assets in the future if EigenLayer expands.
Gus — Yesterday at 6:38 PM When Eigenlayer adds/accepts more LSTs, we will want to support those as well
If in the future additional assets are supported with a decimal of more/less than 1e18, the calculaction used in the for-loop at LRTOracle::getRSETHPrice#L66
will return heavily skewed results and thus minting a way bigger/smaller amount than it should.
Parts of the functionality of the system is locked behind priviliged-role access control modifiers, functions such as adding new supported assets, setting their deposit limits, increasing the node delegator count, pause/unpause of the protocol, transfer of funds between contracts and EigenLayer strategies and oracle integration.
All of these are expected, except maybe letting users control how many of their funds, and when, are allocated to EigenLayer strategies. The codebase is still in a very early stage (especially seeing a Withdraw
function is missing), and it might be in the plans of the developers to allow for such actions to be decided by the users, but as it stands right now, I feel like that part is rather centralized and an improvement to this would be to allow the users to choose.
Severity | Findings |
---|---|
Critical | 0 |
High | 1 |
Medium | 1 |
Low | 0 |
Start Date | 10.11.2023 |
End Date | 15.11.2023 |
Days spent | 5 Days |
Hours | 35h |
35 hours
#0 - c4-pre-sort
2023-11-17T03:24:45Z
raymondfam marked the issue as high quality report
#1 - c4-judge
2023-11-29T18:52:44Z
fatherGoose1 marked the issue as grade-a
#2 - c4-judge
2023-12-01T19:06:58Z
fatherGoose1 marked the issue as selected for report