Replay Attacks & Signature Malleability
Signatures are the backbone of identity in EVM blockchains. But if you don't handle them correctly, an attacker can replay a signature on a different chain, or malleate it to produce a valid but different signature. This article covers the essential patterns.
1. Cross‑Chain Replay
The most common replay attack: a signature that authorises an action on one chain (e.g., Ethereum mainnet) is re‑submitted on another chain (e.g., Polygon, Optimism, Goerli).
// ❌ VULNERABLE — no chain identifier
function permit(address user, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
bytes32 digest = keccak256(abi.encode(
"Permit", user, amount, deadline
));
// ... recover & verify
// This digest is the same on every chain!
}
Mitigation: Always include the chain ID and the contract address in the signed message.
// ✅ SAFE — EIP-712 with chainId
function permit(...) external {
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
PERMIT_TYPEHASH, user, amount, nonces[user]++, deadline
)));
// _hashTypedDataV4 includes domainSeparator = keccak256(abi.encode(
// EIP712_DOMAIN_TYPEHASH, name, version, block.chainid, address(this)
// ));
// ...
}
2. ECDSA Signature Malleability
Every ECDSA signature (r, s, v) has a malleable twin: (r, N - s, 27 ^ 1). If the contract doesn't check the s range, an attacker can create a different signature that also recovers to the same signer.
// ❌ VULNERABLE — accepts any s value
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
return ecrecover(hash, v, r, s);
}
// ✅ SAFE — enforce low s
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"Signature: invalid s");
require(v == 27 || v == 28, "Signature: invalid v");
return ecrecover(hash, v, r, s);
}
Why it matters: If you use signatures as identifiers (e.g., in a mapping), a malleated signature would be treated as a different one, potentially allowing replay or double‑spending.
3. Missing or Predictable Nonces
Without a nonce, the same signature can be used multiple times. If the nonce is predictable, an attacker can pre‑compute signatures.
// ❌ VULNERABLE — no nonce
function transferWithSig(bytes memory sig, address to, uint256 amount) external {
bytes32 message = keccak256(abi.encodePacked(to, amount));
// ... recover
// No nonce → same signature can be replayed
}
// ✅ SAFE — incrementing nonce
mapping(address => uint256) public nonces;
function transferWithSig(bytes memory sig, address to, uint256 amount) external {
uint256 nonce = nonces[msg.sender]++;
bytes32 message = keccak256(abi.encodePacked(to, amount, nonce));
// ... recover
}
4. EIP‑712 Typehash Pitfalls
EIP‑712 is the standard for typed structured data signing. Two common mistakes:
- Missing typehash — signing raw data without a typehash allows collisions with other messages.
- Cross‑typehash collision — if two different structs have the same field layout, their encodings collide.
// Correct EIP-712 pattern
bytes32 constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
function domainSeparator() internal view returns (bytes32) {
return keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes(version)),
block.chainid,
address(this)
));
}
5. Defensive Checklist
- ✅ Always include
block.chainidandaddress(this)in the domain separator. - ✅ Enforce low‑s (
s ≤ N/2) andv ∈ {27, 28}. - ✅ Use an incrementing nonce per user.
- ✅ Use EIP‑712 with a unique typehash for each struct.
- ✅ Never use
ecrecoverwithout validating the full signature.