Lines of code
ttps://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491-L576
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L664-L665
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Deposits/DepositQueue.sol#L134-L145
https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/Bridge/xERC20/contracts/XERC20Lockbox.sol#L125-L152
Vulnerability details
Proof of Concept
NB: This bug report contains two sides of the coin on how the 1-2 wei corner case
problem could affect renzo, 50% builds on the OOS safeApprove()
from the bug report, however the second part of the report is in scope.
First, would be key to note that stETH
is a special token when it comes to it's transfer logic, navigating to lido's official docs we can see that there is a special section that talks about it's unique concept, i.e the "1-2 wei corner case", see https://docs.lido.fi/guides/lido-tokens-integration-guide/#1-2-wei-corner-case, quoting them:
stETH balance calculation includes integer division, and there is a common case when the whole stETH balance can't be transferred from the account while leaving the last 1-2 wei on the sender's account. The same thing can actually happen at any transfer or deposit transaction. In the future, when the stETH/share rate will be greater, the error can become a bit bigger. To avoid it, one can use transferShares
to be precise.
That's to say at any transfer tx there is a possibility that the amount that actually gets sent is up to 2 wei
different, a minute value you might think, however when we couple this with the fact that protocol heavily uses safeApprove()
to pass on approvals for collateral tokens before depositing or withdrawing, this corner case could then brick the protocol.
Now see OpenZeppelin's implementation of safeApprove()
and how it will revert if the current allowance is non-zero and the approval attempt is also passing a non-zero value.
function safeApprove(
IERC20 token,
address spender,
uint256 value
) internal {
// safeApprove should only be called when setting an initial allowance,
// or when resetting it to zero. To increase and decrease it, use
// 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
require(
//@audit
(value == 0) || (token.allowance(address(this), spender) == 0),
"SafeERC20: approve from non-zero to non-zero allowance"
);
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
}
Consider a minimalistic generic scenario:
-
Allowance is set by user A for user B to 1e18
"wei" stETH tokens.
-
User B attempts to transfer these tokens, however due to the corner case, stETH balance gets converted to shares, integer division happens and rounding down applies, the amount of tokens that are actually transferred would be 1e18 - 2
"wei" tokens.
-
Now user A assumes that user A has expended their allowance and attempts granting them a new allowance of a fresh 1e18
"wei" stETH tokens, doing this with the normal ERC20.approve()
is going to go through, however user A attempts to do this with SafeERC20.safeApprove()
which would fail cause SafeERC20.safeApprove()
reverts on non-zero to non-zero approvals and user B is currently being approved of 2
wei tokens which they've not spent yet.
More can be read on the "1-2 wei corner case" issue from here: lidofinance/lido-dao#442
The scenario above is quite generic but this exact idea can be applied to current protocol's logic of passing on allowances around contracts in scope, for example see RestakeManager.deposit()
at https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L491-L576
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);
// Enforce TVL limit if set, 0 means the check is not enabled
if (maxDepositTVL != 0 && totalTVL + collateralTokenValue > maxDepositTVL) {
revert MaxTVLReached();
}
// Enforce individual token TVL limit if set, 0 means the check is not enabled
if (collateralTokenTvlLimits[_collateralToken] != 0) {
// Track the current token's TVL
uint256 currentTokenTVL = 0;
// For each OD, add up the token TVLs
uint256 odLength = operatorDelegatorTokenTVLs.length;
for (uint256 i = 0; i < odLength; ) {
currentTokenTVL += operatorDelegatorTokenTVLs[i][tokenIndex];
unchecked {
++i;
}
}
// Check if it is over the limit
if (currentTokenTVL + collateralTokenValue > collateralTokenTvlLimits[_collateralToken])
revert MaxTokenTVLReached();
}
// Determine which operator delegator to use
IOperatorDelegator operatorDelegator = chooseOperatorDelegatorForDeposit(
operatorDelegatorTVLs,
totalTVL
);
// Transfer the collateral token to this address
_collateralToken.safeTransferFrom(msg.sender, address(this), _amount);
// Check the withdraw buffer and fill if below buffer target
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;
// safe Approve for depositQueue @audit
_collateralToken.safeApprove(address(depositQueue), bufferToFill);
// fill Withdraw Buffer via depositQueue
depositQueue.fillERC20withdrawBuffer(address(_collateralToken), bufferToFill);
}
//@audit
// Approve the tokens to the operator delegator
_collateralToken.safeApprove(address(operatorDelegator), _amount);
// Call deposit on the operator delegator
operatorDelegator.deposit(_collateralToken, _amount);
// Calculate how much ezETH to mint
uint256 ezETHToMint = renzoOracle.calculateMintAmount(
totalTVL,
collateralTokenValue,
ezETH.totalSupply()
);
// Mint the ezETH
ezETH.mint(msg.sender, ezETHToMint);
// Emit the deposit event
emit Deposit(msg.sender, _collateralToken, _amount, ezETHToMint, _referralId);
}
As hinted by the two @Audit tags, protocol uses safeApprove()
to grant approval in this case to both the depositQueue
and the operator delegator, note that the implementations of both operatorDelegator.deposit() and depositQueue.fillERC20withdrawBuffer() include transfers of the allowances they've already been given from the execution of RestakeManager.deposit()
considering the transfers is going to get rounded down, then minute part of the allowance is going to be left untransferred and in consequent calls to safeApprove()
when depositing stETH
the call to safeApprove
is going revert (as shown in the attached openZeppelin's safeApprove()
snippet above) and throw an error since an attempt is being made to approve from a non-zero to a non-zero value, effectively bricking/DOS'ing the depositing logic for the supported stETH
collateral token.
Now, parallel to the already explained issue with safeApprovals
, this 1-2
wei corner case is going to also cause protocol to make a wrong assumption on the amount of tokens that were really transferred, would be key to note that the amount of ezETH
that get minted to the msg.sender
is directly proportional to the collateral value of the amount
of tokens that were considered to be "transferred" in to the RestakeManager
during the deposit attempt.
Evidently, since during deposits, protocol assumes the amount of tokens that was specified in the safeTransferFrom()
is actually the amount of tokens that end up getting transferred in, the amount of ezETH
that gets minted for users is going to be inflated as more than what was transferred in is going to be considered as the amount
transferred in when calculating the collateral token being deposited https://github.com/code-423n4/2024-04-renzo/blob/1c7cc4e632564349b204b4b5e5f494c9b0bc631d/contracts/RestakeManager.sol#L506-L508
// Get the value of the collateral token being deposited
uint256 collateralTokenValue = renzoOracle.lookupTokenValue(_collateralToken, _amount);
The logic from the last two paragraphs hint that the stETH
token does somewhat behave like the popular Fee-On-Transfertokens , albeit in this case the discrepancy in the amount of tokens being recieved is due to rounding down and not fees, also this could lead to the depositing/withdrawing logic of the XERC20Lockbox
to also work with flawed data.
NB: This report hints other instances where protocol's logic could be flawed due to not considering the transfer logic attached to stETH
, however the report mainly focuses on only RestakeManager#deposit()
&RestakeManager#depositTokenRewardsFromProtocol()
as these instances are enough to prove the bug case (keep in mind that this function is always queried whenever there is a need to sweep any accumulated ERC20 tokens in the DepositQueue to the RestakeManager, other instances still exist in scope however, like depositQueue.fillERC20withdrawBuffer()
& operatorDelegator.deposit()
, but in short this bug case can be applied to all instances where protocol attempts to query safeApprove()
on the collateral token as can be pinpointed using this search command, i.e the DepositQueue#fillERC20withdrawBuffer() could now encounter a failure making it impossible fill the up the stETH
buffer in the withdraw queue, extensively this subtly affects all the collateral token transfer logic too.
Impact
This bug cases leads to multiple issues and the root cause is the fact that protocol does not take the 1-2 wei corner case of stETH
into mind, a few noteworthy impacts would be:
-
When the allowance of the supported stETH
token is non-zero in instances where protocol thinks it's already zero, all attempts to safeApprove()
on this collateral token (stETH) is going to fail DOSing the depositing attempts, making protocol's core functionality unavailable to some users.
-
Additionally, the accounting of the backed collateral for minted ezETH could now be flawed since it's going to assume the wrong amount of collaterals are backing already minted assets which covers the main window under the requested bug windows/attack ideas since the integrity on the TVL calculations (ezETH Pricing) is now going to be slightly flawed, i.e users are now going to mint & withdraw at slightly invalid prices considering the stETH
is a core integrated token and with multiple transfers this minute differences could amount to quite a reasonable sum.
-
So, this means that depositing into the strategy manager for the stETH
collateral token would also be broken.
-
This bug case also makes it impossible to complete queued withdrawals for stETH
from OperatorDelegator.sol
, since the channel is going to be DOS'd when the residual amount of approval already made to deposit queue causes this attempt at a new approval to fail, showcasing how the withdrawal channel is also going to be DOS'd.
-
Another subtle one, would be the Inability to fill up the withdrawal queue buffer when needed for the stETH
token, (albeit in this case as hinted by protocol the admins can manually unstake to ensure the buffer is at where it needs to be).
-
Finally, there seems to be a subtle edge case, where, if every user is to attempt withdrawing their deposited assets back, the last set of users might not receive their assets it due to protocol not having enough assets backed for ezETH already minted to send to back to users.
And extensively many more ways where/how this bug case could impact protocol, just depends on the context in which safeApprove()
is being applied or instances where an assumption is being made that the amount specified in the transfer is actually what's been received.
Recommended Mitigation Steps
First consider scraping the idea of safeApprove()
for stETH
, since the all assets being supported right now (ezETH, stETH, wBETH) are standard tokens then the normal ERC20.approve()
can be used which wouldn't revert on non-zero to non-zero approvals.
For the parallel case in regards to the amount
specified in the transferral attempt not being the amount that gets transferred in, then the differences in balance could be used to see the real amount that was transferred in and then use this value to calculate the amount of ezETH
to be minted.
Alternatively, protocol can just consider integrating wstETH
instead of stETH
as suggested by the official Lido docs for more ease in regards to DEFI integration, see how this can be done here.
Assessed type
Token-Transfer