Era

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

User can send toke with fees

Summary

A vulnerability exists in the token bridging mechanism that allows transfer of non-standard tokens with fee-on-transfer functionality from Layer 1 (L1) to Layer 2 (L2), despite documentation stating such tokens are not supported. This issue can occur when transferring base tokens or using the `Bridgehub` for ERC20 token transfers.

Vulnerability Details

The L1AssetRouter transfers tokens to L1NativeTokenVault without verifying the exact balance after the transfer. Specifically:

  • The router returns depositChecked = true without confirming the transferred amount

  • The subsequent NativeTokenVault._bridgeBurnNativeToken method does not validate that the full intended amount has been deposited This means tokens with transfer fees can be bridged without proper accounting, potentially leading to discrepancies in token balances.

This means tokens with transfer fees can be bridged without proper accounting, potentially leading to discrepancies in token balances.


https://github.com/Cyfrin/2024-10-zksync/blob/b0c5565b6078a93cc3358a5d5012258caa701385/era-contracts/l1-contracts/contracts/bridge/ntv/L1NativeTokenVault.sol#L171

https://github.com/Cyfrin/2024-10-zksync/blob/b0c5565b6078a93cc3358a5d5012258caa701385/era-contracts/l1-contracts/contracts/bridge/ntv/NativeTokenVault.sol#L279

Impact

  • Bridge tokens with fee-on-transfer mechanisms, which is not supported by the protocol

  • Cause accounting discrepancies in the bridge

  • Manipulate the actual amount of tokens transferred across layers

Tools Used

Manual review.

Unit test.

Recommendations

In L1NativeTokenVault._bridgeBurnNativeToken do the following changes

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) {
console.log("L1NativeTokenVault:_bridgeBurnNativeToken");
if (!_depositChecked) {
IERC20 token = IERC20(tokenAddress[_assetId]);
uint balanceBefore = token.balanceOf(address(this));
uint256 _depositAmount;
(_depositAmount,) = abi.decode(_data, (uint256, address));
bool depositDone = IL1AssetRouter(address(ASSET_ROUTER)).transferFundsToNTV(
_assetId,
_depositAmount,
_originalCaller
);
if (depositDone) {
uint256 balanceAfter = token.balanceOf(address(this));
if (balanceAfter - balanceBefore != _depositAmount) {
revert TokensWithFeesNotSupported();
}
_depositChecked = true;
}
}
console.log("depositChecked", _depositChecked);
_bridgeMintData = super._bridgeBurnNativeToken({
_chainId: _chainId,
_assetId: _assetId,
_originalCaller: _originalCaller,
_depositChecked: _depositChecked,
_data: _data
});
}

PoC

In era-contracts/l1-contracts/contracts/dev-contracts/TestnetERC20Token.sol add the contract.

contract TestnetERC20TokenWithFee is TestnetERC20Token {
constructor(string memory name_, string memory symbol_, uint8 decimals_) TestnetERC20Token(name_, symbol_, decimals_) {
}
function transfer(address to, uint256 amount) public virtual override returns (bool) {
uint fee = amount / 10;
_burn(msg.sender, fee);
return super.transfer(to, amount - fee);
}
function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
uint fee = amount / 10;
_burn(from, fee);
return super.transferFrom(from, to, amount - fee);
}
}

In era-contracts/l1-contracts/test/foundry/l1/unit/concrete/Bridges/L1SharedBridge/L1SharedBridgeBase.t.sol add the two tests and update imports.

function test_bridgehubDepositBaseToken_ErcWithFee() public {
// setup token with fee as base token
TestnetERC20TokenWithFee tokenWithFee = new TestnetERC20TokenWithFee("TestnetERC20TokenWithFee", "TET", 18);
bytes32 tokenWithFeeAssetId = DataEncoding.encodeNTVAssetId(block.chainid, address(tokenWithFee));
vm.prank(address(nativeTokenVault));
nativeTokenVault.registerToken(address(tokenWithFee));
tokenWithFee.mint(alice, amount);
// only approve the asset router
vm.prank(alice);
tokenWithFee.approve(address(sharedBridge), amount);
vm.prank(bridgehubAddress);
// solhint-disable-next-line func-named-parameters
vm.expectEmit(true, true, true, true, address(sharedBridge));
emit BridgehubDepositBaseTokenInitiated(chainId, alice, tokenWithFeeAssetId, amount);
sharedBridge.bridgehubDepositBaseToken(chainId, tokenWithFeeAssetId, alice, amount);
assertEq(tokenWithFee.balanceOf(alice), 0);
uint amountMinusFee = amount - (amount / 10);
assertEq(tokenWithFee.balanceOf(address(nativeTokenVault)), amountMinusFee);
}
function test_bridgehubDeposit_ErcWithFee() public {
// setup token with fee as base token
TestnetERC20TokenWithFee tokenWithFee = new TestnetERC20TokenWithFee("TestnetERC20TokenWithFee", "TET", 18);
bytes32 tokenWithFeeAssetId = DataEncoding.encodeNTVAssetId(block.chainid, address(tokenWithFee));
vm.prank(address(nativeTokenVault));
nativeTokenVault.registerToken(address(tokenWithFee));
tokenWithFee.mint(alice, amount);
// only approve the asset router
vm.prank(alice);
tokenWithFee.approve(address(sharedBridge), amount);
bytes memory transferData = abi.encode(amount, bob);
bytes memory depositData = bytes.concat(NEW_ENCODING_VERSION, abi.encode(tokenWithFeeAssetId, transferData));
bytes memory erc20Metadata = nativeTokenVault.getERC20Getters(address(tokenWithFee), block.chainid);
bytes32 txDataHash = DataEncoding.encodeTxDataHash({
_nativeTokenVault: address(nativeTokenVault),
_encodingVersion: NEW_ENCODING_VERSION,
_originalCaller: alice,
_assetId: tokenWithFeeAssetId,
_transferData: transferData
});
bytes memory bridgeMintCalldata = DataEncoding.encodeBridgeMintData({
_originalCaller: alice,
_remoteReceiver: bob,
_originToken: address(tokenWithFee),
_amount: amount,
_erc20Metadata: erc20Metadata
});
vm.prank(bridgehubAddress);
vm.expectEmit(true, false, true, false, address(sharedBridge));
emit BridgehubDepositInitiated(chainId, txDataHash, alice, tokenWithFeeAssetId, bridgeMintCalldata);
sharedBridge.bridgehubDeposit(chainId, alice, 0, depositData);
assertEq(tokenWithFee.balanceOf(alice), 0);
uint amountMinusFee = amount - (amount / 10);
assertEq(tokenWithFee.balanceOf(address(nativeTokenVault)), amountMinusFee);
}

Run tests

forge test --match-test test_bridgehubDepositBaseToken_ErcWithFee -vvvv
forge test --match-test test_bridgehubDeposit_ErcWithFee -vvvv
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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.