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: 47/185
Findings: 1
Award: $98.52
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
98.52 USDC - $98.52
Kelp is a DAO-governed protocol that allows restaking of liquid staked assets like Lido's stETH to further compound yields. The key components are:
LRTConfig
This contract stores critical protocol configuration like supported assets and contract addresses. It is the source of truth for integrations.
Risks:
function setContract(bytes32 contractKey, address contractAddress) external onlyRole(DEFAULT_ADMIN_ROLE) { _setContract(contractKey, contractAddress); }
function addNewSupportedAsset(address asset, uint256 depositLimit) external onlyRole(LRTConstants.MANAGER) { _addNewSupportedAsset(asset, depositLimit); }
Mitigations:
LRTDepositPool
This is the user entry point to make deposits. It controls pool balances.
Risks:
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(); } }
Mitigations:
NodeDelegator
Takes custody of user funds and deposits them into yield generating strategies.
Risks:
Unlimited approval given to external StrategyManager contract. Could steal tokens.
Reported asset balances trusted from external Strategy contract. Could lie and underreport.
Mitigations:
Kelp uses a modular architecture of upgradeable contracts connected through the LRTConfig registry. This provides flexibility to swap contract implementations. However, care must be taken with privilege management between contracts.
The deposit flow is:
deposit()
on LRTDepositPoolThe deposit flow is a critical user journey that deserves deeper analysis.
The Deposit Flow
Users approve tokens to LRTDepositPool
Risk: Users could accidentally approve more tokens than they intend to deposit
Mitigation: Approve only the amount about to be deposited
Users call deposit()
on LRTDepositPool
Risk: deposit()
relies on LRTOracle for exchange rates. Manipulated rates could affect minted rsETH amounts.
Mitigation: Compare LRTOracle rate against a fixed sanity rate.
LRTDepositPool transfers tokens to itself
Risk: Reentrancy during this transfer could be exploited to drain funds
Mitigation: Use reentrancy guard in LRTDepositPool
LRTDepositPool mints rsETH to user
Risk: Minting rsETH does not check allowance of LRTDepositPool with RSETH contract
Mitigation: Check rsETH minting allowance before minting
LRTDepositPool transfers tokens to NodeDelegator
Risk: No validation of allowed assets or amounts
Mitigation: Check asset & amount against LRTConfig before transfer
NodeDelegator deposits tokens into strategies
Risk: Strategy contract is not verified before use
Mitigation: Check code hash of Strategy contract
Example Code
// LRTDepositPool.sol // INPUT VALIDATION function deposit(uint amount) nonReentrant { require( LRTOracle.getExchangeRate() >= MIN_EXCHANGE_RATE, "Rate too low" ); require( RSETH.allowance(address(this)) >= amount, "Minting not allowed" ); // ... } // LRTConfig whitelist check function transfer(asset, amount) { require( LRTConfig.allowedAssets[asset], "Asset not allowed" ); // ... }
There is a high degree of trust between the different components:
This makes LRTConfig security-critical. A compromised config can break integrations.
The high degree of trust in LRTConfig creates centralization risks.
The Issue: LRTConfig is a central point of failure due to excessive trust by other contracts.
Root Cause
modifier onlySupportedAsset(address asset) { if (!isSupportedAsset[asset]) { revert AssetNotSupported(); } _; }
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); }
function getAssetPrice(address asset) public view onlySupportedAsset(asset) returns (uint256) { return IPriceFetcher(assetPriceOracle[asset]).getAssetPrice(asset); }
Impact
A compromised LRTConfig can mislead other contracts by:
Returning any strategy for assets
Providing a malicious price feed
Supporting fake/arbitrary assets
This could lead to loss of funds, broken integrations, stalled deposits
Mitigations
Use whitelists and zero knowledge proofs to verify LRTConfig responses
Make critical configuration immutable after initial setup
Build redundancy - don't solely depend on LRTConfig
The admin role in LRTConfig has a lot of power. It can:
Checks should be added before other contracts trust LRTConfig state. For example, verifying:
The broad privileges of the LRTConfig admin role create significant centralization risks if unchecked.
The Issue: The LRTConfig admin has expansive permissions without input validation.
Root Cause
The admin can:
function setContract(bytes32 contractKey, address contractAddress) external onlyRole(DEFAULT_ADMIN_ROLE) { _setContract(contractKey, contractAddress); } /// @dev private function to set a contract /// @param key Contract key /// @param val Contract address function _setContract(bytes32 key, address val) private { UtilLib.checkNonZeroAddress(val); if (contractMap[key] == val) { revert ValueAlreadyInUse(); } contractMap[key] = val; emit SetContract(key, val); } }
Add completely arbitrary assets without validation
Update the price oracle without whitelisting
Impact
This could allow the admin to:
Mitigations
Use a DAO, not solo admin role
Add input validation on state changes
Make critical config immutable after setup
Emit events on all state changes
LRTConfig - Malicious Strategy Manager
The LRTConfig admin can arbitrarily set the strategy manager address:
// LRTConfig.sol function setStrategyManager(address mgr) external onlyAdmin { strategyManager = mgr; // No validation }
NodeDelegator trusts this address to handle large deposits:
// NodeDelegator.sol function deposit() { IStrategyManager(LRTConfig.strategyManager()).deposit(funds); // No checks! }
A malicious manager could steal funds instead of staking them.
Mitigation is to validate the manager code hash before interacting.
LRTConfig - Fake LRTDepositPool
The admin can replace the pool address:
// LRTConfig.sol function setPool(address newPool) external onlyAdmin { pool = newPool; // No protections }
A fake pool could just steal deposits without providing yield.
Mitigation is to make the pool address immutable after setup.
LRTDepositPool - Arbitrary Asset Transfers
LRTDepositPool can transfer any asset:
// LRTDepositPool.sol function transfer(address asset, uint amt) external { // No validation of asset NodeDelegator.transfer(asset, amt); }
Assets should be validated against the LRTConfig whitelist.
LRTOracle - Unvalidated Price Feeds
Price feeds can be set without validation:
// LRTOracle.sol function setFeed(address feed) external { ethFeed = feed; // No protections }
Price updates should come from whitelisted sources.
The LRTConfig admin keys represent a central point of failure. Compromise of the admin can break protocol integrations.
Consider:
LRTConfig
The manager role can pause LRTDepositPool but has no ability to pause NodeDelegator. This inconsistency could block withdrawals.
No validation on asset strategies to ensure they match the asset type.
No events emitted on changes to critical state like supported assets list.
LRTDepositPool
Requires assets be in LRTConfig whitelist, but no check they have a strategy set too.
No validation on transfer amounts to NodeDelegator vs current balance.
NodeDelegator
Doesn't validate strategy contract code before interacting.
No way to escape illiquid assets sent incorrectly by LRTDepositPool.
LRTOracle
Asset price manipulation could trick LRTDepositPool, but no validations in DepositPool.
Manager pausing inconsistency
pause()
function pause() external onlyLRTManager { _pause(); }
But the manager role has no ability to pause NodeDelegator contracts
This is inconsistent and could block withdrawals if NodeDelegator is active but LRTDepositPool is paused
Or, use a DAO voting process instead of the manager role to handle pausing
Asset strategy validation
When the admin sets a new asset strategy in LRTConfig, there is no validation that the strategy contract address matches the expected asset type
For example, an ETH strategy could be wrongly set for a DAI asset
Mitigation
// LRTConfig function setStrategy(address asset, address strategy) external onlyAdmin { require(IStrategy(strategy).token() == asset, "Token mismatch"); // Set strategy }
Emit state change events
Critical state changes like adding a new supported asset are not emitted as events in LRTConfig
This reduces transparency into changes for front-end apps
Mitigation
// LRTConfig event SupportedAssetAdded(address indexed asset); function addSupportedAsset(address asset) external onlyAdmin { // Add asset emit SupportedAssetAdded(asset); }
If LRTDepositPool is paused but NodeDelegator stays active, it could block withdrawals. This could cascade into a wider liquidity crunch.
Coordination is needed for emergency pauses. A DAO-driven process would be more robust.
The Issue: Uncoordinated pauses between LRTDepositPool and NodeDelegator could block withdrawals.
Root Cause
function pause() external onlyLRTManager { _pause(); }
Impact
If LRTDepositPool is paused by admin, deposits would stall
But NodeDelegator remains active and continues delegating funds
This disconnect could block withdrawals and create a liquidity crunch
Likelihood
If the admin panics, they could easily pause LRTDepositPool without considering implications
The impact could cascade across users and integrated protocols
Mitigations
Add a pause mechanism to NodeDelegator
Coordinate pauses via DAO voting rather than admin actions
Implement circuit breaker pause - if deposits stall, automatically pause NodeDelegator
Overall the code is well structured and modular. Additional validation on inputs and outputs between contract calls would improve security.
11 hours
#0 - c4-pre-sort
2023-11-17T03:14:09Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-29T18:59:54Z
fatherGoose1 marked the issue as grade-a