Signature Replay Attacks, Why 'Looks Correct' Can Be a DeFi Disaster
Smart contract bugs don't always scream at you; some hide in plain sight, like signature replay attacks that exploit seemingly correct code, leadin...
Signature Replay Attacks, Why 'Looks Correct' Can Be a DeFi Disaster
One of the subtlest and most dangerous bugs in smart contract development is the signature replay attack. It's insidious because the code often 'looks correct' on a first, second, or even third pass. You've got the ecrecover working, the signature verification looks solid, but then someone loses funds because an old signature gets reused.
Let's break down why this happens and how to prevent it, speaking from the trenches where these kinds of hidden logic flaws bite hard.
The Anatomy of a Signature Replay
At its core, a signature replay attack involves an attacker capturing a valid, signed message intended for one transaction and then resubmitting it for another. The contract, doing its due diligence, verifies the signature against the message and sender, finds it valid, and executes the transaction again.
This isn't about breaking cryptography. ecrecover works fine. The issue is context and uniqueness.
Imagine a scenario where a user signs a message to approve a token transfer by an off-chain relayer. The message might look like this:
bytes32 messageHash = keccak256(abi.encodePacked(
msg.sender,
spenderAddress,
amount
));
The user signs this messageHash. The relayer gets the signature and the original parameters, then calls a contract function like transferWithSignature(address sender, address spender, uint256 amount, bytes memory signature).
Inside transferWithSignature, the contract reconstructs the messageHash using sender, spender, and amount. Then it uses ecrecover to get the signer. If the signer matches sender, the transfer goes through.
Here's the replay vulnerability: the messageHash itself doesn't contain anything that makes it unique to a single execution. The same sender, spender, and amount will always produce the same messageHash and thus verify against the same signature. An attacker can simply intercept the transaction, or even just observe it on-chain, and then resubmit it, executing the transfer again with the same signature.
The Missing Piece: Nonce and Chain ID
The fix is straightforward but critical: introduce uniqueness into the signed message.
- Nonce (Number Used Once): The most common and robust solution. A nonce is a number that is incremented with every successful signature submission by a given user for a specific type of action. When generating the
messageHash, this nonce is included. The contract then verifies that the provided nonce hasn't been used yet for thatsender. After successful execution, the contract marks that nonce as used (e.g., in amapping(address => mapping(uint256 => bool)) public usedNonces;).
bytes32 messageHash = keccak256(abi.encodePacked(
msg.sender,
nonce,
spenderAddress,
amount
));
// ... later in the function ...
require(!usedNonces[signer][nonce], "Nonce already used");
usedNonces[signer][nonce] = true;
This ensures that even if all other parameters are identical, a different nonce is required for each new signing, making replays impossible.
- Chain ID: Another subtle vulnerability arises when a signature created on one chain (e.g., Ethereum Mainnet) is replayed on another (e.g., a testnet, or even a different EVM-compatible chain like Arbitrum or Polygon). If the
messageHashdoesn't include thechainid, a signature valid on one chain might also be valid on another, potentially leading to funds being moved on an unintended chain.
EIP-155 introduced the concept of including chainid in the signature process itself for transactions, but when you're signing arbitrary messages off-chain, you must explicitly include chainid in your messageHash if the signature is intended to be chain-specific.
bytes32 messageHash = keccak256(abi.encodePacked(
block.chainid,
msg.sender,
nonce,
spenderAddress,
amount
));
The current block.chainid is then checked against the chainid used in the signed message.
Why it's easy to miss
When you're building out a new feature that uses off-chain signatures, the immediate goal is just to get ecrecover to work and verify the sender. The security implication of msg.sender not being the one who initiated the true transaction and the replayability often gets overlooked in earlier design phases, especially if you're porting patterns from traditional web development where a POST request payload doesn't inherently carry replay risk in this way.
Adding nonces and chainid costs a little gas and requires a bit more state management, but it's cheap insurance. I've seen teams get burned by this, not because they were sloppy, but because the bug hides in the assumptions about message uniqueness across time and chains. Always assume a malicious actor will try to reuse anything you give them.