Signature replay
A signature is just data. Once it is on-chain, anyone can read it and submit it again. If your contract does not track which signatures it has already honoured, and does not bind them to itself and to this chain, the same signature can be replayed until the funds run out.
Vulnerable code
function claim(
address to,
uint256 amt,
bytes memory sig
) external {
bytes32 h = keccak256(abi.encodePacked(to, amt));
require(_recover(h, sig) == signer, "bad signature");
token.transfer(to, amt); // no nonce, no domain
}
Why it breaks
Three separate failures. There is no nonce, so the same signature can be submitted repeatedly to claim the amount over and over. There is no contract address in the hash, so a signature intended for one deployment works on another. And there is no chain ID, so a signature from mainnet is valid on every fork and testnet. On top of that, abi.encodePacked with dynamic types can produce hash collisions between different inputs.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
mapping(address => uint256) public nonces;
bytes32 private immutable DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256("InverseZeroClaim"),
block.chainid,
address(this)
));
}
function claim(address to, uint256 amt, bytes memory sig) external {
bytes32 structHash = keccak256(abi.encode(
CLAIM_TYPEHASH, to, amt, nonces[to]++
));
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
require(ECDSA.recover(digest, sig) == signer, "bad signature");
token.safeTransfer(to, amt);
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Nonces strictly increase per signer, and no signed digest is ever accepted twice.
invariant nonce(signer, t) > nonce(signer, t-1) invariant digest not in seen_digests invariant block.timestamp <= deadline
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testSignatureCannotBeReplayed() public {
bytes memory sig = _sign(alice, 100e18, 0);
airdrop.claim(alice, 100e18, sig); // succeeds
vm.expectRevert("bad signature");
airdrop.claim(alice, 100e18, sig); // nonce consumed
}
Where it has caused real losses
Cross-chain replay became a practical concern after the Ethereum merge, when signatures valid on mainnet were also valid on the proof-of-work fork. Contracts that omitted block.chainid from their domain separator allowed messages signed for one chain to be executed on the other.
What we check during review
Also check signature malleability: for any valid ECDSA signature there is a second valid form with a flipped s value. If you use the signature bytes themselves as a replay key, an attacker submits the malleable twin and bypasses your check. OpenZeppelin's ECDSA library rejects the high-s form; use it rather than calling ecrecover directly.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission