Platform: Code4rena
Start Date: 30/04/2024
Pot Size: $112,500 USDC
Total HM: 22
Participants: 122
Period: 8 days
Judge: alcueca
Total Solo HM: 1
Id: 372
League: ETH
Rank: 26/122
Findings: 7
Award: $452.72
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: LessDupes
Also found by: 0rpse, 0xAadi, 0xCiphky, 0xhacksmithh, 0xnightfall, FastChecker, KupiaSec, NentoR, SBSecurity, Tendency, adam-idarrha, aman, araj, baz1ka, bigtone, fyamf, jokr, kennedy1030, maxim371, mussucal, p0wd3r, zigtur
13.5262 USDC - $13.53
Instead of passing token address, address of current contract is passed while checking if token has queued shares queuedShares[address(this)] == 0
in getTokenBalanceFromStrategy
leading to incorrect TVL
The protocol enables users to deposit LSTs which are staked in eigen layer. To get the amount of staked tokens in the Eigen layer the OperatorDelegator
uses a function called getTokenBalanceFromStrategy
.
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) { return queuedShares[address(this)] == 0 ? tokenStrategyMapping[token].userUnderlyingView(address(this)) : tokenStrategyMapping[token].userUnderlyingView(address(this)) + tokenStrategyMapping[token].sharesToUnderlyingView( queuedShares[address(token)] ); }
Accounting for any queued tokens is being handled using queuedShares
. So if any tokens are queued, the total staked tokens will be sum of tokens staked to strategy and queued tokens.
tokenStrategyMapping[token].userUnderlyingView(address(this)) + tokenStrategyMapping[token].sharesToUnderlyingView( queuedShares[address(token)] )
But the check to handle if there are any queued amount for the token is incorrect.
Instead of checking the queuedShares
of a token, its checking the queued shares for current contract address which will always return 0
queuedShares[address(this)] == 0
Due to this, the function would never consider queued shares of the tokens in the TVL. So TVL value will be incorrect if there are any queued shares.
Attacker can profit from this situation by
NativeEthRestakeAdmin
queues withdrawl of LST tokensIncorrect TVL calculation leading to loss of users value
Manual review
function getTokenBalanceFromStrategy(IERC20 token) external view returns (uint256) { return - queuedShares[address(this)] == 0 + queuedShares[address(token)] == 0 ? tokenStrategyMapping[token].userUnderlyingView(address(this)) : tokenStrategyMapping[token].userUnderlyingView(address(this)) + tokenStrategyMapping[token].sharesToUnderlyingView( queuedShares[address(token)] ); }
Other
#0 - c4-judge
2024-05-16T10:43:11Z
alcueca marked the issue as satisfactory
🌟 Selected for report: guhu95
Also found by: 0rpse, 0x007, 0x73696d616f, 0xCiphky, 0xabhay, Audinarey, Bauchibred, Fassi_Security, GalloDaSballo, GoatedAudits, KupiaSec, LessDupes, MSaptarshi, OMEN, Ocean_Sky, RamenPeople, SBSecurity, Tendency, WildSniper, aslanbek, bill, blutorque, crypticdefense, cu5t0mpeo, d3e4, gjaldon, grearlake, gumgumzum, honey-k12, ilchovski, jokr, josephdara, kennedy1030, p0wd3r, peanuts, stonejiajia, t0x1c, tapir, underdog, zzykxx
0.4071 USDC - $0.41
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L219-L224
If any of the validators run by the Renzo protocol are slashed, or if AVS operators delegated to are slashed, the slashing losses should be borne by all ezETH token holders. However, it takes time for the slashing losses to reflect in the TVL, as validator slashes need to be proven in Eigen Pod first using a beacon chain state root. During this time, users can front-run the TVL changes and withdraw their funds from Renzo to avoid losses.
To address this issue, a withdrawal delay was introduced. However, the amountToRedeem
is currently calculated during the withdrawal initiation itself instead of calculating it in the claim function. So users can still avoid losses by front-running TVL updates.
This creates a race condition, where users capable of exiting earlier can do so without incurring losses.
Users can avoid validator and AVS slashing losses
Instead of calculating amountToRedeem
in withdraw function itself calculate it in the claim function.
MEV
#0 - c4-judge
2024-05-16T08:17:35Z
alcueca changed the severity to 2 (Med Risk)
#1 - c4-judge
2024-05-16T08:18:01Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2024-05-23T14:04:55Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-23T14:05:21Z
alcueca marked the issue as duplicate of #326
🌟 Selected for report: zzykxx
Also found by: 0x007, 0xCiphky, GalloDaSballo, GoatedAudits, LessDupes, fyamf, jokr, mt030d
336.3531 USDC - $336.35
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L2/xRenzoDeposit.sol#L253 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L178
The amount of ezETH minted for bridged assets will not be same as xezETH minted on for the assets in L2
Renzo utilizes connext bridge to support staking through L2 chains. When users stake native tokens in L2 chains using xRenzoDeposit
the tokens are converted to nextWETH. Corresponding xezETH is minted for the user using the oracle price in L2
function _deposit( uint256 _amountIn, uint256 _minOut, uint256 _deadline ) internal returns (uint256) { // calculate bridgeFee for deposit amount uint256 bridgeFee = getBridgeFeeShare(_amountIn); // subtract from _amountIn and add to bridgeFeeCollected _amountIn -= bridgeFee; bridgeFeeCollected += bridgeFee; // Trade deposit tokens for nextWETH uint256 amountOut = _trade(_amountIn, _deadline); if (amountOut == 0) { revert InvalidZeroOutput(); } // Fetch price and timestamp of ezETH from the configured price feed (uint256 _lastPrice, uint256 _lastPriceTimestamp) = getMintRate(); // Verify the price is not stale if (block.timestamp > _lastPriceTimestamp + 1 days) { revert OraclePriceExpired(); } // Calculate the amount of xezETH to mint - assumes 18 decimals for price and token uint256 xezETHAmount = (1e18 * amountOut) / _lastPrice; // Check that the user will get the minimum amount of xezETH if (xezETHAmount < _minOut) { revert InsufficientOutputAmount(); } // Verify the deadline has not passed if (block.timestamp > _deadline) { revert InvalidTimestamp(_deadline); } // Mint xezETH to the user IXERC20(address(xezETH)).mint(msg.sender, xezETHAmount); // Emit the event and return amount minted emit Deposit(msg.sender, _amountIn, xezETHAmount); return xezETHAmount; }
and bridged to L1. Then xRenzoBridge
receives correponding WETH which will be converted to ETH. This ETH is deposited through restakeManager.depositETH
and corresonding ezETH shares are minted using the oracle price feed in L1.
But here the minting of ezETH in L1 is happening after certain delay
Due to handling this way if there are any price changes between both minting xezETH on L2 and ezETH in L1, there will be less or more ezETH minted for all xezETH tokens. If the value is less, there will be no enough tokens to claim all xezETH tokens in the protocol
Protocol goes to insolvent state
Make sure the amount of ezETH tokens minted in L1 are equal to xezETH minted on L2
Other
#0 - c4-judge
2024-05-16T08:48:09Z
alcueca marked the issue as satisfactory
🌟 Selected for report: t0x1c
Also found by: 0xCiphky, 0xDemon, Bauchibred, DanielArmstrong, FastChecker, MSaptarshi, Maroutis, NentoR, Ocean_Sky, PNS, Rhaydden, SBSecurity, Shaheen, Tigerfrake, ZanyBonzy, atoko, btk, carlitox477, crypticdefense, honey-k12, hunter_w3b, ilchovski, jokr, ladboy233, rbserver, twcctop, umarkhatab_465
1.479 USDC - $1.48
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L491-L576
The deposit function of the RestakeManager contract, enables users to deposit LST tokens into the protocol, getting ezETH tokens in return. The function doesn’t have any type of slippage control, this is relevant in the context of the deposit function, since the amount of tokens received by the user is determined by an interaction with an oracle, meaning that the amount received in return may vary indefinitely while the request is waiting to be executed.
As can be observed by looking at its parameters and implementation, the deposit function of the RestakeManager contract, doesn’t have any type of slippage protection:
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused { // Verify collateral token is in the list - call will revert if not found uint256 tokenIndex = getCollateralTokenIndex(_collateralToken); // Get the TVLs for each operator delegator and the total TVL ( uint256[][] memory operatorDelegatorTokenTVLs, uint256[] memory operatorDelegatorTVLs, uint256 totalTVL ) = calculateTVLs(); // Get the value of the collateral token being deposited uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount); ..... // Calculate how much ezETH to mint uint256 ezETHToMint = renzoOracle.calculateMintAmount( totalTVL, collateralTokenValue, ezETH.totalSupply() ); // Mint the ezETH ezETH.mint(msg.sender, ezETHToMint); }
As can be observed, this function uses renzoOracle.lookupTokenValue
which interacts with oracle to determine the value of deposited collateral token value and uses that value to determine how many tokens to mint.
Meaning that the user has no way to predict how many ezETH they will get back at the moment of minting, as the price could be updated while the request is in the mempool.
User has no slippage control and can incur indefinite amount of slippage.
An additional parameter could be added to the deposit function, to let users decide the minimum amount of tokens to be received, with a relative check after minting.
Oracle
#0 - C4-Staff
2024-05-15T14:38:59Z
CloudEllie marked the issue as duplicate of #345
#1 - c4-judge
2024-05-17T13:28:42Z
alcueca marked the issue as satisfactory
🌟 Selected for report: 0xCiphky
Also found by: 0rpse, 0x007, 0xAadi, 14si2o_Flint, ADM, Aamir, Aymen0909, BiasedMerc, DanielArmstrong, Fassi_Security, FastChecker, KupiaSec, LessDupes, MaslarovK, Neon2835, RamenPeople, SBSecurity, Shaheen, Tendency, ZanyBonzy, adam-idarrha, araj, b0g0, baz1ka, bigtone, bill, blutorque, carrotsmuggler, cu5t0mpeo, fyamf, gesha17, gumgumzum, hunter_w3b, inzinko, jokr, josephdara, kennedy1030, kinda_very_good, lanrebayode77, m_Rassska, mt030d, mussucal, tapir, underdog, xg, zzykxx
0.0402 USDC - $0.04
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L546-L556 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L562 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Delegation/OperatorDelegator.sol#L290-L304
When a user deposits funds into the Renzo protocol, if the withdraw buffer isn't full, it's filled first. Any remaining amount is then deposited or queued for deposit into the Eigen Layer. However, if the entire deposited amount is used to fill the withdraw buffer, the operatorDelegator.deposit
function is called with a zero amount, triggering a revert due to the zero amount check in the deposit function.
function deposit( IERC20 token, uint256 tokenAmount ) external nonReentrant onlyRestakeManager returns (uint256 shares) { // audit-issue This will revert if tokenAmount is zero if (address(tokenStrategyMapping[token]) == address(0x0) || tokenAmount == 0) revert InvalidZeroInput(); // Move the tokens into this contract token.safeTransferFrom(msg.sender, address(this), tokenAmount); return _deposit(token, tokenAmount); }
Similarly, if the buffer is already full, bufferToFill is calculated to be zero. This results in depositQueue.fillERC20withdrawBuffer reverting due to the zero amount check as well.
if (bufferToFill > 0) { bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill; // update amount to send to the operator Delegator _amount -= bufferToFill; // safe Approve for depositQueue _collateralToken.safeApprove(address(depositQueue), bufferToFill); // fill Withdraw Buffer via depositQueue // @audit-issue this will revert if bufferToFill is zero depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill); }
The same issue occurs in OperatorDelegator when filling the buffer.
Deposit function will revert almost every case
operatorDelegator.deposit
will be called with zero amount which will revert due to zero amount check.Only call fillERC20withdrawBuffer
and deposit
functions if the amount is greater than zero.
if (bufferToFill > 0) { bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill; // update amount to send to the operator Delegator _amount -= bufferToFill; // safe Approve for depositQueue // @audit-issue safe approve shouldn't be used _collateralToken.safeApprove(address(depositQueue), bufferToFill); // fill Withdraw Buffer via depositQueue - depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill); + if(bufferToFill > 0) { + depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill); + } }
DoS
#0 - c4-judge
2024-05-20T05:11:42Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-24T10:26:23Z
alcueca changed the severity to 2 (Med Risk)
🌟 Selected for report: GalloDaSballo
Also found by: 0xabhay, GoatedAudits, SBSecurity, d3e4, jokr, p0wd3r, peanuts, zhaojohnson
100.9059 USDC - $100.91
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Oracle/RenzoOracle.sol#L92
The discrepancy between ezETH/ETH oracle price and market price can be used for arbitrage opportunities
The value of stETH
deposited into the protocol is priced using chainlink oracle. The current deviation and heartbeat of stETH/ETH
are 0.5%
and 86400s
. This means stETH price will not be updated it the price doesn't deviate 0.5%
in 24 hours. This difference between market price and oracle price can be used to perform arbitrage
Consider this scenario
The 0.5%
deviation might seem less here. But attacker can front run oracle updates to exploit the above scenario which have more deviation thus more profit.
For ex: if the oracle price for stETH/ETH is 1 and it changes to 0.98, now the attacker can front run the oracle update to perform the above attack. Here attacker used 2% deviation to get more profit.
The delay implemented for withdraws can gaurd against using a flash loan to perform this attack. But its still possible for attacker to perform arbitrage using upfront capital
Refer to this for more info https://medium.com/angle-protocol/angle-research-series-part-1-oracles-and-front-running-d75184abc67
Attacker can make profit using arbitrage opportunity.
Consider implementing a deposit fee to mitigate against arbitrage opportunities. Ideally, this fee would be larger than the oracle's maximum price deviation so that it is not possible to profit from arbitrage.
Oracle
#0 - C4-Staff
2024-05-15T14:45:17Z
CloudEllie marked the issue as duplicate of #429
#1 - c4-judge
2024-05-17T13:42:44Z
alcueca marked the issue as duplicate of #326
#2 - c4-judge
2024-05-17T13:42:52Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2024-05-28T04:28:56Z
alcueca marked the issue as not a duplicate
#4 - c4-judge
2024-05-28T04:29:12Z
alcueca marked the issue as duplicate of #424
#5 - c4-judge
2024-05-28T10:51:24Z
alcueca marked the issue as not a duplicate
#6 - c4-judge
2024-05-28T10:52:27Z
alcueca marked the issue as duplicate of #429
#7 - c4-judge
2024-05-28T10:56:08Z
alcueca marked the issue as duplicate of #424
#8 - c4-judge
2024-05-30T06:15:52Z
alcueca marked the issue as duplicate of #13
#9 - c4-judge
2024-05-30T06:29:08Z
alcueca changed the severity to 2 (Med Risk)
#10 - c4-judge
2024-05-30T19:19:46Z
alcueca marked the issue as not a duplicate
#11 - c4-judge
2024-05-30T19:22:04Z
alcueca marked the issue as duplicate of #13
🌟 Selected for report: Sathish9098
Also found by: 0x73696d616f, 0xCiphky, 0xmystery, ABAIKUNANBAEV, Bauchibred, BiasedMerc, Fassi_Security, FastChecker, GalloDaSballo, GoatedAudits, K42, KupiaSec, LessDupes, Limbooo, ReadyPlayer2, Rhaydden, SBSecurity, Sabit, Sparrow, WildSniper, ZanyBonzy, adam-idarrha, adeolu, araj, aslanbek, atoko, b0g0, carlitox477, crypticdefense, fyamf, gesha17, gjaldon, grearlake, gumgumzum, hihen, honey-k12, hunter_w3b, inzinko, jesjupyter, jokr, kennedy1030, kind0dev, kinda_very_good, ladboy233, lanrebayode77, oakcobalt, oualidpro, pauliax, rbserver, t0x1c, tapir, underdog, xg, zzykxx
0 USDC - $0.00
Beacon chain rewards directed to the Eigen pod are not currently added into TVL calculations. Similarly, rewards held in the Delayed Withdrawal Router are also excluded from TVL calculations, contributing to the discrepancy in the TVL value.
Partial withdrawals occur automatically on the beacon chain when a validator's effective balance exceeds 32 ETH, such as when receiving Beacon chain rewards.
The verifyAndProcessWithdrawals
function can be utilized to verify these withdrawals, enabling the Pod Owner to withdraw excess ETH via DelayedWithdrawalRouter.createDelayedWithdrawal
.
Later, the Pod Owner or anyone can complete the withdrawal after a delay using DelayedWithdrawalRouter.claimDelayedWithdrawals
. Currently the delay is set to 14 hours. The rewards withdrawn are subsequently utilized to fill the withdrawal buffer. Any remaining rewards wait in the deposit queue to be restaked.
However, these rewards are presently excluded from the Total Value Locked (TVL) calculation from the moment they enter the Eigen pod until they are transferred to the OperatorDelegator. Essentially, the rewards are not factored into the TVL while they reside in either the Eigen pod or the DelayedWithdrawalRouter. This discrepancy results in an inaccurate TVL value during this period.
Prior to rewards being withdrawn to the protocol, whether residing in the Eigen pod or the Delayed Withdraw Router, an attacker can exploit the situation by minting more ezETH than actually feasible due to the undervaluation of TVL, as rewards are not added to it.
Upon claiming the rewards after the delay, the tokens are allocated to the withdrawal buffer, while the remaining rewards will wait to be staked back into the Eigen layer. In both scenarios, the TVL value increases.
This sudden surge in TVL results in the attacker having inflated value for their ezETH tokens, thereby yielding profits.
The attacker can repeat this cycle by minting ezETH prior to the rewards being claimed.
Incorrect calculation of TVL leading to loss of users
TVL should account for rewards held in both Eigen pod and Delayed withdraw router.
ETH rewards in Eigen pod can be accounted using
beacon chain rewards = address(eigenPod).balance -withdrawableRestakedExecutionLayerGwei
/// @notice the amount of execution layer ETH in this contract that is staked in EigenLayer (i.e. withdrawn from the Beacon Chain but not from EigenLayer), uint64 public withdrawableRestakedExecutionLayerGwei;
While ETH in delayed withdraw router can be accounted using getUserDelayedWithdrawals
function in DelayerWithdrawalRouter
contract of EigenLayer.
Other
#0 - c4-judge
2024-05-16T11:34:05Z
alcueca marked the issue as primary issue
#1 - jatinj615
2024-05-22T19:10:24Z
Eth in the EigenPod can contain exited validator ETH. Therefore, we cannot count this in TVL because it will double count the ETH. We require accumulated beacon chain rewards to be withdrawn and re deposited via depositQueue to be accumulated towards TVL.
#2 - c4-judge
2024-05-23T10:47:53Z
alcueca marked the issue as unsatisfactory: Invalid
#3 - 0xvj
2024-05-26T12:35:40Z
Eth in the EigenPod can contain exited validator ETH. Therefore, we cannot count this in TVL because it will double count the ETH. We require accumulated beacon chain rewards to be withdrawn and re deposited via depositQueue to be accumulated towards TVL.
The actual issue here is about not considering beacon chain rewards in TVL.
As the sponsor mentioned, if the ETH in the EigenPod is included, it will double count the exited validator ETH. However, beacon chain rewards can be calculated in the TVL as follows:
beacon_chain_rewards = address(eigenPod).balance - withdrawableRestakedExecutionLayerGwei * 1e9
Here, withdrawableRestakedExecutionLayerGwei
represents ETH withdrawn from the Beacon Chain but not from EigenLayer. This approach will avoid double-counting the exited validator ETH and only account for the beacon chain rewards.
Similarly, ETH in the delayed withdrawal router can be accounted for using the getUserDelayedWithdrawals
function in the DelayedWithdrawalRouter contract of EigenLayer.
We require accumulated beacon chain rewards to be withdrawn and re deposited via depositQueue to be accumulated towards TVL.
Since the sum of beacon rewards accumulated in all the pods is very large, only considering them after they are redeposited into the depositQueue will cause significant inconsistencies in reported TVL values. This inconsistency could be exploited by an attacker.
#4 - alcueca
2024-05-27T10:10:46Z
Since the sum of beacon rewards accumulated in all the pods is very large, only considering them after they are redeposited into the depositQueue will cause significant inconsistencies in reported TVL values.
That depends on the frequency with which they are redeposited. My own napkin math tells me that if they are collected daily the tvl increase would be of about 1 or 2 bps.
@jatinj615, would you please review this?
Meanwhile, the warden needs to provide proof of the actual impact on the current TVL from undercounting beacon rewards.
#5 - c4-judge
2024-05-27T10:10:59Z
alcueca changed the severity to QA (Quality Assurance)
#6 - c4-judge
2024-05-27T10:11:04Z
alcueca marked the issue as grade-b
#7 - 0xvj
2024-05-28T16:35:40Z
Hello @alcueca , No they are not redepositing the rewards frequently. At the time of the contest we verified it on-chain and the total amount of beacon chain rewards in all pods were 486 ETH.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Script.sol"; import "forge-std/console.sol"; interface IRestakeManager { function operatorDelegators(uint) external view returns(address); function getOperatorDelegatorsLength() external view returns(uint256); } interface IOperatorDelegator { function eigenPod() external view returns(address); } interface IEigenPod { function withdrawableRestakedExecutionLayerGwei() external view returns(uint64); } contract CheckEtherBalance is Script { function run() public { // Block Number @May-07-2024 11:59:35 AM +UTC uint256 blockNumber = 19818045; // 10 days before the above time stamp total beacon chain rewards are around 553 ETH // blockNumber = 19746045; string memory rpcUrl = vm.envString("RPC_URL"); uint256 forkId = vm.createFork(rpcUrl, blockNumber); vm.selectFork(forkId); IRestakeManager restakeManager = IRestakeManager(0x74a09653A083691711cF8215a6ab074BB4e99ef5); uint256 operatorDelegatorsLength = restakeManager.getOperatorDelegatorsLength(); uint256 beaconRewards; for(uint i = 0; i < operatorDelegatorsLength; i++) { address delegator = restakeManager.operatorDelegators(i); address eigenPod = IOperatorDelegator(delegator).eigenPod(); uint64 withdrawableRestakedExecutionLayerGwei = IEigenPod(eigenPod).withdrawableRestakedExecutionLayerGwei(); beaconRewards += address(eigenPod).balance - withdrawableRestakedExecutionLayerGwei * 1e9; } console.log(beaconRewards/1e18); } }
== Logs == 486
#8 - jatinj615
2024-05-29T06:46:35Z
The proofs are constantly being submitted for partial withdrawals on the daily basis and being claimed and redeposited into the protocol once 7 days delay from EigenLayer is passed.
I believe the instance which warden is referring in the above POC which shows 480 pending ETH is because protocol went through the M2 upgrade and was constantly proving partial withdrawals for 7 days before finally able to claim the rewards for the 1st day on 7th day.
Stays disputed from our end.