User initiates the process by depositing ETH/wETH into the xRenzoDeposit.sol contract particularly using the function xRenzoDeposit::deposit() or the function xRenzoDeposit::depositETH(). If the user deposited ETH using the latter function, the ETH will then be wrapped into wETH.
xRenzoDeposit::_deposit() function will then be called, and it will do three things:
Retrieve the bridge fee for the amount you deposited, using the function xRenzoDeposit::getBridgeFeeShare() and it'll subtract it from the amountIn (the amount you deposited).
It'll then utilize Connext's swapping feature to swap the ETH or wETH you deposited to nextWETH.
It'll then retrieve the price and last price timestamp of ezETH from the RenzoOracleL2.sol and calculate how much xezETH (XERC20) to mint you, and then it'll mint you that amount of xezETH.
The resulted nextWETH from the swap will remain in the xRenzoDeposit contract for now.
Periodically, the xRenzoDeposit::sweep() function will be called by an admin. This function simply takes the balance of nextWETH in the contract and bridges it down to the L1.
After the bridging occurs and we're now in L1, the function xRenzoBridge::xReceive() will be triggered. It'll simply unwrap the received nextWETH to ETH and then deposit it into the RestakeManager using the function RestakeManager::depositETH()
xRenzoBridge will then burn the amount of xezETH tokens minted since they were already minted on L2.
The vulnerability
The Connext documentation discusses how a receiver contract should be created. It explicitly mentions that any contract implementing the IXReceive interface, should be built defensively and any revert-susceptible code should always be handled properly. From the docs:
If the call on the receiver contract (also referred to as "target" contract) reverts, funds sent in with the call will end up on the receiver contract.
Ultimately, the goal should be to handle any revert-susceptible code and ensure that the logical owner of funds always maintains agency over them.
The xRenzoBridge::xReceive() function does not wrap the restakeManager.depositETH{value:ethAmount}() call in a try/catch block, which is dangerous given that the function RestakeManager::depositETH() can revert under certain circumstances.
function xReceive(
bytes32 _transferId,
uint256 _amount,
address _asset,
address _originSender,
uint32 _origin,
bytes memory
) external nonReentrant returns (bytes memory) {
...............
// Get the amonut of ezETH before the deposit
uint256 ezETHBalanceBeforeDeposit = ezETH.balanceOf(address(this));
// Deposit it into Renzo RestakeManager
------> restakeManager.depositETH{ value: ethAmount }();
// Get the amount of ezETH that was minted
uint256 ezETHAmount = ezETH.balanceOf(address(this)) - ezETHBalanceBeforeDeposit;
................
function depositETH(uint256 _referralId) public payable nonReentrant notPaused {
// Get the total TVL
(, , uint256 totalTVL) = calculateTVLs();
// Enforce TVL limit if set
------> if (maxDepositTVL != 0 && totalTVL + msg.value > maxDepositTVL) {
revert MaxTVLReached();
}
// Deposit the remaining ETH into the DepositQueue
depositQueue.depositETHFromProtocol{ value: msg.value }();
.............
Looking at RestakeManager::depositETH(), it is evident that it can revert anytime xRenzoBridge tries to deposit an amount of ETH which would exceed the maxDepositTVL threshold.
Impact
When that happens, there are several consequences:
The continued execution flow in L1 will fail silently. The admin calling sweep() won't immediately notice that this has failed in L1.
The bridged WETH from L2 -> L1 will be stuck in the L1 xRenzoBridge contract.
The minted xezETH on L2 will become insolvent, as a corresponding amount to be redeemed for ezETH will not be minted on L1.
Tools Used
Manual Review
Recommended Mitigation Steps
Wrap the restakeManager.depositETH{}() call in a try/catch block and handle any potential reverts.
Lines of code
https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/Bridge/L1/xRenzoBridge.sol#L175 https://github.com/code-423n4/2024-04-renzo/blob/main/contracts/RestakeManager.sol#L598
Vulnerability details
How L2 Native Restaking in Renzo works
xRenzoDeposit.sol
contract particularly using the functionxRenzoDeposit::deposit()
or the functionxRenzoDeposit::depositETH()
. If the user deposited ETH using the latter function, the ETH will then be wrapped into wETH.xRenzoDeposit::_deposit()
function will then be called, and it will do three things:xRenzoDeposit::getBridgeFeeShare()
and it'll subtract it from theamountIn
(the amount you deposited).nextWETH
.ezETH
from theRenzoOracleL2.sol
and calculate how muchxezETH
(XERC20) to mint you, and then it'll mint you that amount ofxezETH
.nextWETH
from the swap will remain in thexRenzoDeposit
contract for now.xRenzoDeposit::sweep()
function will be called by an admin. This function simply takes the balance ofnextWETH
in the contract and bridges it down to the L1.xRenzoBridge::xReceive()
will be triggered. It'll simply unwrap the receivednextWETH
to ETH and then deposit it into theRestakeManager
using the functionRestakeManager::depositETH()
xRenzoBridge
then sends the minted ezETH tokens to the Lockbox contract to be wrapped.xRenzoBridge
will then burn the amount ofxezETH
tokens minted since they were already minted on L2.The vulnerability
The Connext documentation discusses how a receiver contract should be created. It explicitly mentions that any contract implementing the
IXReceive
interface, should be built defensively and any revert-susceptible code should always be handled properly. From the docs:The
xRenzoBridge::xReceive()
function does not wrap therestakeManager.depositETH{value:ethAmount}()
call in a try/catch block, which is dangerous given that the functionRestakeManager::depositETH()
can revert under certain circumstances.xRenzoBridge::xReceive()
:RestakeManager::depositETH()
:Looking at
RestakeManager::depositETH()
, it is evident that it can revert anytimexRenzoBridge
tries to deposit an amount of ETH which would exceed themaxDepositTVL
threshold.Impact
When that happens, there are several consequences:
L1
will fail silently. The admin callingsweep()
won't immediately notice that this has failed in L1.WETH
from L2 -> L1 will be stuck in the L1xRenzoBridge
contract.xezETH
on L2 will become insolvent, as a corresponding amount to be redeemed forezETH
will not be minted on L1.Tools Used
Manual Review
Recommended Mitigation Steps
Wrap the
restakeManager.depositETH{}()
call in a try/catch block and handle any potential reverts.Assessed type
Other