A signature replay attack happens when a valid signature is reused multiple times, even though the signer only intended to approve ONE transaction.
Think of it like this:
You sign a check for $100 to your friend
Your friend photocopies the check
Your friend cashes it 10 times
You lose $1,000 instead of $100! 💸
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/*
Alice signs a message off-chain allowing Bob to withdraw 1 ETH
But Bob can reuse the same signature multiple times to drain Alice's account!
*/
contract VulnerableBank {
mapping(address => uint) public balances;
constructor() payable {}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// ❌ VULNERABLE: No replay protection
function withdraw(
address _from,
address _to,
uint _amount,
bytes memory _signature
) public {
// Verify signature
bytes32 messageHash = getMessageHash(_from, _to, _amount);
bytes32 ethSignedHash = getEthSignedMessageHash(messageHash);
require(recoverSigner(ethSignedHash, _signature) == _from, "Invalid signature");
require(balances[_from] >= _amount, "Insufficient balance");
// Transfer funds
balances[_from] -= _amount;
balances[_to] += _amount;
}
function getMessageHash(address _from, address _to, uint _amount)
public pure returns (bytes32)
{
return keccak256(abi.encodePacked(_from, _to, _amount));
}
function getEthSignedMessageHash(bytes32 _messageHash)
public pure returns (bytes32)
{
return keccak256(abi.encodePacked("\\\\x19Ethereum Signed Message:\\\\n32", _messageHash));
}
function recoverSigner(bytes32 _ethSignedHash, bytes memory _signature)
public pure returns (address)
{
(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
return ecrecover(_ethSignedHash, v, r, s);
}
function splitSignature(bytes memory sig)
public pure returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "Invalid signature length");
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
}
}
// Alice wants to allow Bob to withdraw 1 ETH ONCE
const message = ethers.utils.solidityKeccak256(
['address', 'address', 'uint256'],
[alice.address, bob.address, ethers.utils.parseEther('1')]
);
// Alice signs the message
const signature = await alice.signMessage(ethers.utils.arrayify(message));
/*
Alice's signature:
0x1a2b3c4d5e6f... (65 bytes)
Alice thinks: "This allows Bob to withdraw 1 ETH one time"
*/
Alice → Bob: "Here's my signature, withdraw 1 ETH"
Signature contents:
├─ From: Alice (0xAlice...)
├─ To: Bob (0xBob...)
├─ Amount: 1 ETH
└─ Valid: ✅ Alice signed it
Alice expects: Bob withdraws 1 ETH, done!
// Bob uses the signature
await bank.withdraw(
alice.address, // from
bob.address, // to
parseEther('1'), // amount
signature // Alice's signature
);
// ✅ Success! Bob gets 1 ETH
Alice's Balance:
Before: 10 ETH
After: 9 ETH ✅ (Expected)
// Bob calls the SAME function with the SAME signature again!
await bank.withdraw(
alice.address, // from (same)
bob.address, // to (same)
parseEther('1'), // amount (same)
signature // SAME SIGNATURE! ❌
);
// ✅ Success AGAIN! Bob gets another 1 ETH!
Alice's Balance:
Before: 9 ETH
After: 8 ETH ❌ (Not expected!)