🌳 Advanced ⏱️ 40 min

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.

🚨 Critical

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

💻 ecrecover — Recovering the Signer
// 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

⚠️ Signature Replay — Same Signature Used Infinitely
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

✅ Proper Signature Validation with Nonce and Chain ID
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.

📝 EIP-712 Implementation
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

⚠️ Signature Malleability — Same Message, Different Bytes
// 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

⚠️ Cross-Chain Replay Attack
// 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 signer

Foundry Test: Replay Attack and Fix

🧪 Foundry Test — Signature Replay
// 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

ProtocolYearLossBug
Poly Network2021$611MAttacker could forge valid guardian signatures by exploiting keeper role assignment
BadgerDAO2021$120MAttacker injected malicious approvals via compromised Cloudflare API — front-end signature attack
Ronin Bridge2022$625MAttacker 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

✅ Using OZ ECDSA for Safe Signature Verification
// 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

⚠️ Common permit() Implementation Mistakes
// 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

ComponentWhy It MattersAttack Without ItImplementation
NoncePrevents replay of same signatureSame signature used 1000 timesmapping(address => uint256) nonces
DeadlineSignature expires after set timeSignature valid forever, used years laterrequire(block.timestamp <= deadline)
Chain IDSignature only works on this chainEthereum sig used on PolygonInclude block.chainid in hash
Contract addressSignature only works on this contractSig for Contract A used on Contract BInclude address(this) in hash
Non-zero checkecrecover returns 0 on invalid sigInvalid sig passes if signer == 0require(recovered != address(0))
Low-s enforcementPrevents signature malleabilityMalleable sig bypasses bytes-based trackingUse 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 ecrecover precompile returns address(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 implementing try/catch around 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.