Kelp DAO | rsETH - Juntao's results

A collective DAO designed to unlock liquidity, DeFi and higher rewards for restaked assets through liquid restaking.

General Information

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

Kelp DAO

Findings Distribution

Researcher Performance

Rank: 69/185

Findings: 2

Award: $38.79

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

36.0335 USDC - $36.03

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
edited-by-warden
duplicate-62

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L136-L141

Vulnerability details

Impact

Depositor may be grief attacked and no rsETH will be minted.

Proof of Concept

Depositor can mint rsETH by depositing asset through depositAsset(...) function. Asset will be transferred from depositor to the protocol:

if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); }

Then rsETH will be minted through _mintRsETH(...) function:

uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);

The amount of rsETH to be minted is calculated is determined by rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(), here lrtOracle.getRSETHPrice() returns the rsETH price:

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;

As we can see from above, the rsETH price is determined by the asset owned by the protocol and the total supply of rsETH. However, as the asset will be transferred to protocol before minting, the depositor may not receive a correct amount of rsETH, and even worse, depositor can be grief attacked and receive no rsETH at all. Imagine the following scenario:

  1. Alice submits a transaction to mint rsETH by depositing 1 ether stETH (price is 1e18);
  2. Bob front-runs Alice's transaction and deposits 1 wei stETH;
  3. Because Bob is the first depositor, 1 wei rsETH will be minted to Bob, so totalETHInPool is 1 and rsEthSupply is 1;
  4. Alice's transaction is executing, because 1 ether is transferred before minting, so totalETHInPool is 1e18 + 1 and rsEthSupply is 1, rsETH price is 1e18 * (1e18 + 1);
  5. rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice() = (1e18 * 1e18) / (1e18 * (1e18 + 1)) = 0;
  6. Alice is thus grief attacked and received no rsETH.

Please see below test case:

function test_AuditDepositAsset() external { ProxyAdmin proxyAdmin = new ProxyAdmin(); LRTOracle lrtOracleImpl = new LRTOracle(); TransparentUpgradeableProxy lrtOracleProxy = new TransparentUpgradeableProxy( address(lrtOracleImpl), address(proxyAdmin), "" ); LRTOracle lrtOracle = LRTOracle(address(lrtOracleProxy)); lrtOracle.initialize(address(lrtConfig)); vm.startPrank(manager); lrtOracle.updatePriceOracleFor(address(stETH), address(new MockPriceOracle())); lrtOracle.updatePriceOracleFor(address(cbETH), address(new MockPriceOracle())); lrtOracle.updatePriceOracleFor(address(rETH), address(new MockPriceOracle())); vm.stopPrank(); vm.startPrank(admin); lrtConfig.setContract(LRTConstants.LRT_DEPOSIT_POOL, address(lrtDepositPool)); lrtConfig.setContract(LRTConstants.LRT_ORACLE, address(lrtOracle)); vm.stopPrank(); vm.startPrank(bob); rETH.approve(address(lrtDepositPool), 1); lrtDepositPool.depositAsset(rETHAddress, 1); uint256 bobBalance = rseth.balanceOf(address(bob)); console.log(bobBalance); vm.stopPrank(); vm.startPrank(alice); rETH.approve(address(lrtDepositPool), 1 ether); // lrtDepositPool.depositAsset(rETHAddress, 2 ether); lrtDepositPool.depositAsset(rETHAddress, 1 ether); uint256 aliceBalance = rseth.balanceOf(address(alice)); assertEq(aliceBalance, 0); vm.stopPrank(); }

Tools Used

Manual Review

When depositing, rsETH should be minted before transferring asset.

+ // interactions + uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } - // interactions - uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount);

Assessed type

Math

#0 - c4-pre-sort

2023-11-16T03:04:29Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-11-16T03:04:36Z

raymondfam marked the issue as duplicate of #42

#2 - c4-judge

2023-12-01T17:01:26Z

fatherGoose1 marked the issue as not a duplicate

#3 - fatherGoose1

2023-12-01T17:02:07Z

Doesn't describe the standard donation attack. Instead highlights the issue with using the deposited funds in the share calculation, sharing impact with #62.

#4 - c4-judge

2023-12-01T17:02:16Z

fatherGoose1 marked the issue as duplicate of #62

#5 - c4-judge

2023-12-01T17:02:21Z

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-04T15:31:42Z

fatherGoose1 changed the severity to 3 (High Risk)

Awards

2.7592 USDC - $2.76

Labels

bug
downgraded by judge
grade-b
high quality report
primary issue
QA (Quality Assurance)
sponsor disputed
Q-90

External Links

Lines of code

https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L121-L124

Vulnerability details

Impact

Asset staked EigenLayer is not properly calculated, this could lead to incorrect total asset balance and incorrect rsETH price.

Proof of Concept

To get the asset balance staked in EigenLayer, protocol calls userUserlyingView(...) function o get the asset balance staked in EigenLayer, this function is defined in StrategyBase contract as below:

function userUnderlyingView(address user) external view virtual returns (uint256) { return sharesToUnderlyingView(shares(user)); }

Where [shares(...)]https://etherscan.io/address/0xdfda04f980be6a64e3607c95ca26012ab9aa46d3#code#F2#L267) function is called to get user's share:

function shares(address user) public view virtual returns (uint256) { return strategyManager.stakerStrategyShares(user, IStrategy(address(this))); }

stakerStrategyShares is a mapping define in StrategyManagerStorage contract, when user deposits into strategy, share will be updated accordingly:

_addShares(depositor, strategy, shares);

When user withdraws, it's reasonable to think user's share will be burned and asset will be returned immediately, however, this is not exactly true. To withdraw from EigenLayer, queueWithdrawal(...) function should be firstly called, user's share will be decreased:

if (_removeShares(msg.sender, strategyIndexes[strategyIndexIndex], strategies[i], shares[i])) {

Then completeQueuedWithdrawal(...) is required to complete the withdrawal and the asset will be returned to user. It is unlikely these 2 functions can be called in a single transaction, as said in the comment:

* @dev middlewareTimesIndex should be calculated off chain before calling this function by finding the first index that satisfies `slasher.canWithdraw`

So the problem is that the protocol does not take the queued withdrawal asset amount into consideration, incorrect total asset balance will be retrieved, if a user deposits before withdrawals are completed, incorrect rsETH price will be used to calculated the rsETH token to be minted, results in user receiving incorrect amount of rsETH. Withdrawal function is a must to the protocol even if it has not been implemented, incorrect calculation of asset balance should be fixed in the current release to prevent the issue mention above.

Tools Used

Manual Review

When calculating the asset balance staked into EigenLayer, the queued withdrawal balance should be took into consideration, protocol can save the queued balance in local and use it in the calculation.

Assessed type

Access Control

#0 - c4-pre-sort

2023-11-16T03:21:01Z

raymondfam marked the issue as sufficient quality report

#1 - raymondfam

2023-11-16T03:21:41Z

Inadequate integration.

#2 - c4-pre-sort

2023-11-16T03:35:09Z

raymondfam marked the issue as primary issue

#3 - c4-pre-sort

2023-11-17T08:26:56Z

raymondfam marked the issue as high quality report

#4 - c4-sponsor

2023-11-24T10:42:18Z

manoj9april (sponsor) disputed

#5 - manoj9april

2023-11-24T10:44:53Z

This is out of scope of audit. Withdrawal is not implemented yet. This bug doesn't exist in the current state of the codebase.

#6 - c4-judge

2023-12-01T17:41:37Z

fatherGoose1 marked the issue as unsatisfactory: Invalid

#7 - c4-judge

2023-12-06T18:19:18Z

fatherGoose1 changed the severity to QA (Quality Assurance)

#8 - c4-judge

2023-12-08T18:52:28Z

fatherGoose1 marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter