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: 37/185
Findings: 4
Award: $130.28
🌟 Selected for report: 1
🚀 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
46.8436 USDC - $46.84
Price of rsETH is calculated as totalLockedETH / rsETHSupply
. rsETH price is used to calculate rsETH amount to mint when user deposits. Formulas are following:
rsethAmountToMint = amount * assetPrice / rsEthPrice
rsEthPrice = totalEthLocked / rsETHSupply
Problem is that it transfers deposit amount before calculation of rsethAmountToMint
. It increases totalEthLocked
. As a result rsethAmountToMint is less than intended because rsEthPrice is higher.
For example:
totalEthLocked
= 10e18, assetPrice = 1e18, rsETHSupply = 10e1830e18 * 1e18 / ((30e18 * 1e18 + 10e18 * 1e18) / 10e18) = 7.5e18
Here you can see that it firstly transfers asset to address(this)
, then calculates amount to mint:
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { ... if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
There is long chain of calls:
_mintRsETH() getRsETHAmountToMint() LRTOracle().getRSETHPrice() getTotalAssetDeposits() getTotalAssetDeposits()
Finally getTotalAssetDeposits()
uses current balanceOf()
, which was increased before by transferrign deposit amount:
function getAssetDistributionData(address asset) public view override onlySupportedAsset(asset) returns (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) { // Question: is here the right place to have this? Could it be in LRTConfig? @> assetLyingInDepositPool = IERC20(asset).balanceOf(address(this)); uint256 ndcsCount = nodeDelegatorQueue.length; for (uint256 i; i < ndcsCount;) { assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]); assetStakedInEigenLayer += INodeDelegator(nodeDelegatorQueue[i]).getAssetBalance(asset); unchecked { ++i; } } }
Manual Review
Transfer tokens in the end:
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { ... + uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } - // interactions - uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); - emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
Oracle
#0 - c4-pre-sort
2023-11-15T21:17:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-15T21:17:43Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2023-11-15T21:21:51Z
This vulnerability is worse than a donation attack.
#3 - c4-pre-sort
2023-11-17T06:53:34Z
raymondfam marked the issue as high quality report
#4 - c4-sponsor
2023-11-24T09:03:00Z
gus-staderlabs (sponsor) confirmed
#5 - c4-judge
2023-11-29T21:17:26Z
fatherGoose1 marked the issue as satisfactory
#6 - c4-judge
2023-12-01T19:00:05Z
fatherGoose1 changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-12-01T19:01:04Z
fatherGoose1 marked the issue as selected for report
#8 - aslanbekaibimov
2023-12-03T10:51:58Z
@fatherGoose1
Thank you for judging!
I believe this issue meets the criteria for High severity:
Due to this bug, the rsETH/ETH price depends on the amount of LST the user is depositing. Because of that, each user would get a different amount of rsETH per unit of deposited LST (as every user may deposit a different amount), and rsETH is then expected to be redeemable back for LSTs. Thus, essentially, those who deposited more LSTs are directly losing funds to those who deposited less.
#9 - xAriextz13
2023-12-03T15:16:11Z
I agree with @aslanbekaibimov that this should be a High Risk issue. As I explain in https://github.com/code-423n4/2023-11-kelp-findings/issues/636 a user can deposit 100 ETH and receive less rsETH than a user who deposits only 1 ETH. I think that this is loss of funds for all the users who deposit assets, specially affecting large deposits as shown in the example above, where a user has a 99% loss of his deposited funds.
I want to emphasize that this issue arises everytime users deposit funds into the protocol, creating a big loss of funds for them.
#10 - fatherGoose1
2023-12-04T15:31:33Z
I also agree that this is a HIGH severity issue. The miscalculation causes direct fund loss to users. The downgrade to medium was a mistaken action. I appreciate the QA.
#11 - c4-judge
2023-12-04T15:31:59Z
fatherGoose1 changed the severity to 3 (High Risk)
#12 - PaperParachute
2023-12-11T16:55:56Z
Comment added at sponsor request:
This is a legitimate issue and has been fixed in commit 3b4e36c740013b32b78e93b00438b25f848e5f76 to separately have rsETH price calculators and read value from state variables, which also helped reduce gas cost to a great extent as well. We thank the warden for alerting us to this issue.
🌟 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
Attacker can perform classic first depositor attack:
function getRsETHAmountToMint( address asset, uint256 amount ) 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 @> rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); }
function getRSETHPrice() external view returns (uint256 rsETHPrice) { address rsETHTokenAddress = lrtConfig.rsETH(); @> uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); ... 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; }
getTotalAssetDeposits()
uses balanceOf to get locked valuefunction getTotalAssetDeposits(address asset) public view override returns (uint256 totalAssetDeposit) { (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) = getAssetDistributionData(asset); return (assetLyingInDepositPool + assetLyingInNDCs + assetStakedInEigenLayer); }
So attacker can deposit 1 wei of LST, therefore supply of rsETH will be 1 wei. Then he donates 1_000e18 LST to Pool - it makes rsETH price to be 1_000e36. Hence every deposit of amount less than 1_000 ETH will round down to 0 minted rsETH
Manual Review
Perform initial deposit in initialize()
. Deposit around 0.5 ETH, it will be enough
ERC4626
#0 - c4-pre-sort
2023-11-15T21:15:42Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-15T21:15:57Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T16:53:55Z
fatherGoose1 marked the issue as satisfactory
🌟 Selected for report: crack-the-kelp
Also found by: 0xDING99YA, 0xffchain, Aymen0909, Bauchibred, DanielArmstrong, Pechenite, Stormy, T1MOH, ZanyBonzy, ast3ros, bLnk, chaduke, crack-the-kelp, deepkin, deth, jasonxiale, jayfromthe13th, lsaudit, nmirchev8, osmanozdemir1, roleengineer, tallo, zhaojie
76.0163 USDC - $76.02
How currently rsETH price is calculated? totalEthLocked is divided by rsETH supply. How totalEthLocked is calculated? It sums 3 amounts per every asset: 1) balance of LRTDepositPool.sol, 2) balance of all node delegators, 3) already deposited amount of asset to EigenLayer's Strategy. We are interested in the 3rd point.
EigenLayer has 2 types of functions to fetch the user's deposited amount: 1) view, which doesn't update state, 2) function that potentially modifies state. Here's problem, now only view function is used. Future version of Strategy contract can contain specific logic which affects user's balance calculation, like interest accruing for example.
As a result rsETH price can be incorrect in protocol. It affects deposits and most likely withdrawals.
That's callback order:
LRTOracle.getRSETHPrice() LRTDepositPool.getTotalAssetDeposits() LRTDepositPool.getAssetDistributionData() NodeDelegator.getAssetBalance() IStrategy.userUnderlyingView()
NodeDelegator in its turn calls view version of function:
function getAssetBalance(address asset) external view override returns (uint256) { address strategy = lrtConfig.assetStrategy(asset); return IStrategy(strategy).userUnderlyingView(address(this)); }
That's EigenLayer documentation: https://github.com/Layr-Labs/eigenlayer-contracts/blob/master/src/contracts/strategies/StrategyBase.sol#L246-L259 https://github.com/Layr-Labs/eigenlayer-contracts/blob/master/src/contracts/strategies/StrategyBase.sol#L193-L217
function underlyingToShares(uint256 amountUnderlying) external view virtual returns (uint256) { return underlyingToSharesView(amountUnderlying); } function userUnderlyingView(address user) external view virtual returns (uint256) { return sharesToUnderlyingView(shares(user)); } /** * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy. * @notice In contrast to `sharesToUnderlying`, this function guarantees no state modifications * @param amountShares is the amount of shares to calculate its conversion into the underlying token * @return The amount of underlying tokens corresponding to the input `amountShares` @> * @dev Implementation for these functions in particular may vary significantly for different strategies */ function sharesToUnderlyingView(uint256 amountShares) public view virtual override returns (uint256) { ... } /** * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy. * @notice In contrast to `sharesToUnderlyingView`, this function **may** make state modifications * @param amountShares is the amount of shares to calculate its conversion into the underlying token * @return The amount of underlying tokens corresponding to the input `amountShares` @> * @dev Implementation for these functions in particular may vary significantly for different strategies */ function sharesToUnderlying(uint256 amountShares) public view virtual override returns (uint256) { ... }
Manual Review
Create 2 versions of function getRSETHPrice()
: view and state modifying. In state modifying version use Strategy.userUnderlying()
instead of Strategy.userUnderlyingView()
. And utilize state modifying version in deposit.
Other
#0 - c4-pre-sort
2023-11-16T23:39:45Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T23:39:56Z
raymondfam marked the issue as duplicate of #197
#2 - c4-judge
2023-12-01T17:24:51Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-12-08T17:26:50Z
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
getAssetCurrentLimit()
will revert if admin sets new limit to less than deposited nowLimit from config can be lower than actually deposited, therefore function will revert instead of returning 0
function getAssetCurrentLimit(address asset) public view override returns (uint256) { return lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset); }
Return 0 instead of revert
function getAssetCurrentLimit(address asset) public view override returns (uint256) { - return lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset); + return lrtConfig.depositLimitByAsset(asset) > getTotalAssetDeposits(asset) + ? lrtConfig.depositLimitByAsset(asset) - getTotalAssetDeposits(asset) + : 0; }
Protocol assumes that all LSTs have 18 decimals, it can be different in the future.
Here function expects that totalAssetAmt
has 1e18 precission. Otherwise it will over/under-estimate asset:
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; } } return totalETHInPool / rsEthSupply; }
Normalize asset amount to 18 decimals:
for (uint16 asset_idx; asset_idx < supportedAssetCount;) { address asset = supportedAssets[asset_idx]; uint256 assetER = getAssetPrice(asset); uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset); + uint256 decimals = IERC20(asset).decimals(); + totalAssetAmt = decimals < 18 ? totalAssetAmt * (10 ** (18 - decimals)) : totalAssetAmt / (10 ** (decimals - 18)); totalETHInPool += totalAssetAmt * assetER; unchecked { ++asset_idx; } }
function maxApproveToEigenStrategyManager(address asset) external override onlySupportedAsset(asset) onlyLRTManager { address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); @> IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max); }
Consider giving approval before deposit:
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)); + + token.approve(eigenlayerStrategyManagerAddress, balance); emit AssetDepositIntoStrategy(asset, strategy, balance); IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance); }
#0 - raymondfam
2023-11-18T01:05:15Z
Possible upgrade:
#1 - c4-pre-sort
2023-11-18T01:05:20Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2023-12-01T16:26:29Z
fatherGoose1 marked the issue as grade-b
#3 - fatherGoose1
2023-12-01T18:59:28Z
Not upgrading as it's not a dupe of #479. 479 discusses the different decimals in Chainlink price feeds. LSTs are assumed to mirror ETH's 18 decimals.