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: 97/122
Findings: 3
Award: $0.04
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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#L316-L321
The current code that calculates the value of totalWithdrawalQueueValue
incorrectly passes the same token to each call to lookupTokenValue()
with balances of the withdrawQueue
of other tokens, as the passed balance accesses collateralTokens[j].balanceOf(withdrawQueue)
rather than collateralTokens[i].balanceOf(withdrawQueue)
.
This will cause the calculated totalWithdrawalQueueValue
to be incorrect, which will cause the calculation of BalancerRateProvider::getRate()
to calculate the incorrect rate of ezETH
in ETH
which will cause protocol to utilise an incorrect price for ezETH
which can make the protocol mint too much ezETH
on deposits which will devalue ezETH
or to mint less ezETH
than intended and users will lose funds.
RestakeManager::calculateTVLs()
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) { ...SKIP!... // record token value of withdraw queue if (!withdrawQueueTokenBalanceRecorded) { totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( collateralTokens[i], collateralTokens[j].balanceOf(withdrawQueue) ); }
totalWithdrawalQueueValue
is calculated by summing the value of collateralTokens[j].balanceOf(withdrawQueue)
of each token collateralTokens[i]
however the balance being passed to renzoOracle.lookupTokenValue
is for collateralTokens[j]
rather than collateralTokens[i]
meaning the returned value will be incorrect.
RenzoOracle::lookupTokenValue()
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; }
As seen, lookUpTokenValue
is meant to be passed the token and the balance of the same token.
BalancerRateProvider::getRate()
function getRate() external view returns (uint256) { // Get the total TVL priced in ETH from restakeManager (, , uint256 totalTVL) = restakeManager.calculateTVLs(); // 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; }
getRate()
utilises the calculated TVL from restakeManager.calculateTVLs()
to calculate the exchange rate between ezETH
and ETH
. Therfore if the returned TVL is deflated due to the incorrect calculations mentioned above, the returned ezETH
exchange rate will be lower, which will lead to more ezETH
being minted. This will cause the the protocol and users to become diluted and their redeems of ezETH
in the future will redeem less ETH
than intended, which will lead to a loss of funds.
Manual Review
Make the following changes:
withdrawQueueTokenBalanceRecorded
variabletotalWithdrawalQueueValue
calculation outside of the nested j
loopcollateralTokens[j].balanceOf(withdrawQueue)
to collateralTokens[i].balanceOf(withdrawQueue)
.// flag for withdrawal queue balance set - bool withdrawQueueTokenBalanceRecorded = false; address withdrawQueue = address(depositQueue.withdrawQueue());
for (uint256 i = 0; i < odLength; ) { // Track the TVL for this OD uint256 operatorTVL = 0; // Track the individual token TVLs for this OD - native ETH will be last item in the array uint256[] memory operatorValues = new uint256[](collateralTokens.length + 1); operatorDelegatorTokenTVLs[i] = operatorValues; // Iterate through the tokens and get the value of each uint256 tokenLength = collateralTokens.length; for (uint256 j = 0; j < tokenLength; ) { // Get the value of this token uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy( collateralTokens[j] ); // Set the value in the array for this OD operatorValues[j] = renzoOracle.lookupTokenValue( collateralTokens[j], operatorBalance ); // Add it to the total TVL for this OD operatorTVL += operatorValues[j]; - // record token value of withdraw queue - if (!withdrawQueueTokenBalanceRecorded) { - totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( - collateralTokens[i], - collateralTokens[j].balanceOf(withdrawQueue) - ); - } unchecked { ++j; } } + totalWithdrawalQueueValue += renzoOracle.lookupTokenValue( + collateralTokens[i], + collateralTokens[i].balanceOf(withdrawQueue) + ); // Get the value of native ETH staked for the OD uint256 operatorEthBalance = operatorDelegators[i].getStakedETHBalance(); // Save it to the array for the OD operatorValues[operatorValues.length - 1] = operatorEthBalance; // Add it to the total TVL for this OD operatorTVL += operatorEthBalance; // Add it to the total TVL for the protocol totalTVL += operatorTVL; // Save the TVL for this OD operatorDelegatorTVLs[i] = operatorTVL; - // Set withdrawQueueTokenBalanceRecorded flag to true - withdrawQueueTokenBalanceRecorded = true; unchecked { ++i; } }
This now accesses the correct token and balance for that token.
Other
#0 - c4-judge
2024-05-16T10:25:48Z
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: 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#L542-L562
When funds are deposited using RestakeManager::deposit()
the current withdraw buffer is calculated to check if it needs to be refilled, bufferToFill
. If there is an amount that needs to be refilled then the deposit is used to fully or partiall refill the deposit (as much as the deposit funds allow). However if the _amount
that the user is depositing is less than bufferToFill
the whole _amount
will be used, setting _amount
to 0.
This will then be passed to operatorDelegator.deposit(_collateralToken, _amount)
which will revert if _amount
is 0. Meaning that all deposits of less than bufferToFill
will revert, breaking core functionality of the protocol.
User deposits can be used to refill buffer. If _amount
is less than the buffer then the full _amount
will be used, reducing _amount
to 0
.
uint256 bufferToFill = depositQueue.withdrawQueue().getBufferDeficit( address(_collateralToken) ); if (bufferToFill > 0) { bufferToFill = (_amount <= bufferToFill) ? _amount : bufferToFill; // update amount to send to the operator Delegator _amount -= bufferToFill;
// Approve the tokens to the operator delegator _collateralToken.safeApprove(address(operatorDelegator), _amount); // Call deposit on the operator delegator operatorDelegator.deposit(_collateralToken, _amount);
If 0
is passed to operatorDelegator.deposit()
the call will revert:
function deposit( IERC20 token, uint256 tokenAmount ) external nonReentrant onlyRestakeManager returns (uint256 shares) { 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); }
This will cause all deposits worth less than bufferToFill
to revert.
Manual Review
Ensure that if _amount
is 0
after refilling the buffer, then the approve and deposit calls are skipped to avoid 0 amount deposit attempts.
- // Approve the tokens to the operator delegator - _collateralToken.safeApprove(address(operatorDelegator), _amount); - // Call deposit on the operator delegator - operatorDelegator.deposit(_collateralToken, _amount); + if (_amount > 0) { + // Approve the tokens to the operator delegator + _collateralToken.safeApprove(address(operatorDelegator), _amount); + // Call deposit on the operator delegator + operatorDelegator.deposit(_collateralToken, _amount); + }
Error
#0 - c4-judge
2024-05-20T05:10:46Z
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
xRenzoDeposit.sol
utilises RenzoOracleL2::getMintRate()
to retrieve the price of ezETH
however the implementation of the staleness check differs between the two contracts, which means that a valid price from the oracle can result a revert in xRenzoDeposit.sol
.
// Verify the price is not stale if (block.timestamp > _lastPriceTimestamp + 1 days) { revert OraclePriceExpired(); }
The staleness check ensures that the _lastPriceStamp
is not older than 1 day, however in the RenzoOracleL2.sol
contract prices are only stale after 1 day + 60 seconds
, which can cause a valid price from the oracle to be unaccepted by this contract.
function getMintRate() public view returns (uint256, uint256) { (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData(); if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired(); // scale the price to have 18 decimals uint256 _scaledPrice = (uint256(price)) * 10 ** (18 - oracle.decimals()); if (_scaledPrice < 1 ether) revert InvalidOraclePrice(); return (_scaledPrice, timestamp); }
uint256 public constant MAX_TIME_WINDOW = 86400 + 60; // 24 hours + 60 seconds
As seen above, in RenzoOracleL2.sol
the price is only stale after 24 hours + 60 seconds, whilst in xRenzoDeposit.sol
a price is stale after 24 hours. This small difference can cause a valid to be rejected due to the implementation difference.
Manual Review
Ensure that staleness checks are consistent throughout the whole codebase to avoid issues with oracle prices being accepted in certain contracts and not in others.
Invalid Validation
#0 - c4-judge
2024-05-17T12:09:33Z
alcueca marked the issue as not a duplicate
#1 - jatinj615
2024-05-21T13:28:21Z
Will be having staleness check consistent throughout the L2 as 24 hours + 60 seconds
#2 - c4-judge
2024-05-23T08:23:18Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2024-05-24T09:35:51Z
alcueca marked the issue as primary issue
#4 - c4-judge
2024-05-24T10:22:33Z
alcueca marked the issue as selected for report
#5 - s1n1st3r01
2024-05-25T16:32:14Z
Hey @alcueca
Although I believe this is worth fixing for sure, I also strongly believe this is QA worthy rather a med, for the following reasons:
Sponsor (@jatinj615 ) mentioned numerous times that there are oracle updates every single hour. So the oracle trying to provide data that is as old as 24 hours or more is already extremely unlikely. That'd mean that the oracle is down and some disaster happened.
Even if an >24 hours oracle update gets sent, the chance of that update being between the timeframe 24:00:00
and 24:00:60
is quite unlikely, making the possibility of this bug occuring is even less and less
Maximum impact of this bug (if it even occurs) would be extremely low as the oracle updates are expected to be rejected starting at most 60 seconds later. So this is just an odd edge case and it doesn't matter that it reverts also here.
I also believe it isn't remotely as impactful as any medium severity bug in this contest so far.
Thanks!
#6 - 0xEVom
2024-05-26T20:17:07Z
Just to add here, I don't think there is an inherent need for the RenzoOracleL2
staleness threshold to match that of the xRenzoDeposit
contract. The oracle could be configured to consider data older than 24 hours stale and the deposit contract already after, say, 6 hours without it constituting a misconfiguration, and the protocol would still work as expected.
#7 - alcueca
2024-05-27T09:26:14Z
Yeah, Medium is a bit much for the impact
#8 - c4-judge
2024-05-27T09:27:04Z
alcueca changed the severity to QA (Quality Assurance)
#9 - c4-judge
2024-05-27T09:27:10Z
alcueca marked the issue as grade-b
#10 - c4-judge
2024-05-28T11:00:59Z
alcueca marked the issue as not selected for report