Signature Replay Attacks
Digital signatures allow off-chain authorization — a user signs a message with their private key, and a contract verifies that signature to grant access without requiring an on-chain transaction from the signer. This pattern is used in gasless transactions, permit-based approvals, and meta-transactions. When signature validation is incomplete, an attacker can reuse a valid signature repeatedly, across different chains, or against different contracts — draining funds without ever accessing the private key.
Poly Network's $611M hack involved signature validation failures. BadgerDAO lost $120M through a signature-based front-end attack. Signature security is subtle and must be implemented precisely — missing a single component of proper validation opens catastrophic vulnerabilities.
How ECDSA Signatures Work in Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SignatureExample {
// ecrecover(hash, v, r, s) recovers the address that signed the hash
// v, r, s are the three components of an ECDSA signature
function recoverSigner(
bytes32 messageHash,
uint8 v,
bytes32 r,
bytes32 s
) public pure returns (address) {
// ecrecover returns address(0) on invalid signatures
address signer = ecrecover(messageHash, v, r, s);
require(signer != address(0), "Invalid signature");
return signer;
}
// Typical message hash creation for Ethereum:
function getMessageHash(address to, uint256 amount) public pure returns (bytes32) {
// keccak256 of the ABI-encoded message
return keccak256(abi.encodePacked(to, amount));
}
// Ethereum signed message format (adds prefix to prevent hash collisions)
function getEthSignedHash(bytes32 messageHash) public pure returns (bytes32) {
return keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32", messageHash
));
}
}Type 1: Simple Replay Attack
contract VulnerableWithdraw {
mapping(address => uint256) public balances;
address public signer; // Trusted signer (admin key)
// ❌ No nonce, no expiry, no chain ID
// Signature for "withdraw 1 ETH" can be used INFINITE times
function withdrawWithSignature(
address user,
uint256 amount,
bytes calldata signature
) external {
bytes32 msgHash = keccak256(abi.encodePacked(user, amount));
bytes32 ethHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32", msgHash
));
(uint8 v, bytes32 r, bytes32 s) = abi.decode(signature, (uint8, bytes32, bytes32));
address recovered = ecrecover(ethHash, v, r, s);
require(recovered == signer, "Invalid signature");
// ❌ Nothing tracks that this signature was already used
// Attacker calls this with the same signature 1000 times
balances[user] -= amount;
(bool ok,) = user.call{value: amount}("");
require(ok);
}
}The Fix: Nonces, Deadlines, Chain IDs
contract SecureWithdraw {
mapping(address => uint256) public balances;
mapping(address => uint256) public nonces; // Per-user nonce counter
address public signer;
// ✅ Signature includes: user, amount, nonce, deadline, chainId, contractAddress
function withdrawWithSignature(
address user,
uint256 amount,
uint256 nonce,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// ✅ Check 1: Signature must not be expired
require(block.timestamp <= deadline, "Signature expired");
// ✅ Check 2: Nonce must match — prevents replay
require(nonces[user] == nonce, "Invalid nonce");
// ✅ Check 3: Message includes chainId and this contract address
// Prevents cross-chain and cross-contract replay
bytes32 msgHash = keccak256(abi.encodePacked(
user,
amount,
nonce,
deadline,
block.chainid, // ← Chain-specific
address(this) // ← Contract-specific
));
bytes32 ethHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32", msgHash
));
address recovered = ecrecover(ethHash, v, r, s);
require(recovered != address(0), "Invalid signature");
require(recovered == signer, "Wrong signer");
// ✅ Increment nonce — this signature can never be used again
nonces[user]++;
balances[user] -= amount;
(bool ok,) = user.call{value: amount}("");
require(ok);
}
}EIP-712: Structured Data Signing Standard
EIP-712 standardizes how typed structured data is signed and verified. Instead of signing a raw hash, the signer signs a human-readable structured message. This prevents confusion attacks where a user is tricked into signing something that looks harmless but is actually a financial authorization. The domain separator binds the signature to a specific contract, chain, and version.
pragma solidity ^0.8.20;
contract EIP712Example {
// Domain separator: binds signatures to THIS contract, THIS chain, THIS version
bytes32 public immutable DOMAIN_SEPARATOR;
// Type hash: defines the structure of the signed data
bytes32 public constant WITHDRAW_TYPEHASH = keccak256(
"Withdraw(address user,uint256 amount,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public nonces;
constructor() {
// Domain separator includes: contract name, version, chainId, verifyingContract
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("SecureWithdraw")), // contract name
keccak256(bytes("1")), // version
block.chainid, // chain ID — prevents cross-chain replay
address(this) // verifying contract address
));
}
function _hashWithdraw(address user, uint256 amount, uint256 nonce, uint256 deadline)
internal view returns (bytes32) {
// EIP-712 hash: domain separator + typed data hash
return keccak256(abi.encodePacked(
"\x19\x01", // EIP-712 prefix
DOMAIN_SEPARATOR,
keccak256(abi.encode(
WITHDRAW_TYPEHASH,
user,
amount,
nonce,
deadline
))
));
}
function withdrawEIP712(
address user, uint256 amount, uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "Expired");
uint256 nonce = nonces[user]++;
bytes32 digest = _hashWithdraw(user, amount, nonce, deadline);
address recovered = ecrecover(digest, v, r, s);
require(recovered == user, "Invalid signature");
// Execute withdrawal...
}
}Signature Malleability
// ECDSA signatures have malleability: for any signature (v, r, s),
// there exists another valid signature (v', r, s') for the same message
// Both signatures recover to the same address
// But the bytes are different — so signature.bytes != used[signature.bytes]
// If your contract stores used signatures by bytes, malleable sigs bypass this!
mapping(bytes => bool) public usedSignatures;
function vulnerableSignatureCheck(bytes32 hash, bytes calldata sig) external {
// ❌ Malleable: attacker creates a different sig bytes for same hash
require(!usedSignatures[sig], "Signature used");
usedSignatures[sig] = true;
// sig.bytes != malleable_sig.bytes → malleable sig passes the check
}
// ✅ FIX: Use nonces (not signature bytes) to track used authorizations
// Or enforce low-s values to make signatures non-malleable:
uint256 constant SECP256K1_HALF_ORDER =
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
function checkLowS(bytes32 s) internal pure {
// If s > half_order, use (order - s) instead — forces canonical form
require(uint256(s) <= SECP256K1_HALF_ORDER, "Malleable signature");
}Cross-Chain and Cross-Contract Replay
// SCENARIO: Contract deployed at SAME address on Ethereum and Polygon
// (Factory deployments often produce same addresses cross-chain)
// Without chain ID in signature:
// Alice signs: "withdraw 1000 USDC" on Ethereum
// Attacker uses same signature on Polygon — same contract, same sig → passes!
// Alice's Polygon wallet is drained
// ✅ FIX: Always include block.chainid in the signed message
bytes32 msgHash = keccak256(abi.encodePacked(
user, amount, nonce, deadline,
block.chainid, // ← ETHEREUM: 1, POLYGON: 137, ARBITRUM: 42161
address(this) // ← Prevents cross-contract replay too
));
// CROSS-CONTRACT REPLAY: Without contract address in signed data,
// a signature for Contract A can be used on Contract B
// if both contracts have the same validation logic and accept the same signerFoundry Test: Replay Attack and Fix
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract SignatureReplayTest is Test {
VulnerableWithdraw public vulnContract;
SecureWithdraw public secureContract;
uint256 signerKey = 0xA11CE;
address signerAddr;
function setUp() public {
signerAddr = vm.addr(signerKey);
vulnContract = new VulnerableWithdraw(signerAddr);
secureContract = new SecureWithdraw(signerAddr);
vm.deal(address(vulnContract), 10 ether);
vm.deal(address(secureContract), 10 ether);
}
/// @notice Same signature can be replayed on vulnerable contract
function test_signatureReplay_succeedsOnVuln() public {
address user = makeAddr("user");
vulnContract.balances[user] = 5 ether;
// Create and sign a withdrawal message
bytes32 msgHash = keccak256(abi.encodePacked(user, uint256(1 ether)));
bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, ethHash);
// First withdrawal: legitimate
vulnContract.withdrawWithSignature(user, 1 ether, v, r, s);
// REPLAY: Same signature works again! Attacker gets another ETH
vulnContract.withdrawWithSignature(user, 1 ether, v, r, s);
// User intended to authorize one withdrawal, got two
assertEq(vulnContract.balances[user], 3 ether); // 5-1-1 = 3
}
/// @notice Replay fails on secure contract (nonce already incremented)
function test_signatureReplay_failsOnSecure() public {
address user = makeAddr("user");
secureContract.balances[user] = 5 ether;
// Sign with nonce=0, deadline=far future, chainId, contract address
bytes32 msgHash = keccak256(abi.encodePacked(
user, uint256(1 ether), uint256(0), block.timestamp + 1 days,
block.chainid, address(secureContract)
));
bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, ethHash);
// First withdrawal: succeeds, nonce advances to 1
secureContract.withdrawWithSignature(user, 1 ether, 0, block.timestamp + 1 days, v, r, s);
// REPLAY: nonce=0 signature fails — nonce is now 1
vm.expectRevert("Invalid nonce");
secureContract.withdrawWithSignature(user, 1 ether, 0, block.timestamp + 1 days, v, r, s);
}
}Real-World Exploits
| Protocol | Year | Loss | Bug |
|---|---|---|---|
| Poly Network | 2021 | $611M | Attacker could forge valid guardian signatures by exploiting keeper role assignment |
| BadgerDAO | 2021 | $120M | Attacker injected malicious approvals via compromised Cloudflare API — front-end signature attack |
| Ronin Bridge | 2022 | $625M | Attacker stole validator private keys, forged withdrawal signatures |
Key Takeaways
- Every signature must include a nonce, deadline, chain ID, and contract address. Missing any one of these enables a specific replay attack. Nonces prevent repeat use, deadlines prevent indefinite validity, chain ID prevents cross-chain replay, contract address prevents cross-contract replay.
- Use EIP-712 structured data signing. It is the standard for on-chain signature verification and includes the domain separator (which encodes chain ID and contract address) automatically.
- Never check signature uniqueness by storing signature bytes. Signature malleability means two different byte sequences can be valid signatures for the same message. Use nonces to track consumed authorizations instead.
- Always verify ecrecover did not return address(0). An invalid signature returns address(0) from ecrecover. If address(0) happens to match your stored signer (e.g., default uninitialized), the check passes falsely.
- Use OpenZeppelin's ECDSA library. It handles low-s enforcement and the address(0) check correctly. Don't reimplement signature verification from scratch.
OpenZeppelin ECDSA Library Usage
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract SafeSignatureVerifier is EIP712 {
using ECDSA for bytes32;
mapping(address => uint256) public nonces;
bytes32 private constant ACTION_TYPEHASH = keccak256(
"Action(address user,uint256 amount,uint256 nonce,uint256 deadline)"
);
constructor() EIP712("MyProtocol", "1") {}
function executeAction(
address user,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
// ✅ Check deadline before anything else
require(block.timestamp <= deadline, "Signature expired");
// ✅ Consume nonce atomically
uint256 nonce = nonces[user]++;
// ✅ EIP-712 structured hash — includes domain separator (chainId + contract)
bytes32 structHash = keccak256(abi.encode(
ACTION_TYPEHASH, user, amount, nonce, deadline
));
bytes32 digest = _hashTypedDataV4(structHash);
// ✅ OZ ECDSA.recover handles low-s enforcement and zero-address check
address recovered = digest.recover(signature);
require(recovered == user, "Invalid signature");
// Execute the action...
_doAction(user, amount);
}
function _doAction(address user, uint256 amount) internal { /* ... */ }
}EIP-2612 permit() — Correct vs Incorrect Implementation
// EIP-2612 adds permit() to ERC20 — allows gasless approvals via signatures
// Correct implementation must include ALL these components:
contract ERC20WithPermit {
mapping(address => uint256) public nonces; // Per-owner nonce
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// ✅ Check 1: Deadline
require(block.timestamp <= deadline, "Permit expired");
// ✅ Check 2: Owner must not be zero address
require(owner != address(0), "Zero owner");
// ✅ Check 3: Verify signature includes owner nonce
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address recovered = ecrecover(digest, v, r, s);
// ✅ Check 4: Recovered signer must be owner AND not zero
require(recovered != address(0) && recovered == owner, "Invalid signature");
// Set allowance
allowance[owner][spender] = value;
// ❌ COMMON MISTAKE 1: Not checking recovered != address(0)
// If ecrecover fails, it returns address(0)
// If owner is address(0) (bug in caller), check passes falsely
// ❌ COMMON MISTAKE 2: Not including nonce in signed data
// Without nonce, same permit can be used forever (replay attack)
// ❌ COMMON MISTAKE 3: Not including deadline in signed data
// Without deadline, permit is valid forever even after owner wants to revoke
}
mapping(address => mapping(address => uint256)) public allowance;
}Signature Verification: Security Decision Matrix
| Component | Why It Matters | Attack Without It | Implementation |
|---|---|---|---|
| Nonce | Prevents replay of same signature | Same signature used 1000 times | mapping(address => uint256) nonces |
| Deadline | Signature expires after set time | Signature valid forever, used years later | require(block.timestamp <= deadline) |
| Chain ID | Signature only works on this chain | Ethereum sig used on Polygon | Include block.chainid in hash |
| Contract address | Signature only works on this contract | Sig for Contract A used on Contract B | Include address(this) in hash |
| Non-zero check | ecrecover returns 0 on invalid sig | Invalid sig passes if signer == 0 | require(recovered != address(0)) |
| Low-s enforcement | Prevents signature malleability | Malleable sig bypasses bytes-based tracking | Use OZ ECDSA library |
Key Takeaways
- Every signed message must include: a nonce (prevents replay), a deadline (prevents stale signatures), the chain ID (prevents cross-chain replay), and the contract address (prevents cross-contract replay).
- EIP-712 structured data hashing is the standard for typed signed messages — it provides human-readable signing prompts in wallets and prevents accidental signature reuse.
- The
ecrecoverprecompile returnsaddress(0)on invalid inputs — always check the recovered address is non-zero and equals the expected signer. - Signature malleability allows an attacker to produce a second valid signature from one — use OpenZeppelin's ECDSA library which enforces low-s normalization.
- EIP-2612
permit()griefing (attacker front-runs permit to invalidate it) is avoided by implementingtry/catcharound permit calls in routers. - Permit2 (Uniswap's canonical permit contract) provides a unified, battle-tested signature-based approval system — prefer it over rolling your own permit logic.