From signature-replay-analysis
Detects signature replay vulnerabilities in smart contracts using ecrecover, ECDSA, EIP-712, permit, Permit2, ERC-1271, and meta-transactions. Analyzes five replay types, nonce management, domain separators, and ecrecover edge cases.
npx claudepluginhub quillai-network/quillshield_skills --plugin signature-replay-analysisThis skill uses the workspace's default tool permissions.
Detect vulnerabilities where **cryptographic signatures can be reused**, replayed across chains/contracts, or exploited through implementation flaws. Research shows 19.63% of Ethereum contracts using signatures contain replay vulnerabilities.
Provides Solidity security patterns, common vulnerabilities like reentrancy and decimal mismatches, defensive code fixes, and pre-deploy audit checklists for contracts handling value.
Detects reentrancy vulnerabilities in smart contracts including classic, cross-function, cross-contract, and read-only variants. Verifies CEI pattern, builds call graphs, traces state changes around external calls.
Audits EVM and Solana smart contracts for vulnerabilities like reentrancy, access control, oracle manipulation, flash loans, and economic exploits.
Share bugs, ideas, or general feedback.
Detect vulnerabilities where cryptographic signatures can be reused, replayed across chains/contracts, or exploited through implementation flaws. Research shows 19.63% of Ethereum contracts using signatures contain replay vulnerabilities.
ecrecover, ECDSA, EIP-712)permit() / Uniswap Permit2 implementationsA signature proves that a specific private key holder authorized a specific action. For this to be secure, the signature must be:
Any gap in this model creates a replay vulnerability.
The exact same signature is submitted multiple times to the same contract on the same chain.
// VULNERABLE: No nonce — same signature works forever
function executeWithSig(address to, uint256 amount, bytes memory signature) external {
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = ECDSA.recover(hash, signature);
require(signer == admin, "Invalid signer");
token.transfer(to, amount);
// Attacker can submit this same signature again and again!
}
// SAFE: Use nonce
mapping(address => uint256) public nonces;
function executeWithSig(address to, uint256 amount, uint256 nonce, bytes memory signature) external {
require(nonce == nonces[admin], "Invalid nonce");
bytes32 hash = keccak256(abi.encodePacked(to, amount, nonce));
address signer = ECDSA.recover(hash, signature);
require(signer == admin, "Invalid signer");
nonces[admin]++;
token.transfer(to, amount);
}
A signature valid on one chain (e.g., Ethereum) is replayed on another chain (e.g., Polygon, Arbitrum) where the same contract is deployed.
// VULNERABLE: No chainId in signed message
bytes32 hash = keccak256(abi.encodePacked(to, amount, nonce));
// This hash is identical on Ethereum, Polygon, Arbitrum, etc.
// SAFE: Include chainId (via EIP-712 domain separator)
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("MyContract")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
A signature for Contract A is replayed on Contract B (same chain) if both accept the same message format without contract-specific binding.
// VULNERABLE: No contract address in signed message
bytes32 hash = keccak256(abi.encodePacked(to, amount, nonce, block.chainid));
// Same hash for any contract on this chain
// SAFE: Include verifyingContract (via EIP-712)
// The domain separator includes address(this), binding to this specific contract
Nonce implementation allows gaps or out-of-order execution, enabling skipped nonces to be replayed later.
// VULNERABLE: Bitmap nonce without invalidation
mapping(uint256 => bool) public usedNonces;
function execute(uint256 nonce, ...) external {
require(!usedNonces[nonce], "Used");
usedNonces[nonce] = true;
// If nonces 1, 2, 3 are used but 4 is skipped,
// nonce 4 can be used anytime in the future
// This may be intentional OR a vulnerability depending on context
}
// SAFER for strict ordering: Sequential nonce
mapping(address => uint256) public nonces;
function execute(uint256 nonce, ...) external {
require(nonce == nonces[signer], "Invalid nonce");
nonces[signer]++;
}
A signature without a deadline can be held and executed at an arbitrary future time when conditions have changed.
// VULNERABLE: No deadline — signature valid forever
function permit(address owner, address spender, uint256 value, uint8 v, bytes32 r, bytes32 s) external {
bytes32 hash = keccak256(abi.encodePacked(owner, spender, value, nonces[owner]++));
require(ecrecover(hash, v, r, s) == owner, "Invalid");
allowance[owner][spender] = value;
// This permit can be executed weeks later when user doesn't expect it
}
// SAFE: Include deadline
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
require(block.timestamp <= deadline, "Expired");
// ... rest of verification
}
ecrecover returns address(0) for invalid signatures instead of reverting.
// VULNERABLE: address(0) accepted as valid signer
address signer = ecrecover(hash, v, r, s);
require(signer == owner, "Invalid");
// If owner == address(0) AND signature is invalid → passes!
// SAFE: Explicit zero check
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "Invalid signature");
require(signer == owner, "Wrong signer");
// SAFEST: Use OpenZeppelin's ECDSA.recover() — reverts on address(0)
address signer = ECDSA.recover(hash, signature);
For every valid ECDSA signature (r, s, v), there exists a second valid signature (r, s', v') for the same message. This allows anyone to create an alternate valid signature without the private key.
// The Ethereum standard: s must be in the lower half of the curve
// s' = secp256k1n - s (the "flipped" signature)
// VULNERABLE: Accepts both s values
address signer = ecrecover(hash, v, r, s); // Works for both s and s'
// If used as a unique identifier, the same message has TWO valid signatures
// SAFE: Enforce lower-s (OpenZeppelin's ECDSA library does this)
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, "Invalid s");
// v should be 27 or 28 (Ethereum standard)
// Some implementations use 0 or 1 (subtract 27)
// Not normalizing v can cause signature verification to fail
require(v == 27 || v == 28, "Invalid v");
bytes32 constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes(name)), // Contract name
keccak256(bytes(version)), // Version string
block.chainid, // Chain ID — prevents cross-chain replay
address(this) // Contract address — prevents cross-contract replay
));
| Field | Purpose | Missing = |
|---|---|---|
name | Identifies the signing domain | MEDIUM risk |
version | Prevents replay across upgrades | MEDIUM risk |
chainId | Prevents cross-chain replay | HIGH risk |
verifyingContract | Prevents cross-contract replay | HIGH risk |
salt (optional) | Additional disambiguation | LOW risk |
// MISTAKE 1: Hardcoded chainId (doesn't update on chain forks)
uint256 immutable CHAIN_ID = 1;
// After a fork, signatures valid on both chains!
// SAFE: Use block.chainid at verification time, or recalculate domain separator
function DOMAIN_SEPARATOR() public view returns (bytes32) {
if (block.chainid == INITIAL_CHAIN_ID) return _DOMAIN_SEPARATOR;
return _calculateDomainSeparator(); // Recalculate for new chain
}
// MISTAKE 2: Empty name/version
keccak256(bytes("")) // Valid but weak — same across all contracts with empty name
// MISTAKE 3: Missing struct type hash in message
// EIP-712 requires: hashStruct(message) = keccak256(typeHash + encodeData(message))
// Omitting typeHash weakens the domain binding
- [ ] Uses EIP-712 domain separator with chainId and verifyingContract
- [ ] Includes per-user sequential nonce
- [ ] Includes deadline with block.timestamp check
- [ ] Uses ECDSA.recover (not raw ecrecover)
- [ ] Checks recovered address != address(0)
- [ ] Checks recovered address == owner parameter
- [ ] Nonce incremented BEFORE any state change
- [ ] Domain separator recalculated on chain fork
- Permit2 uses nonce-bitmap approach (unordered nonces)
- Supports batch permits and transfer-with-permit
- Still requires deadline, domain separator, nonce management
- Contracts integrating Permit2 must verify the permit2 contract address
Task Progress:
- [ ] Step 1: Find all signature verification code (ecrecover, ECDSA.recover, EIP-712)
- [ ] Step 2: Check for same-chain replay protection (nonce management)
- [ ] Step 3: Check for cross-chain replay protection (chainId in domain/message)
- [ ] Step 4: Check for cross-contract replay protection (address(this) in domain/message)
- [ ] Step 5: Check deadline/expiry enforcement
- [ ] Step 6: Verify ecrecover safety (address(0) check, s-value, v-value)
- [ ] Step 7: Verify EIP-712 domain separator completeness
- [ ] Step 8: Check ERC-1271 support for contract wallets (if applicable)
- [ ] Step 9: Score findings and generate report
## Signature & Replay Analysis Report
### Finding: [Title]
**Function:** `functionName()` at `Contract.sol:L42`
**Replay Type:** [Same-Chain | Cross-Chain | Cross-Contract | Nonce-Skip | Expired]
**Severity:** [CRITICAL | HIGH | MEDIUM]
**Issue:**
[Description of the replay vulnerability or signature verification flaw]
**Signed Message Fields:**
- [x] to/from addresses
- [x] amount/value
- [ ] chainId ← MISSING
- [ ] verifyingContract ← MISSING
- [x] nonce
- [ ] deadline ← MISSING
**Attack Scenario:**
1. User signs message for [intended purpose]
2. Attacker captures signature from [source]
3. Attacker replays on [target chain/contract/time]
4. [Unauthorized action occurs]
**Recommendation:**
[Add EIP-712 domain separator, add nonce, add deadline, use ECDSA.recover]
chainId? (Prevents cross-chain replay)address(this)? (Prevents cross-contract replay)block.timestamp check? (Prevents late execution)ecrecover result checked against address(0)?ecrecover?isValidSignature supported?For replay type details, see {baseDir}/references/replay-taxonomy.md. For EIP-712 checklist, see {baseDir}/references/eip712-checklist.md.