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: 14/122
Findings: 6
Award: $1,187.66
🌟 Selected for report: 2
🚀 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.5292 USDC - $0.53
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L220-L224
Deposit and withdrawal requests can be done immediately with no costs or fees, and both use the current oracle prices and TVL calculation (deposit, and withdraw). Crucially, the withdrawal amount is calculated at withdrawal request submission time instead of at withdrawal claim time. Any small change in either ezETH value or in the price of a collateral token can be exploited at no cost by MEV. Specifically, if the price increases, a deposit is made before the increase, and a withdrawal request immediately after.
Additionally, in case of a supported LST's sudden change in price (for example due to price manipulation, an exploit of that LST, due to consensus layer penalties (slashing), or liquidity issues), external holders of that LST may frontrun the change, deposit the LST into Renzo, and immediately request a withdrawal of another asset (e.g., native ETH). In such situations, Renzo functions as a zero-slippage zero-fees oracle-price-based DEX for LSTs and ETH up to the TVL cap for the affected LST. Zero-slippage zero-fee oracle-price-based designs are notoriously vulnerable to both oracle manipulation and oracle latency attacks if not carefully prevented.
The newly introduced frontrunning vector, due to incurring only gas fees, and no fee that is proportional to the size of the "trade", allows profitably exploiting most TVL and oracle price changes, and exploiting previously exploitable updates (vie Balancer's usage of getRate()
) even more profitably via the new vector.
The impact is that value that otherwise should be distributed to ezETH holders is constantly lost to MEV.
Additionally, ezETH holders lose value due to facilitating asset swaps with no slippage and no fees based on outdated oracle prices.
Scenario 1 (MEV, price increase):
Scenario 2 (malicious):
Scenario 3 (MEV, price decrease, zerp-slippage zero-fees DEX):
Manual review.
The redemption conversion should be performed at both request and claim time. If it results in a lower redeem value, that value should be used for the claim instead of the initial redeem amount. Additionally, a rate limit or a short delay on deposits with similar protection can be added as well.
function claim(uint256 withdrawRequestIndex) external nonReentrant { ... + // All the code converting from ezETH amount to amountToRedeem as is done in withdraw() + if (amountToRedeem < _withdrawRequest.amountToRedeem) { + _withdrawRequest.amountToRedeem = amountToRedeem; + } }
MEV
#0 - c4-judge
2024-05-16T05:31:23Z
alcueca marked the issue as selected for report
#1 - alcueca
2024-05-16T05:33:57Z
@jatinj615, please review. You reviewed #320 which is in the same group, but this report correctly points out that the withdrawal value is calculated immediately, rendering the sandwich possible.
#2 - c4-judge
2024-05-16T11:19:30Z
alcueca marked the issue as satisfactory
#3 - alcueca
2024-05-17T12:26:12Z
The sponsor comment in #259 is relevant here, on why withdrawals are priced on withdraw
, and not claim
. The resulting implementation might have to take a trade-off between being arbitraged one way or another, or opt for a different implementation altogether.
#4 - jatinj615
2024-05-17T12:54:04Z
@alcueca , thanks for pointing this out ser. This report is really great and the mitigation steps also looks good. Just want to confirm if we move ahead with the mitigation steps would there be any other possible attack vector which we need to worry about ?
🌟 Selected for report: LessDupes
Also found by: RamenPeople, SBSecurity, bill, guhu95, ilchovski, peanuts
598.5522 USDC - $598.55
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L158
WithdrawQueue
's getAvailableToWithdraw
function may underflow and revert if stETH (or another rebasing LST) experiences a negative rebase. This is because when IERC20(_asset).balanceOf(address(this)) - claimReserve[_asset]
is 0 (or nearly 0), a negative rebase will result in stETH balance being lower than claimReserve
.
A negative rebase is expected to happen due to slashing, inactivity leaks, or a Lido issue in the case of stETH. Other rebasing LSTs may also expirience a negative rebase during the course of normal operation.
This will cause the following user flows to revert:
This will also cause the following admin flow to revert:
Crucially, because user deposits will revert and OperatorDelegator.completeQueuedWithdrawal
will revert, it will not be possible to refill the buffer. This is because refilling the buffer always calls the method to calculate the transfer amount, which will revert for this asset.
For the issue to resolve, unclaimed withdrawals will need to be claimed. However, due to the 7-day delay imposed on unclaiming, this may take up to 7 days. Even then, because a claim transaction may only be submitted by the original withdrawer (msg.sender check), a user may prolong the DoS maliciously or inadvertently by not claiming their withdrawal.
Even if the buffer is not near 0 (when balance and reserve are equal to each other), an attacker may frontrun a negative rebase with a deposit and a withdrawal to "take the buffer" bringing it to 0, such that after their action, the protocol will stop functioning for this asset, possibly until the attacker claims their withdrawal. This will result in a prolonged DoS.
claimReserve
, either in the course of normal operations or maliciously.WithdrawQueue
.OperatorDelegator.completeQueuedWithdrawal
) involving stETH all revert until a sufficient amount of withdrawals is claimed to reduce the claimReserve
.Manual Review
Check if the subtraction will underflow and report 0 in that case:
function getAvailableToWithdraw(address _asset) public view returns (uint256) { if (_asset != IS_NATIVE) { - return IERC20(_asset).balanceOf(address(this)) - claimReserve[_asset]; + uint256 balance = IERC20(_asset).balanceOf(address(this)); + return (balance > claimReserve[_asset]) ? balance - claimReserve[_asset] : 0;
DoS
#0 - c4-judge
2024-05-17T12:46:14Z
alcueca changed the severity to 3 (High Risk)
#1 - c4-judge
2024-05-17T12:46:26Z
alcueca marked the issue as duplicate of #326
#2 - c4-judge
2024-05-17T12:48:22Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2024-05-27T08:47:28Z
alcueca marked the issue as not a duplicate
#4 - c4-judge
2024-05-27T08:47:33Z
alcueca changed the severity to 2 (Med Risk)
#5 - c4-judge
2024-05-27T08:47:46Z
alcueca marked the issue as duplicate of #282
#6 - c4-judge
2024-05-27T08:51:57Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: LessDupes
Also found by: RamenPeople, SBSecurity, bill, guhu95, ilchovski, peanuts
598.5522 USDC - $598.55
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L217-L233 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L305-L308
The WithdrawQueue
contract calculates the withdrawal redemption amount at the time of the withdrawal request but pays out the amount at the time of the claim.
For accruing LSTs, this means that yield that can be accrued for ezETH holders, is instead accrued to withdrawals, and lost to the protocol.
This will also create an imbalance in deposit and withdrawal flows because users will prefer to redeem in accruing tokens to avoid losing 7 days of yield.
This, in turn, will skew the asset composition, causing the protocol to accumulate rebasing LSTs over time to reach their TVL cap and revert on subsequent deposits. This will result in an ongoing DoS of deposits for these assets.
While it's possible to keep increasing TVL caps for rebasing LSTs, it is unlikely to be a long term solution, because would not be responsible risk management and would reduce asset diversification.
Manual Review
Calculate the redemption from ezETH to ETH value at the withdrawal request time, but calculate the final LST amount at claim time for all assets. This will keep the ETH value of a withdrawal fixed for the duration of the delay and divert the yield earned to the ezETH holders.
Alternatively, allow the admin to rebalance the asset mixture by swapping assets withdrawn from Eigenlayer before transferring them to the WithdrawQueue
.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant { ... // Calculate amount to Redeem in ETH uint256 amountToRedeem = renzoOracle.calculateRedeemAmount( _amount, ezETH.totalSupply(), totalTVL ); - // update amount in claim asset, if claim asset is not ETH - if (_assetOut != IS_NATIVE) { - // Get ERC20 asset equivalent amount - amountToRedeem = renzoOracle.lookupTokenAmountFromValue( - IERC20(_assetOut), - amountToRedeem - ); } function claim(uint256 withdrawRequestIndex) external nonReentrant { ... WithdrawRequest memory _withdrawRequest = withdrawRequests[msg.sender][ withdrawRequestIndex ]; + // update amount in claim asset, if claim asset is not ETH + if (_withdrawRequest.collateralToken != IS_NATIVE) { + // Get ERC20 asset equivalent amount + _withdrawRequest.amountToRedeem = renzoOracle.lookupTokenAmountFromValue( + IERC20(_withdrawRequest.assetOut), + _withdrawRequest.amountToRedeem + ); }
Other
#0 - C4-Staff
2024-05-15T14:27:08Z
CloudEllie marked the issue as duplicate of #467
#1 - c4-judge
2024-05-17T12:57:48Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2024-05-17T12:57:53Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2024-05-17T12:58:03Z
alcueca marked the issue as duplicate of #326
#4 - c4-judge
2024-05-27T08:49:56Z
alcueca marked the issue as not a duplicate
#5 - c4-judge
2024-05-27T08:50:06Z
alcueca marked the issue as duplicate of #282
#6 - alcueca
2024-05-27T08:54:29Z
While this issue doesn't reveal the reverts caused by decreasing stEth balances in an slashing event, it does correctly point out that stETH shouldn't be treated as non-rebasing LSTs in the withdrawal queue.
🌟 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
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L318
The calculateTVLs
function uses uses i
(the index of the operator delegator) instead of j
(the index of the collateral token) to access the collateralTokens
array when calculating the token value of the withdrawal queue balance causing the wrong oracle price to be used for the calculation.
Because i
is 0 when withdrawQueueTokenBalanceRecorded
is false, the calculation will always use the price of the first token for all tokens in the withdrawal queue. For all tokens except the first, the TVL calculation will be incorrect and exploitable:
OperatorDelegator.completeQueuedWithdrawal
is called, it changes the TVL without changing the supply by moving the balance from a correctly accounted location (queuedShares
) to an incorrectly accounted one (WithdrawalQueue
).RestakeManager.deposit
is called, it mints an amount of ezETH based on correct token price but then moves the tokens into the withdrawal queue where they are accounted for with the incorrect price.WithdrawalQueue.claim
is called, incorrectly acounted tokens reduce the TVL by an incorrect amount, because the change in shares due to burn
does not correspond to the change in TVL due to previously incorrectly accounted tokens that now are transferred out.Currently, token 0 is wBETH and token 1 is stETH, with a price difference of 4%, meaning that stETH balances in the withdrawal queue are overvalued in the TVL calculation. When more tokens are enabled, larger price differences will be possible. Accumulating tokens (e.g., ANKRETH & rETH) are much more expensive than rebasing ones (stETH, frxETH), with price differences of up to 15%.
This makes the TVL calculation exploitable by frontrunning. A transaction that moves a balance from an incorrectly accounted location to a correctly accounted one changes the TVL, which in turn affects the ezETH share price. This directly changes the deposit and withdrawal prices, as well as the getRate()
price value used by the Balancer pool.
Sandwiching this predictable change between either two opposite Balancer trades or a pair of deposit and withdraw actions allows an attacker to steal funds from ezETH holders.
Scenario 1:
getRate()
price that uses the TVL calculation.OperatorDelegator.completeQueuedWithdrawal
is called, withdrawing a large amount of stETH
, the stETH
balance is moved from a correctly accounted location (queuedShares
) to an incorrectly accounted one (WithdrawalQueue
). This increases the TVL due to using wBETH
price for calculating the stETH
value added to the WithdrawalQueue.getRate
price to realize a risk free profit.A variety of similar exploits involving both Balancer traders, and deposit and withdrawals are made possible. Both for exploiting the TVL in deposit flow, for the completeQueuedWithdrawal
flow, and for the withdrawal flow. They can involve sandwiching large transactions of other users, or using attacker's funds to trigger the deposit and withdrawal flows directly.
Manual review.
Use the correct index j
instead of i
when accessing collateralTokens
in the totalWithdrawalQueueValue
calculation:
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( -- collateralTokens[i], ++ collateralTokens[j], collateralTokens[j].balanceOf(withdrawQueue) );
This will ensure that the correct token prices are used for each token in the withdrawal queue.
Oracle
#0 - c4-judge
2024-05-16T10:31: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:20Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: zigtur
Also found by: 0x73696d616f, 0xBeastBoy, 0xCiphky, Aymen0909, FastChecker, LessDupes, NentoR, Sathish9098, TECHFUND, TheFabled, ak1, bigtone, cu5t0mpeo, eeshenggoh, guhu95, ilchovski, josephdara, ladboy233, mt030d, oakcobalt, rbserver, t0x1c, tapir, xg
2.6973 USDC - $2.70
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L206 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Withdraw/WithdrawQueue.sol#L279
The WithdrawQueue
contract defines a pausing functionality but does not use it for the user-controlled withdraw
and claim
functions. This is in contrast to the pausing functionality that is used for deposits and ezETH transfers.
Crucially, during the withdrawal claim()
function, ezETH is burned (not transferred), meaning that it cannot be paused using ezETH's pausing functionality as a workaround. This is because ezETH's pausing functionality is limited to transfers and does not pause minting or burning.
In case of an issue that requires pausing the protocol, for example, during a configuration change, an upgrade, or the mitigation of another issue, funds in the WithdrawQueue
will remain vulnerable and may leave the protocol incorrectly, causing a loss of funds to ezETH holders.
Additionally, if ezETH itself is not paused, withdrawal requests will also be callable, potentially taking advantage of possibly incorrectly calculated redemption values, causing loss of value to ezETH holders.
N/A
Manual Review
Use the whenNotPaused
modifier on the withdraw()
and claim()
functions:
- function withdraw(uint256 _amount, address _assetOut) external nonReentrant { + function withdraw(uint256 _amount, address _assetOut) external nonReentrant whenNotPaused { - function claim(uint256 withdrawRequestIndex) external nonReentrant { + function claim(uint256 withdrawRequestIndex) external nonReentrant whenNotPaused {
Access Control
#0 - c4-judge
2024-05-16T09:55:47Z
alcueca marked the issue as not a duplicate
#1 - jatinj615
2024-05-22T10:33:52Z
@alcueca , seen the same issue in QA as well, please confirm on the severity of the issue.
#2 - c4-judge
2024-05-23T09:46:30Z
alcueca marked the issue as duplicate of #569
#3 - alcueca
2024-05-23T09:47:25Z
Under the rules of the contest, this looks like some protocol features (pausing) are not working, which is classified as Medium.
#4 - c4-judge
2024-05-24T10:13:31Z
alcueca marked the issue as satisfactory
🌟 Selected for report: guhu95
Also found by: Bauchibred, LessDupes, ilchovski, zzykxx
452.3315 USDC - $452.33
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L289-L310
The calculateTVLs
function in the RestakeManager
contains two nested loops: one loop for each operator delegator (OD), and an internal loop for each token. In each internal loop:
getTokenBalanceFromStrategy
function is called for the token, which in turn calls the Eigenlayer strategy for the shares balance.renzoOracle
is called for the token value, which in turns calls the respective oracle.Due to the the nested loop structure, the gas consumption in the case of several operator delegators, and several tokens used is quadratic: n-OD x n-Tokens
. Crucially, this method is called in most user flows: deposits, withdrawals, and Balancer trades.
An even small number of operators and supported tokens will render the protocol unusable, up to running out of block gas limit.
Currently, with 1 OD and 2 tokens, calculateTVLs
consumes approximately 450K gas (see the example transaction gas profiling section).
For reasonable values, such as 12 ODs and 12 tokens (Eigenlayer supports 12 tokens), there would be 144 nested loop iterations instead of just 2 iterations in the current version and configuration. A simple extrapolation, without adding the cost of operations added in the new version, and without estimating the additional quadratic memory expansion costs, would suggest that 144 iterations, instead of the current 2, would cost 32M gas (450K * 144 / 2), exceeding the block gas limit.
However, an earlier limiting factor will be encountered because calculateTVL
is called in most user interactions with Renzo: deposit, withdrawal, and Balancer trade. Thus, exceeding even 1M-2M gas on L1 will render most protocol methods practically unusable for users.
calculateTVLs
consumes approximately 450K gas (see the example transaction gas profiling section)Manual Review
Instead of "pulling" operator delegator token balances, a "pushing" pattern can be used:
RenzoOracle.lookupTokenValues
once, which will result in a single loop over the tokens only.chooseOperatorDelegatorForDeposit
a Set of ODs that can accept a deposit (are below allocation) can be maintained via "push" updates.This will remove all loops during the calculation, except for the loop needed to iterate over the token oracles (once).
Alternatively, an off-chain custom TVL oracle can be used, for example, via a custom Chainlink oracle.
DoS
#0 - C4-Staff
2024-05-15T14:14:29Z
CloudEllie marked the issue as duplicate of #560
#1 - c4-judge
2024-05-20T03:54:05Z
alcueca marked the issue as selected for report
#2 - c4-judge
2024-05-20T03:54:08Z
alcueca marked the issue as satisfactory
133.552 USDC - $133.55
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L148-L150
According to Connext documentation, Connext xcall
's destination xReceive
out-of-gas errors and reverts are not replayable despite transferring funds. While they are recoverable by an admin (as recommended), recovering them without also completing the xReceive
logic will leave the lockbox undersupplied with ezETH. However, calling xReceive
a second time from the admin is not possible because only the Connext bridge is the allowed caller.
If a revert happens for any reason during the deposit (e.g., a TVL limit is reached or due to any other error condition), or if L1 Connext approved relayer or protocol relayer inadvertently provide insufficient gas, the execute
transaction may not revert (reconciled
true), and only the inner transaction to the xRenzoBridge
contract xReceive
will fail.
As a result, the funds will not be deposited, and ezETH will not be minted and added to the lockbox. This will prevent the ezETH that was minted on L2 from being possible to bridge to L1 and be unwrapped for underlying ezETH. This is because to bridge xezETH
from L2, real L1 ezETH must be provided from the lockbox.
Since there is no withdrawal logic on L2 (only on L1), and bridging of ezETH is done through Connext and the lockbox, if this happens, it will cause L2 ezETH not to be possible to bridge back or redeem.
Subsequently, being impossible to bridge to L1 will cause L2 xezETH it to lose value compared to L1 ezETH due to the inability to complete the arbitrage cycle, and equalize prices due to selling.
xReceive
internal call on L1 failed due to a gas issue or an internal revert.Manual Review
Allow the admin to call xReceive
instead of only the Connext bridge. This will allow retrying the reverted portion of the transaction (the whole xReceive
sub-call).
- if (msg.sender != address(connext)) { + if (msg.sender != address(connext && !roleManager.isBridgeAdmin(msg.sender)) { revert InvalidSender(address(connext), msg.sender); }
Other
#0 - c4-judge
2024-05-17T13:37:14Z
alcueca marked the issue as satisfactory