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: 18/185
Findings: 3
Award: $241.53
🌟 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
The _mintRsETH
calculates the amount of rsETH to mint based on the latest exchange rate from the LRTOracle. This means there is a window between when a user deposits tokens, and when _mintRsETH
is called, where the user is exposed to exchange rate fluctuations. If the rate changes unfavorably during this window, the user will receive less rsETH than expected based on deposit-time rates.
The _mintRsETH
function calculates the amount of rsETH to mint based on the latest exchange rate from the LRTOracle.
This means if there is a lag between when the user deposits tokens, and when _mintRsETH
is called, the user is exposed to exchange rate changes during that time.
If the rate changes unfavorably in that window, they will receive less rsETH than expected based on deposit-time rates.
The root cause is that _mintRsETH
always uses the latest rate from the oracle rather than the rate at the time of deposit. Link to the Issue
/// @notice helps user stake LST to the protocol /// @param asset LST asset address to stake /// @param depositAmount LST asset amount to stake 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); emit AssetDeposit(asset, depositAmount, rsethAmountMinted); } /// @dev private function to mint rseth. It calculates rseth amount to mint based on asset amount and asset exchange /// rates from oracle /// @param _asset Asset address /// @param _amount Asset amount to mint rseth /// @return rsethAmountToMint Amount of rseth minted 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); }
As you can see, the rsETH amount is calculated at the time of deposit but not used during minting.
In depositAsset
, the expected rsETH amount is calculated via getRsETHAmountToMint
This happens at the time of deposit, so it uses the deposit-time exchange rate
_mintRsETH
is called later to actually mint the rsETH
However, _mintRsETH
recalculates the amount to mint based on the latest exchange rate
So any rate change between deposit and _mintRsETH
affects the final rsETH received
So this issue has a high likelihood of occurring any time there is a lag between deposit and minting, as exchange rates can fluctuate frequently.
Maximum Risk Impact
This can be exploited to minimize the amount of rsETH minted in a deposit.
Here is an example exploit scenario:
Attacker deposits 100 DAI when 1 DAI = 1 USD and the rsETH rate is 0.95 ETH.
Between deposit and minting, the attacker temporarily manipulates the DAI/USD rate on the oracle to 1 DAI = 0.10 USD.
_mintRsETH
uses this latest rate, so the rsETH amount calculated is now 9.5.
Attacker mints only 9.5 rsETH instead of the expected 95 rsETH based on deposit-time rates.
Demonstration
Here is some code that demonstrates how this could work:
// Deposit when 1 DAI = 1 USD deposit(100 DAI) // Expected rsETH is 95 uint expectedRsETH = getExpectedMintAmount(100 DAI, 1 USD); // Manipulate DAI/USD rate to 0.10 USD setManipulatedRate(0.10 USD); // _mintRsETH uses manipulated rate uint rsETHReceived = _mintRsETH(100 DAI); // rsETHReceived is 9.5 instead of 95 due to manipulated rate assertEq(rsETHReceived, 9.5); assertLt(rsETHReceived, expectedRsETH);
This demonstrates how manipulating the rate between deposit and minting can severely impact the rsETH amount received.
Manual
_mintRsETH
instead of recalculatingOracle
#0 - c4-pre-sort
2023-11-16T00:07:56Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T00:08:05Z
raymondfam marked the issue as duplicate of #39
#2 - c4-pre-sort
2023-11-17T06:43:06Z
raymondfam marked the issue as duplicate of #148
#3 - c4-judge
2023-11-29T19:09:51Z
fatherGoose1 marked the issue as satisfactory
#4 - c4-judge
2023-11-29T19:21:54Z
fatherGoose1 changed the severity to 2 (Med Risk)
🌟 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/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTConfig.sol#L70-L76 https://github.com/code-423n4/2023-11-kelp/blob/c5fdc2e62c5e1d78769f44d6e34a6fb9e40c00f0/src/LRTConfig.sol#L70-L76
The addNewSupportedAsset
function does not validate asset addresses. This allows anyone to add malicious or invalid contracts as supported assets. These could manipulate protocol behavior or steal funds when interacted with.
addNewSupportedAsset
takes an asset address as input. But does not verify that this address is a valid ERC20 token contract.
This means an arbitrary address could be added as a "supported" asset, not just real tokens.
The addNewSupportedAsset
external function which:
MANAGER
role using the OpenZeppelin onlyRole modifier_addNewSupportedAsset
function to add the assetThe key issue is that addNewSupportedAsset
does not verify that the asset address passed in is a valid ERC20 token contract. It only relies on the access control check from onlyRole
.
So any address could be passed and added as a supported asset, even if it is not an actual token contract.
function addNewSupportedAsset(address asset, uint256 depositLimit) external { // No input validation on `asset` _addNewSupportedAsset(asset, depositLimit); }
This allows adding any arbitrary address as a supported asset.
An attacker could:
contract MalToken { function transferFrom(address, address, uint) external { // Drain funds } }
LRTConfig(config).addNewSupportedAsset(malToken, 100);
IERC20(malToken).transferFrom(user, pool, 10); // Calls malicious contract
This test demonstrates adding a malicious contract, and it draining funds when used as a "supported" asset.
// test/LRTConfig.t.sol import "forge-std/Test.sol"; import {LRTConfig} from "../src/LRTConfig.sol"; contract Exploit is Test { LRTConfig config; MalToken malToken; function testAddMaliciousAsset() public { malToken = new MalToken(); // malicious contract config.addNewSupportedAsset(address(malToken), 100); assertTrue(config.isSupportedAsset(address(malToken))); vm.expectCall(address(malToken), abi.encodeWithSelector( MalToken.transferFrom.selector, address(0), address(0), 10 )); IERC20(address(malToken)).transferFrom(address(0), address(0), 10); } } contract MalToken { function transferFrom(address from, address to, uint amount) external { // drain funds } }
But the Likelihood of this happening
addNewSupportedAsset
(MANAGER role holder)Manual review
In addNewSupportedAsset
, validate asset addresses are proper ERC20 contracts before adding:
function addNewSupportedAsset(address asset, uint256 depositLimit) external { + require(ERC20(asset).totalSupply() > 0, "Invalid ERC20 token"); // Additional ERC20 validity checks // ... }
This prevents invalid addresses from being added.
Invalid Validation
#0 - c4-pre-sort
2023-11-15T23:00:08Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-11-15T23:00:29Z
raymondfam marked the issue as duplicate of #69
#2 - c4-judge
2023-11-29T20:58:12Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-11-29T20:59:00Z
fatherGoose1 marked the issue as grade-b
🌟 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 aims to provide liquidity rewards for staked ETH assets by integrating with the EigenLayer protocol. It consists of core contracts for configuration, deposits, delegating to strategies, pricing, and receipt tokens.
I reviewed the contracts through manual analysis, focused on security, intended behavior, and integration.
LRTConfig: An upgradeable contract which is responsible for storing the configuration of the Kelp product. It is also responsible for storing the addresses of the other contracts in the Kelp product.
LRTDepositPool: An upgradeable contract which receives the funds deposited by users into the Kelp product. From here, the funds are transferred to NodeDelegators contracts.
NodeDelegator: These are contracts that receive funds from the LRDepositPool and delegate them to the EigenLayer strategy. The funds are then used to provide liquidity on the EigenLayer protocol. It is also an upgradeable contract.
LRTOracle: An upgradeable contract which is responsible for fetching the price of Liquid Stasking Tokens tokens from oracle services.
RSETH: Receipt token for depositing into the Kelp product. It is an upgradeable ERC20 token contract.
Scope
Contract | Purpose | SLOC | Libraries Used |
---|---|---|---|
LRTConfig | Configuration contract | 111 | |
LRTDepositPool | User interfacing contract when funds are deposited | 97 | |
NodeDelegator | Recipient of funds from LRTDepositPool. Sends funds to Eigenlayer strategies | 65 | |
LRTOracle | Gets prices of liquid staking tokens | 60 | ChainlinkPriceOracle |
RSETH | Receipt token user receives upon depositing in LRTDepositPool | 45 | |
ChainlinkPriceOracle | Wrapper contract to integrate Chainlink oracles in LRTOracle | 25 | |
LRTConfigRoleChecker | Handles role checks | 33 | |
UtilLib | Helper function library | 7 | |
LRTConstants | Shared constant variables | 10 |
Note: The "Libraries Used" column indicates any external libraries or contracts that are utilized by the respective contract.
My review involved:
Kelp has a modular architecture with well-defined contract responsibilities:
Diagram of the Kelp architecture showing the key contracts and their interactions:
graph TD subgraph Kelp DAO LRTConfig(LRTConfig<br/>Config & Address Storage) LRTDepositPool(LRTDepositPool<br/>Deposit Handler & RSETH Minting) NodeDelegator(NodeDelegator<br/>Asset Delegation) LRTOracle(LRTOracle<br/>Price Feeds) RSETH(RSETH<br/>Receipt Token) end User==>LRTDepositPool LRTDepositPool==Mint RSETH==>RSETH LRTDepositPool==Price Data==>LRTOracle LRTDepositPool-.->LRTConfig NodeDelegator-.->LRTConfig LRTDepositPool==Delegates Assets==>NodeDelegator classDef grey fill:#dddddd,stroke:#ffffff,stroke-width:4px,color:#000000; class LRTConfig,LRTOracle,LRTDepositPool,NodeDelegator,RSETH grey style LRTConfig fill:#5DA5DA,stroke:#ffffff,stroke-width:4px,color:#ffffff style LRTDepositPool fill:#FAA43A,stroke:#ffffff,stroke-width:4px,color:#ffffff style NodeDelegator fill:#60BD68,stroke:#ffffff,stroke-width:4px,color:#ffffff style LRTOracle fill:#F17CB0,stroke:#ffffff,stroke-width:4px,color:#ffffff style RSETH fill:#B2912F,stroke:#ffffff,stroke-width:4px,color:#ffffff
Key Points
The core dependency is the unified configuration in LRTConfig, which must be secure and authorize only valid contract addresses.
LRTConfig
The LRTConfig contract is relied upon by other contracts for address lookups via the getContract
and getToken
functions:
function getContract(bytes32 contractKey) external view override returns (address) { return contractMap[contractKey]; }
// Example usage in LRTDepositPool address lrtOracle = LRTConfig.getContract(LRT_ORACLE);
If an incorrect address is returned, it could lead to interactions with unauthorized contracts.
The key risk identified is that setContract
allows the admin to update any address mapping. Proper access control is needed to restrict this.
LRTOracle
The LRTOracle provides pricing data that is consumed by other contracts: getAssetPrice
function getAssetPrice(address asset) public view onlySupportedAsset(asset) returns (uint256) { return IPriceFetcher(assetPriceOracle[asset]).getAssetPrice(asset); }
An vulnerability in getAssetPrice
or getRSETHPrice
could allow manipulated rates.
The main risk is that the LRTManager can set the price oracles, opening up privilege escalation.
RSETH
The RSETH contract must mint tokens in sync with LRTDepositPool based on exchange rates.
A mismatch in logic between the two could lead to economic exploits:
The key risk is there is no supply cap or validation in mint
, so it must be used correctly.
This separation of concerns follows best practices. The use of upgradeable proxy contracts also allows flexibility.
Some dependencies to note:
The LRTManager role has broad privileges such as pausing transfers and setting oracles. This could lead to centralized control over the system.
The lrETH token address is set once at initialization but the admin can later change it. This poses a risk as the token is relied on for minting.
LRTManager Privileges
The LRTManager role has broad authority in several contracts:
function pause() external onlyLRTManager { _pause(); } } // LRTOracle [updatePriceOracleFor](https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTOracle.sol#L88-L99) ```solidity function updatePriceOracleFor( address asset, address priceOracle ) external onlyLRTManager onlySupportedAsset(asset) { UtilLib.checkNonZeroAddress(priceOracle); assetPriceOracle[asset] = priceOracle; emit AssetPriceOracleUpdate(asset, priceOracle); }
This allows an LRTManager to pause transfers and change price oracles. If a single account held this role, they could control deposits and pricing.
The main functions the role should not have are pausing, setting oracles, and updating config addresses.
rsETH Update
The rsETH token address is set at initialization in LRTConfig: Line 21
// LRTConfig address public rsETH; function initialize(address rsETH_) { rsETH = rsETH_; }
But the admin can later change this: setRsETH
function setRSETH(address rsETH_) external onlyRole(DEFAULT_ADMIN_ROLE) { UtilLib.checkNonZeroAddress(rsETH_); rsETH = rsETH_; }
This poses a risk as rsETH is relied on for minting in LRTDepositPool. An unauthorized contract could lead to loss of funds.
Making rsETH immutable after initialization would prevent this centralized control over minting.
The key minting logic is in _mintRsETH in LRTDepositPool:
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); }
It calculates the rsethAmount based on the deposit amount, gets the RSETH token address, and mints the tokens.
The issue is there is no logic enforcing a max cap on the total RSETH supply: mint
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) whenNotPaused { _mint(to, amount); } } // No check on max supply
This means that if incorrect exchange rates are provided, an attacker could deposit at that rate and mint arbitrarily large amounts of RSETH.
For example:
Attacker sets a high rate for some asset in LRTOracle
Attacker deposits a small amount of that asset
_mintRsETH
uses the high rate to mint a large amount of RSETH
Adding a max supply cap on RSETH would prevent this kind of exploit.
No validation of RSETH minting contract in LRTDepositPool (_mintRsETH). An unauthorized contract could be set via LRTConfig.
Exchange rate in LRTOracle (getRSETHPrice) only considers LRTDepositPool balance. It does not account for assets already delegated through NodeDelegators. The total deposits could be inflated, affecting the rate.
No RSETH Validation in _mintRsETH
The _mintRsETH
function gets the RSETH token address from LRTConfig but does not validate it:
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); }
This could allow an unauthorized contract to be set as the rsETH address and manipulate minting.
For example:
// Attacker contract function mint(address to, uint amount) external { msg.sender.transfer(amount); // drain funds }
The admin could point LRTConfig.rsETH to this contract. _mintRsETH
would then drain funds instead of minting tokens.
This can be mitigated by:
Inflated Rates in getRSETHPrice
The getRSETHPrice
function sums all deposits via LRTDepositPool only: getRSETHPrice
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; } }
But it misses deposits already delegated through NodeDelegators, inflating the rate.
This can be fixed by tracking an independent totalSystemDeposits
variable that sums across all contracts.
Reduce LRTManager privileges to only critical operations like emergency shutdown. Move oracle control to admin only.
Make rsETH immutable in LRTConfig after initialization.
Enforce a cap on RSETH total supply in minting logic.
In _mintRsETH, verify rsETH contract address using a hardcoded known good address.
Maintain a separate total deposits variable in LRTOracle that tracks funds across all contracts.
Overall the system architecture is well designed but some key risks around privilege, logic, and incentives exist. Adding validation, immutable configuration, and supply caps would make the system more robust.
28 hours
#0 - c4-pre-sort
2023-11-17T03:15:51Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-29T18:59:03Z
fatherGoose1 marked the issue as grade-a