Era

ZKsync
FoundryLayer 2
500,000 USDC
View results
Submission Details
Severity: medium
Valid

Checks in `_depositFunds` can be bypassed, causing tokens to not be backed 1 to 1 in the bridge

Summary

When using Bridgehub.sol's requestL2TransactionDirect or requestL2TransactionTwoBridges, it often leads to the function _depositFunds in the NTV.

function _depositFunds(address _from, IERC20 _token, uint256 _amount) internal virtual returns (uint256) {
uint256 balanceBefore = _token.balanceOf(address(this));
// slither-disable-next-line arbitrary-send-erc20
_token.safeTransferFrom(_from, address(this), _amount);
uint256 balanceAfter = _token.balanceOf(address(this));
return balanceAfter - balanceBefore;
}

As shown in the code snippet from the codebase, balanceAfter - balanceBefore is returned and then cross-checked in NativeTokenVault.sol's _bridgeBurnNativeToken :

function _bridgeBurnNativeToken(
uint256 _chainId,
bytes32 _assetId,
address _originalCaller,
bool _depositChecked,
bytes calldata _data
) internal virtual returns (bytes memory _bridgeMintData) {
....
if (_assetId == BASE_TOKEN_ASSET_ID) {
...
} else {
...
_handleChainBalanceIncrease(_chainId, _assetId, amount, true);
if (!_depositChecked) {
uint256 expectedDepositAmount = _depositFunds(_originalCaller, IERC20(nativeToken), _depositAmount);
// The token has non-standard transfer logic
--> if (amount != expectedDepositAmount) {
revert TokensWithFeesNotSupported();
}
}
}
....
}

Hence, the change in balance is cross-checked with what the amount the user initially requested when calling bridgehub.

However, if you follow through the sequence of function from bridgehub to _depositFunds, there's actually another way the user can deposit funds, without triggering _depositFunds.

For context the sequence is:

  • bridgehub.requestL2TransactionDirect() -> L1AssetRouter.bridgehubDepositBaseToken() -> NativeTokenVault.bridgeBurn()

Then after that bridgeBurn calls _bridgeBurnNativeToken. The _bridgeBurnNativeToken in L1NativeTokenVault.sol overwrites the original virtual one in NativeTokenVault.sol. The code is as follows:

function _bridgeBurnNativeToken(
uint256 _chainId,
bytes32 _assetId,
address _originalCaller,
// solhint-disable-next-line no-unused-vars
bool _depositChecked,
bytes calldata _data
) internal override returns (bytes memory _bridgeMintData) {
uint256 _depositAmount;
(_depositAmount, ) = abi.decode(_data, (uint256, address));
bool depositChecked = IL1AssetRouter(address(ASSET_ROUTER)).transferFundsToNTV(
_assetId,
_depositAmount,
_originalCaller
);
_bridgeMintData = super._bridgeBurnNativeToken({
_chainId: _chainId,
_assetId: _assetId,
_originalCaller: _originalCaller,
_depositChecked: depositChecked,
_data: _data
});
}

Since, we can see that in the original virtual _bridgeBurnNativeToken code snippet pasted further up above that _depositFunds is not called if depositChecked is true.

Hence bool depositChecked = IL1AssetRouter(address(ASSET_ROUTER)).transferFundsToNTV() is the line to focus on.

Taking a look at L1AssetRouter's transferFundsToNTV

function transferFundsToNTV(
bytes32 _assetId,
uint256 _amount,
address _originalCaller
) external onlyNativeTokenVault returns (bool) {
address l1TokenAddress = INativeTokenVault(address(nativeTokenVault)).tokenAddress(_assetId);
if (l1TokenAddress == address(0) || l1TokenAddress == ETH_TOKEN_ADDRESS) {
return false;
}
IERC20 l1Token = IERC20(l1TokenAddress);
// Do the transfer if allowance to Shared bridge is bigger than amount
// And if there is not enough allowance for the NTV
bool weCanTransfer = false;
if (l1Token.allowance(address(legacyBridge), address(this)) >= _amount) {
_originalCaller = address(legacyBridge);
weCanTransfer = true;
} else if (
l1Token.allowance(_originalCaller, address(this)) >= _amount &&
l1Token.allowance(_originalCaller, address(nativeTokenVault)) < _amount
) {
weCanTransfer = true;
}
if (weCanTransfer) {
// slither-disable-next-line arbitrary-send-erc20
--> l1Token.safeTransferFrom(_originalCaller, address(nativeTokenVault), _amount);
--> return true;
}
return false;
}

We can see that the balance after is not checked like how _depositFunds checks it, and it just simply returns true without checking that exactly the amount has been transferred.

Impact no. 1

There are a few common tokens that accepts type(uint256).max passed into the transfer parameters to transfer all of the user's balance. Other than that, it behaves normally just like a standard ERC20 token, meaning there are no fee on transfer when transferring.

So if a user sends bridgehub.requestL2TransactionDirect or bridgehub.requestL2TransactionTwoBridges(if the token isnt the base token of a chain) and passes in type(uint256).max as the requested mint value on L2, the transaction will go through without reverting. (As transferFundsToNTV will just end up transferring the user's balance to the bridge without reverting)

This is problematic as

  1. _handleChainBalanceIncrease will cause the chainBalance[_chainId][_assetId] to become type(uint256).max and anymore further deposits of that token will revert permanently

  2. Request sent to the L2 would be to mint type(uint256).max tokens for that user which breaks the 1:1 backed invariant of the bridge. Also the user can just use their new increased balance to carry out trades or transactions in the L2 since the token is a legit token on L1

Impact no. 2

Since the protocol's bridge norm invariant is to back tokens bridged to L2 in a 1:1 ratio, it prevents fee on transfer tokens from being bridged.

Being an ecosystem where any user can create chains, the code also actively seeks to prevent the use of fee on transfer tokens from being added and bridged. This can be seen in _depositFunds where the code reverts with revert TokensWithFeesNotSupported() when amount != expectedDepositAmount.

So if users approve their funds to L1AssetRouter instead of the NTV, transferFundsToNTV will run the deposit instead of _depositFunds, bypassing the revert for fee on transfer tokens, allowing it to happen.

The result of that would be the user having more tokens on L2 than they should have (since a part of it was already lost during the transfer to the bridge for locking)

Recommendation

Require that balanceAfter - balanceBefore == amount requested to be minted on L2 in transferFundsToNTV

Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Checks in `_depositFunds` can be bypassed, causing tokens to not be backed 1 to 1 in the bridge

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.