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: 25/122
Findings: 4
Award: $467.94
🌟 Selected for report: 1
🚀 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
ezETH calculates withdrawal value via the following:
if (_assetOut != IS_NATIVE) { // Get ERC20 asset equivalent amount amountToRedeem = renzoOracle.lookupTokenAmountFromValue( IERC20(_assetOut), amountToRedeem ); /// @audit Arbitrage here }
This is a spot value conversion of the ETH to LST value.
In the case of stETH, a slashing would reduce the balance of each token holder and as such it would reduce the total amount of tokens in Renzo.
Because the amount of stETH to withdraw is cached as amountToRedeem
, in case of a slashing withdrawers stake will not be slashed, which will:
If this were to be done to a sufficient scale, this would cause ezETH to be undercollateralized, as all available withdrawals would be used to escape slashing mechanism
The logic to compute the ETH value of an LRT in Renzo is as follows:
if (_assetOut != IS_NATIVE) { // Get ERC20 asset equivalent amount amountToRedeem = renzoOracle.lookupTokenAmountFromValue( IERC20(_assetOut), amountToRedeem ); /// @audit Arbitrage here } // revert if amount to redeem is greater than withdrawBufferTarget if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer(); // increment the withdrawRequestNonce withdrawRequestNonce++; // add withdraw request for msg.sender withdrawRequests[msg.sender].push( WithdrawRequest( _assetOut, withdrawRequestNonce, amountToRedeem, /// @audit Can prevent slashing from here -> Insolvency _amount, block.timestamp ) );
This amount is then subtracted in claim
function claim(uint256 withdrawRequestIndex) external nonReentrant { // check if provided withdrawRequest Index is valid if (withdrawRequestIndex >= withdrawRequests[msg.sender].length) revert InvalidWithdrawIndex(); WithdrawRequest memory _withdrawRequest = withdrawRequests[msg.sender][ withdrawRequestIndex ]; if (block.timestamp - _withdrawRequest.createdAt < coolDownPeriod) revert EarlyClaim(); // subtract value from claim reserve for claim asset claimReserve[_withdrawRequest.collateralToken] -= _withdrawRequest.amountToRedeem;
The issue with this logic is that in between the queueing of a withdrawal and the claiming
If a slashing happens, then the value of the LRT withdrawable would need to be decreased by a commesurate amount
In lack of that, withdrawers that are being slashed are protected from slashing as Renzo is taking on the Loss by socializing it to all depositors that do not withdraw
I believe there's 2 ways to handle mitigation
One requires ad hoc customizations for each token, by computing the delta index in the exchange rate at the time of withdrawal
The alternative is to denominate withdrawals in ETH, and make changes to make all LSTs withdrawals be done back to ETH
Each token would need to have it's index tracked, and slashing must account for a discount that is reflects the loss that happened
While the codes bases are different, a good starting point is to check how Blast handles discount: https://github.com/blast-io/blast/blob/04ed82f54a627622f82bea2217a090106d1ca2c2/blast-optimism/packages/contracts-bedrock/src/mainnet-bridge/withdrawal-queue/WithdrawalQueue.sol#L411-L441
In my opinion, since Renzo separates different tokens at a conceptual level, then each token should have an index for the share value
e.g.
uint256 withdrawalIndex = stETH.getPooledEthByShares(1e18); // Retrieve index at time of withdrawal
uint256 currentIndex = stETH.getPooledEthByShares(1e18); uint256 impreciseDiscountFactor = currentIndex > withdrawalIndex ? 0 /// @audit No discount : withdrawalIndex - currentIndex /// @audit Discount /// @audit TODO: Fuzz tests and invariant test for precision loss uint256 safeWithdrawalAmount = _withdrawRequest.amountToRedeem * (1e18 - impreciseDiscountFactor);
By doing this, the system should maintain the property that withdrawals do not lower the PPFS
MEV
#0 - jatinj615
2024-05-15T15:52:16Z
Proper fix will be implemented once EigenLayer Slashing is finalised.
#1 - C4-Staff
2024-05-15T16:43:22Z
CloudEllie marked the issue as primary issue
#2 - alcueca
2024-05-16T08:32:32Z
Related to #326 in that the root cause is calculating withdrawal amounts immediately allows front-running the TVL changes.
#3 - c4-judge
2024-05-16T08:33:17Z
alcueca marked the issue as duplicate of #326
#4 - c4-judge
2024-05-17T12:48:38Z
alcueca marked the issue as satisfactory
🌟 Selected for report: zzykxx
Also found by: 0x007, 0xCiphky, GalloDaSballo, GoatedAudits, LessDupes, fyamf, jokr, mt030d
336.3531 USDC - $336.35
xRenzoDeposit
uses the L2 Oracle Data to determine how much xezETH to Mint
// Fetch price and timestamp of ezETH from the configured price feed (uint256 _lastPrice, uint256 _lastPriceTimestamp) = getMintRate();
It will then receive a WETH amount to deposit into ezETH and it will then proceed to mint ezETH for the corresponding amount
This logic has flaws, which will cause undercollateralization of xezETH in the Lockbox, this is because:
From my review, the lockbox is currently over-collateralized
https://docs.google.com/spreadsheets/d/11YceC1tdweSHn4zqsVJoCsOBYp8X_bmQ6-Z6_5usCeM/edit?usp=sharing
I attribute this to the fact that the stETH Feed is a Market Rate Feed
Which means that over time, the stETH feed is causing a slight reduction in the price of ezETH, making it so that the lockbox is minting more than intended
Empirically we have prove that the accounting is incorrect
That said the incorrect accounting currently ensures that withdrawers will be able to exit, while some ETH will be indefinitely stuck in the Renzo Contracts
To keep things simple, we can think of ezETH as a stETH Wrapper
Depositing 1 stETH over time, will entitle to more stETH
The rate between ezETH and stETH is not dictated by it's TVL but rather the conversion of stETH to ETH via a Chainlink Price feed, which has at most 1 day of delay
The same logic applies between ezETH and xezETH, a feed with another day of delay is used.
This means that we can have 3 scenarios:
From this, assuming the pessimistic case can happen, we derive the following POC
Let's assume a 1% Price change to simplify (a more likely price change would be betwee 1.5 BPS and 3 BPS)
I believe that it would be best for Renzo to use fees as part of a mechanism to ensure solvency.
At 4% per year, we get 4/365 = 0.0109589041
APR
Meaning that in the average case, xezETH should lose around 1 BPS to oracle drift
Given that xezETH fees are 5 BPS, the fees should be able to cover this in most cases
MEV
#0 - c4-judge
2024-05-16T08:48:17Z
alcueca marked the issue as satisfactory
#1 - c4-judge
2024-05-16T08:49:54Z
alcueca marked the issue as selected for report
#2 - c4-judge
2024-05-16T08:52:25Z
alcueca marked the issue as not selected for report
#3 - c4-judge
2024-05-16T08:52:31Z
alcueca marked issue #145 as primary and marked this issue as a duplicate of 145
#4 - c4-judge
2024-05-24T10:27:58Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: GalloDaSballo
Also found by: 0xabhay, GoatedAudits, SBSecurity, d3e4, jokr, p0wd3r, peanuts, zhaojohnson
131.1777 USDC - $131.18
The stETH/ETH oracle is not a exchange rate feed, it's a Market Rate Feed
While other feeds are exchange rate feeds
This opens up ezETH to be vulnerable to:
This opens up to arbitrage anytime stETH trades at a discount (see Liquidations on the 13th of April)
Had withdrawals been open, the following could have been possible:
As well as:
I believe the withdrawal logic needs to be rethought to be denominated in ETH
The suggested architecture would look like the following:
Oracle
#0 - jatinj615
2024-05-14T21:48:20Z
Expected Behaviour.
#1 - C4-Staff
2024-05-15T16:42:39Z
CloudEllie marked the issue as primary issue
#2 - c4-judge
2024-05-20T03:04:19Z
alcueca marked issue #8 as primary and marked this issue as a duplicate of 8
#3 - c4-judge
2024-05-20T03:06:19Z
alcueca marked the issue as not a duplicate
#4 - alcueca
2024-05-20T03:11:10Z
It is debatable whether the market or exchange rate is the real price. The market price is the price for an instant trade, while the exchange rate is the price for a trade in the terms of the stETH contract. Renzo is not even using a real market price, which would be retrieved from a DEX. Whatever the choice of oracle, there will be some arbitrage opportunities.
#5 - c4-judge
2024-05-20T03:11:18Z
alcueca marked the issue as unsatisfactory: Invalid
#6 - alcueca
2024-05-27T08:26:02Z
From the PJQA:
I'd like to point that for https://github.com/code-423n4/2024-04-renzo-findings/issues/13 the arbitrage can be avoided, by underpricing stETH on deposit (market value), and over pricing it (redemption value) on withdrawal. Pricing in any other way, due to lack of fees will create arbitrage, while pricing in the way above will protect against it There is a factually better way to price stETH which is to use it's redemption value, and to be on the safer side, use it's market value on deposit and the redemption value on withdrawal The in scope configuration is objectively leaking value to arbitrage when compare to the suggest mitigation
I do actually know of other protocols working on similar topics that have recognized this as a problem and taken significant steps to avoid it.
#7 - c4-judge
2024-05-27T08:30:52Z
alcueca marked the issue as satisfactory
#8 - c4-judge
2024-05-27T08:30:57Z
alcueca marked the issue as primary issue
#9 - c4-judge
2024-05-27T08:31:02Z
alcueca marked the issue as selected for report
#10 - alcueca
2024-05-30T06:28:30Z
Mitigation from sponsor on #424, along with explanation on why both groups should be merged. Namely, that using the market rate for stETH/ETH is what enables arbitraging between different collaterals, and that the fix from this finding will also fix the duplicates.
🌟 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
The following are notes I wrote while doing the security review for Renzo
They are separate by topic and include:
Gas considerations
Small Refactorings
Economic Considerations
Blast Integration specifics
Invariants
We can see 0x0B1981a9Fcc24A445dE15141390d3E46DA0e425c.adminGasSpentInWei(address(0x3b8C27038848592a51384334D8090DD869a816CB))
As well as the rest of the operatorDelegators
Have almost 1ETH of gas refund tied to their accounts
But there seems to be no way to pay these out
And paying them out would require causing a loss of yield to he rest of the depositors
Which doesn't seem to be accounted for
Instead of capping the refund to block.basefee + premium
The refunds are using tx.gasprice
, which is directly manipulatable by the eoa performing the calls
Since the logic is as follows:
adminGasSpentInWei[tx.origin]
totalEarned
logic ignores gas refundsreceive() external payable nonReentrant { uint256 feeAmount = 0; // Take protocol cut of rewards if enabled if (feeAddress != address(0x0) && feeBasisPoints > 0) { feeAmount = (msg.value * feeBasisPoints) / 10000; (bool success, ) = feeAddress.call{ value: feeAmount }(""); if (!success) revert TransferFailed(); emit ProtocolFeesPaid(IERC20(address(0x0)), feeAmount, feeAddress); } // update remaining rewards uint256 remainingRewards = msg.value - feeAmount; // Check and fill ETH withdraw buffer if required _checkAndFillETHWithdrawBuffer(remainingRewards); // Add to the total earned totalEarned[address(0x0)] = totalEarned[address(0x0)] + remainingRewards; /// @audit THIS IS WRONG DUE TO FEES
Since the system pays out gas refunds, and totalEarned
is ignoring them, it will over-report by a bit
function getRate() external view returns (uint256) { /// TODO: I feel the TVL math is wrong post-withdrawal, esp post slashing // Get the total TVL priced in ETH from restakeManager (, , uint256 totalTVL) = restakeManager.calculateTVLs(); /// @audit 100% Arbitrage here due to delay + oracle swings // Get the total supply of the ezETH token uint256 totalSupply = ezETHToken.totalSupply(); // Sanity check if (totalSupply == 0 || totalTVL == 0) revert InvalidZeroInput(); // Return the rate return (10 ** 18 * totalTVL) / totalSupply; /// @audit this breaks if total supply is below 1e18 }
function xReceive( bytes32 _transferId, uint256 _amount, address _asset, address _originSender, uint32 _origin, bytes memory ) external nonReentrant returns (bytes memory) { // Only allow incoming messages from the Connext contract if (msg.sender != address(connext)) { /// @audit TODO: Connext integration unclear revert InvalidSender(address(connext), msg.sender); }
xezETH can be minted and lost by anyone, meaning that the total ezETH in the lockbox can be greater than what is claimable
This doesn't seem to cause issues
-> NOTE: as long as the oracles used are the exchange rate oracles, this can be performed both for depositing and withdrawing
This line shows the logic to queue a withdrawal:
if (amountToRedeem > getAvailableToWithdraw(_assetOut)) revert NotEnoughWithdrawBuffer();
This is strictly first-in first-out
As any new withdrawal queued will reduce claimReserve
The problem with this approach is that while the duration of a withdrawal is guaranteed to be coolDownPeriod
The time to wait for getAvailableToWithdraw(_assetOut)
to be increased and the amount by which it will grow, is undefined
This is because:
-> Oracle on L2 reports pre-slashing amount -> Oracle on Mainnet has post-slashing amount -> xRenzoBridge mints ezETH amount -> That's a higher amount
The extra tokens would be minted but not backed
That said the tokens wouldn't be redeemable
withdrawRequestNonce
is unusedJust use the previous length of the array
contract LockboxAdapterBlast { address immutable blastStandardBridge; address immutable registry; // ERRORS error InvalidRemoteToken(address _remoteToken); error AmountLessThanZero(); error InvalidAddress(); constructor(address _blastStandardBridge, address _registry) { // Sanity check if (_blastStandardBridge == address(0) || _registry == address(0)) { revert InvalidAddress(); } blastStandardBridge = _blastStandardBridge; registry = _registry; }
See: https://docs.blast.io/building/guides/gas-fees
Without explicitly enabling gas mode, you will not be able to claim the gas back
PPFS (Price Per Full Share) reduction would indicate that the Withdrawal Mechanism is leaking value against other user operations, this is the case in the current code
This invariant is currently safe, however, inaccuracies will cause loss of collateral (inability to withdraw ezETH) Or may cause insolvency of the Lockbox
No single token pair should allow for an immediate arbitrage
By trying various combinations of tokens in and out, you should ensure that no direct arbitrage can be achieved
It's important you consider any direct arbitrage as a potential risk as your system is factually giving away value to the caller
While unlikely, it is possible for new LSTs to get slashed below parity, this will cause the oracle to stop working
If you plan on adding a lot more LSTs you may want to allow them to be valued slightly below parity
This is a common issue in validating Chainlink Oracles, you should ensure that the value from the aggregator is not at the bound of min, and max
#0 - jatinj615
2024-05-20T13:42:34Z
Highlights the arbitrage issue with withdrawals. Mostly highlights the issues around Trusted Admin actions.
#1 - alcueca
2024-05-24T09:10:46Z
A few of these could have been HMs in the current contest, but the descriptions are so minimal that I don't think they deserve to be upgraded.
#2 - c4-judge
2024-05-24T09:11:01Z
alcueca marked the issue as grade-a