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: 73/122
Findings: 4
Award: $1.89
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/RestakeManager.sol#L274 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Delegation/OperatorDelegator.sol#L274
calculateTVLs()
fails to consider ETH
queued for withdrawals when withdrawals are initiated from sources external to Renzo. This oversight leads to an underestimated TVL, potentially enabling an attacker to purchase ezETH
at a temporary discount.
The problem lies in the fact that withdrawals may be triggered from outside of Renzo. This can happen when:
When withdrawals are initiated from external sources, such as above, Renzo's TVL calculation fails to account for the ETH queued for withdrawal. This oversight results in an underestimation of TVL during the withdrawal delay period, creating an opportunity for individuals to purchase ezETH
at a temporary discount.
Consider this hypothetical situation:
ezETH
and TVL is 96 ETH, implying a ratio of 1 ezETH to 1 ETH.Eigenlayer
and reducing the shares of the Eigenpod by 32 ETH.calculateTVLs()
decreases by 32 ETH due to the reduction in shares, but Renzo remains unaware of the ETH in the withdrawal queue. Consequently, the exchange rate shifts to 96 ezETH to 64 ETH, resulting in a ratio of 0.67.ezETH
. They wait for the withdrawal to complete, after which the ETH is transferred to Renzo's Restaking Manager, restoring the TVL to its original value.ezETH
increases, allowing the operator to sell ezETH
for a profit.A malicious operator or any observer who identifies this issue can profit by purchasing ezETH at a lower price and selling it for a profit several days later. This comes at the expense of existing ezETH holders, whose shares will be diluted.
This scenario could also occur during a significant slashing event, such as a smart contract bug, where numerous validators are slashed to 16 ETH and forcefully exited. Observers of this event would have an opportunity to exploit the temporary underpricing of ezETH
.
Manual Review
Consider off-chain monitoring for such edge case withdrawals and temporarily pause Renzo operations until the withdrawals have completed and ETH is back inside Renzo's system. Will require implementation of a pause function in Renzo.
Other
#0 - C4-Staff
2024-05-15T14:37:47Z
CloudEllie marked the issue as duplicate of #320
#1 - c4-judge
2024-05-16T05:28:21Z
alcueca marked the issue as not a duplicate
#2 - alcueca
2024-05-16T11:31:48Z
Operator who holds active key initiates validator exit
That would be Medium, since it is a trusted role
Validator is slashed to 16 ETH and is forcefully exited
That doesn't require a trusted role, and makes this a duplicate of #441
Operator calls undelegate on Eigenlayer's DelegationManager
That would be Medium, since it is a trusted role
#3 - c4-judge
2024-05-16T11:32:01Z
alcueca marked the issue as duplicate of #441
#4 - c4-judge
2024-05-23T14:05:12Z
alcueca marked the issue as duplicate of #326
#5 - c4-judge
2024-05-24T10:11:37Z
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/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L220-L224 https://github.com/code-423n4/2024-04-renzo/blob/519e518f2d8dec9acf6482b84a181e403070d22d/contracts/Withdraw/WithdrawQueue.sol#L242-L249
In case of penalties Eigenlayer can be notified of the balance drop via the permissionless function EigenPod::verifyBalanceUpdates(). In case of slashing the validator is forced to exit and Eigenlayer can be notified via the permissionless function EigenPod::verifyAndProcessWithdrawals() because the slashing event is effectively a full withdrawal.
Changes to the actual balance of validators are updated when verifyBalanceUpdates()
is called on an EigenPod
. Slashing
of an EigenPod
is therefore known before it is updated in the EigenLayer
system.
A user can monitor the validators and call withdraw()
when a validator is slashed to front-run calls to verifyBalanceUpdates()
in order to avoid the loss.
If a user calls withdraw()
before the balance is update on EigenLayer
they will inflate amountToRedeem
they are entitled to since the TVL
has still not accounted for the slashing that happened.
uint256 amountToRedeem = renzoOracle.calculateRedeemAmount( _amount, ezETH.totalSupply(), totalTVL );
This is saved in the withdraw request for user
withdrawRequests[msg.sender].push( WithdrawRequest( _assetOut, withdrawRequestNonce, amountToRedeem, _amount, block.timestamp )
The user will increase their amountToRedeem
since the TVL is inflated.
Example scenario:
Initial state: 2 validators 32 ETH each 10 users with equal LRT, 6.4 each.
Validator 1 is slashed for 16 ETH
User 1 front-runs verifyBalanceUpdates()
with a call to withdraw()
and has amountToRedeem
= 6.4 since TVL is still 64 ETH.
verifyBalanceUpdates()
is now called to update EigenLayer balance.
User 2 calls withdraw()
and has amountToRedeem
= 4.8 since TVL has decreased to 48.
User 1 has stolen 1.4 ETH from the rest.
Thus, slashing will be concentrated among those that don't front-run. When this issue becomes known a new "meta" will be at play, everybody will have to front-run otherwise they risk losing all their funds since they will have to cover an outsized part of the slashing penalty.
Manual Review
Adjust the withdrawal process to account for penalties or slashing events ensuring fair distribution of penalties among all stakers.
Other
#0 - c4-judge
2024-05-16T08:17:56Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-23T14:04:55Z
alcueca changed the severity to 3 (High Risk)
#2 - c4-judge
2024-05-23T14:05:23Z
alcueca marked the issue as duplicate of #326
๐ Selected for report: pauliax
Also found by: 0rpse, 0x73696d616f, 0xAadi, 0xCiphky, 0xPwned, 0xhacksmithh, 0xnev, 0xnightfall, 0xordersol, 14si2o_Flint, Aamir, Aymen0909, BiasedMerc, DanielArmstrong, Fassi_Security, FastChecker, GoatedAudits, Greed, KupiaSec, LessDupes, Maroutis, NentoR, OMEN, SBSecurity, Stefanov, TheFabled, adam-idarrha, ak1, aman, araj, aslanbek, b0g0, baz1ka, bigtone, blutorque, carlitox477, carrotsmuggler, crypticdefense, eeshenggoh, fyamf, gesha17, gjaldon, grearlake, guhu95, honey-k12, hunter_w3b, ilchovski, josephdara, kinda_very_good, lanrebayode77, m_Rassska, maxim371, mt030d, mussucal, oakcobalt, p0wd3r, peanuts, rbserver, shui, siguint, t0x1c, tapir, twcctop, ustazz, xg, zhaojohnson, zigtur, zzykxx
0.0026 USDC - $0.00
The incorrect calculation of totalWithdrawalQueueValue
leads to a false calculation of the total TVL for the protocol. This breaks the Main invariant
of the protocol:
ezETH should be minted or redeemed based on current supply and TVL
calculateTVLs()
function calculates the Total Value Locked (TVL)
for each operator delegator by individual token, total for each operator delegator, and total for the protocol.
RestakeManager.sol#L270-L274
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
It also calculates the withdrawalQueue
total for each tokwn value by looping through the collateralTokens
array
RestakeManager.sol#L286-L287
// withdrawalQueue total value uint256 totalWithdrawalQueueValue = 0;
However, there is a vulnerability in the calculation of totalWithdrawalQueueValue
, where the wrong token value is retrieved due to incorrect indexing
, leading to an inaccurate calculation of the total TVL
.
Inside the inner loop, When the withdrawQueueTokenBalanceRecorded
flag is false, following code block executes:
RestakeManager.sol#L316-L321
if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue) ); }
Here, i
is the index for iterating over operatorDelegators
, and j
is the index for iterating over collateralTokens
. However, in the call to lookupTokenValue
, collateralTokens[i]
is used instead of collateralTokens[j]
. This means that the function is looking up the value of the wrong token, specifically the token at the index of the current operator delegator(i=0), not the current token.
This leads to the wrong token value being retrieved, resulting in an incorrect calculation of totalWithdrawalQueueValue
.
Furthermore, withdrawQueueTokenBalanceRecorded
flag is set to true after the first iteration of the outer loop. Therefore, the code block inside the if (!withdrawQueueTokenBalanceRecorded)
condition is only executed during the first iteration (when i=0). For subsequent iterations (when i>0), this code block is skipped.
RestakeManager.sol#L344
withdrawQueueTokenBalanceRecorded = true;
The incorrect calculation of totalWithdrawalQueueValue
leads to a false calculation of the total TVL for the protocol.
RestakeManager.sol#L352
totalTVL += address(depositQueue).balance;
It breaks the Main invariant
of the protocol:
ezETH should be minted or redeemed based on current supply and TVL
.
Total TVL is a critical metric and is used in various functions within the protocol:
RestakeManager.sol::deposit#L500-L504
( uint256[][] memory operatorDelegatorTokenTVLs, uint256[] memory operatorDelegatorTVLs, uint256 totalTVL ) = calculateTVLs();
RestakeManager.sol::depositETH()#L594
(, , uint256 totalTVL) = calculateTVLs();
RestakeManager.sol::depositTokenRewardsFromProtocol()#L652
(, uint256[] memory operatorDelegatorTVLs, uint256 totalTVL) = calculateTVLs();
WithdrawQueue.sol::withdraw()#L217
(, , uint256 totalTVL) = restakeManager.calculateTVLs();
Manual Review
if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue) ); }
In this corrected code, lookupTokenValue
is called with the correct token (collateralTokens[j])
, and the balance of that token in the withdrawal queue
. This ensures that the total value of the withdrawal queue
is calculated correctly, using the correct prices for each token.
Error
#0 - c4-judge
2024-05-16T10:34:50Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-16T10:38:47Z
alcueca changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-05-16T10:39:08Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-20T04:26:26Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-23T13:47:21Z
alcueca changed the severity to 3 (High Risk)
๐ 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
The deposit()
function of the RestakeManager.sol
contract enables users to deposit assets 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.
Also the users will have no defense against price manipulations attacks, if they where to be found after the protocolโs deployment.
As can be observed by looking at its parameters and implementation, the deposit()
function of the RestakeManager.sol
contract, doesnโt have any type of slippage protection:
RestakeManager.sol#L473
function deposit(IERC20 _collateralToken, uint256 _amount) external {
Meaning that users have no control over how many ezETH
tokens they will get in return for depositing in the contract.
The amount of tokens to be minted, with respect to the deposited amount, is determined by the calculateMintAmount
function
uint256 ezETHToMint = renzoOracle.calculateMintAmount( totalTVL, collateralTokenValue, ezETH.totalSupply() );
The values of the tokens being deposited is determined as follows:
uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);
The lookupTokenValue
function queries the external oracle for the asset price:
function lookupTokenValue(IERC20 _token, uint256 _balance) public view returns (uint256) { AggregatorV3Interface oracle = tokenOracleLookup[_token]; if (address(oracle) == address(0x0)) revert OracleNotFound(); (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData(); if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired(); if (price <= 0) revert InvalidOraclePrice(); // Price is times 10**18 ensure value amount is scaled return (uint256(price) * _balance) / SCALE_FACTOR; }
Thus, there is no protection for the user in the current implementation. 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.
Manual Review
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.
Other
#0 - c4-judge
2024-05-17T13:28:51Z
alcueca marked the issue as satisfactory
๐ 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
If a depositor stakes stETH, the value of their asset could fluctuate. This issue affects not only the staked asset but also all existing stETH tokens in the protocol.
Currently, ezETH is minted based on the eth feeds for assets. RenzoOracle.sol::L60-62
// Verify that the pricing of the oracle is 18 decimals - pricing calculations will be off otherwise if (_oracleAddress.decimals() != 18) revert InvalidTokenDecimals(18, _oracleAddress.decimals());
function deposit( IERC20 _collateralToken, uint256 _amount, uint256 _referralId ) public nonReentrant notPaused {
uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);
function lookupTokenValue(IERC20 _token, uint256 _balance) public view returns (uint256) { AggregatorV3Interface oracle = tokenOracleLookup[_token]; if (address(oracle) == address(0x0)) revert OracleNotFound(); (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData(); if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired(); if (price <= 0) revert InvalidOraclePrice(); // Price is times 10**18 ensure value amount is scaled return (uint256(price) * _balance) / SCALE_FACTOR; }
However, this could be an issue for stETH
because its USD
feed is more reliable than its ETH
feed.
While using the ASSET/ETH
feed is a safe choice for some assets, it could pose a problem for assets with safer USD
feeds. For instance, the stETH
token has two Chainlink-supported feeds:
The stETH/ETH
feed at 0x86392dC19c0b719886221c78AB11eb8Cf5c52812
The stETH/USD
feed at 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8
On the official Chainlink site, we can see that the former feed has a heartbeat
of of a significant 86400 seconds
, while the latter has a heartbeat of just 3600 seconds
.
This means the price can change up to 24 hours before a price update is triggered on the stETH/ETH
feed, resulting in a significant difference between the on-chain price and the actual stETH price. On the other hand, the stETH/USD
feed updates every hour, making it more accurate as it stays close to the actual stETH price.
This is particularly important when minting ezETH
, as the stETH price is often queried. In some cases, it might even be queried twice, especially when the provided asset is stETH
. This is because the value of the tokens provided by the depositor and the assets held by the protocol are both determined in relation to this price. This could potentially double the impact of any discrepancies in the price. Therefore, itโs crucial to consider these factors when dealing with stETH.
VS Code
Implement two feeds to get the price of stETH, i.e the stETH/USD feed
, then use the ETH/USD
feed to convert the price to ETH.
Oracle
#0 - c4-judge
2024-05-23T17:24:51Z
alcueca changed the severity to QA (Quality Assurance)
#1 - c4-judge
2024-05-23T17:25:04Z
alcueca marked the issue as grade-a