Attack classes
Signature Replay Attack Variants in Smart Contracts and the Check That Catches Each
A signature replay attack costs nothing to run: the attacker resubmits bytes the real owner already signed, and every check inside the contract passes. Six fields decide whether that works, and published audit reports name the category far more often than they file a finding against it. This guide maps each variant to the auditor check that catches it, then measures what 155 member firm reports actually say.

Key facts
- Named far more than found
- Of 155 reports sampled from 17 member firm GitHub archives, 31 mention replay near a severity word, but a hand read finds 3 real findings, 26 checklist rows and 2 unclear cases
- The malleable twin
- EIP-2 bans a high s value for transaction signatures only, so a used signature set keyed on the 65 bytes is walked past by flipping s to secp256k1n minus s and swapping v
- Chain id 0 is a feature
- An EIP-7702 authorization tuple signed with chain ID 0 is deliberately valid on every EVM chain at once, which makes cross chain replay a design choice rather than a bug
- No detector for it
- Slither ships no dedicated signature replay detector as of September 4, 2026. The only signature adjacent entries are domain-separator-collision and arbitrary-send-erc20-permit
- The 20,000,000 OP case
- Optimism disclosed the loss on June 9, 2022, after a 2019 Gnosis Safe factory deployment signed without a chain id was replayed on another chain
Missing nonce
A signature replay attack reuses a signature its owner really produced, in a context the owner never agreed to. Nothing is forged. The bytes verify, the recovered address is the genuine signer and the contract does what it was told, twice. OWASP files the weakness as SCWE-055 and maps it to CWE-294, describing it as a valid signature from a previous transaction that is reused in a different context.
| Variant | Field the digest must bind | What to grep | Fork test | Report wording |
|---|---|---|---|---|
| Missing nonce | A counter consumed on use | ecrecover, ECDSA.recover, isValidSignature |
Submit the same 65 bytes twice | A missing nonce in a signed message |
| Missing deadline | An expiry inside the type hash | deadline, expiry, validUntil, validAfter |
Warp past the deadline with vm.warp |
A signature with no expiration |
| Cross-chain replay | The chain id | DOMAIN_SEPARATOR, block.chainid |
vm.chainId(999), then replay |
Potential replay attacks due to the chain hard fork |
| Cross-contract replay | The verifying contract address | keccak256(abi.encodePacked( near ecrecover |
Deploy twice, sign to the first, submit to the second | A signed digest that omits the verifying contract |
| Permit and ERC-2612 | All four fields at once | permit, nonces, DOMAIN_SEPARATOR |
Call permit twice with one signature |
An approval grantable twice from a single signature |
| Meta-transaction relayer | The submitter | _msgSender, trustedForwarder, isTrustedForwarder |
Same request, two relayers | A forwarded request not bound to a single submitter |
| Malleability and ecrecover(0) | The signer, keyed on a nonce not the bytes | mapping(bytes32 => bool), usedSignatures, signatureUsed |
Flip s to secp256k1n - s, swap v, submit |
Signature malleability, or an unchecked ecrecover return value |
- nonce
- A signature spendable any number of times.
- deadline
- A signature held for months and then spent.
- chain id
- A signature valid on every other EVM chain.
- verifying contract
- A signature accepted by a sibling deployment.
- signer
- A signature submitted by whoever captured it.
- function and arguments
- A signature redirected into a different call.
Start with the seal that goes missing most often. A digest carrying no counter is a bearer instrument: whoever holds the 65 bytes can present them again, which is why ERC-2612 says in one line that the nonces mapping is given for replay protection, and why OpenZeppelin's Nonces.sol writes the same idea as an invariant, tracking nonces for addresses that will only increment.
Read the increment order first. OpenZeppelin uses
unchecked { return _nonces[owner]++; } and comments that
x++ rather than ++x matters here, because post
increment returns the value that was actually signed. The strict variant,
_useCheckedNonce, reverts with
InvalidAccountNonce(owner, current) instead of moving on
quietly.
Your check is mechanical.
- Grep for
ecrecover,ECDSA.recoverandisValidSignature. - For each hit walk back to the line that builds the digest and ask which value in it changes between two otherwise identical calls.
- Nothing changes? It replays.
- Prove it in a fork test: capture one valid signature, call the function, call it again with the same bytes and assert the revert.
mapping(address => uint256) public nonces;
require(nonce == nonces[owner]++, "bad nonce");
Reports word this as a missing nonce in a signed message
, filed against the verifying function.
Missing deadline
Single use is not the same as short lived. A nonce stops the second execution and says nothing about the first, so a signature issued today can sit in a relayer queue, a leaked log or an attacker's notes until the price moves, which is why ERC-2612 treats expiry as a hard precondition: the current blocktime must be less than or equal to deadline, and the call reverts otherwise.
Where the deadline lives matters more than whether it exists. ERC-2612 puts
it inside the type hash,
Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline),
so the value is covered by the signature. A deadline that appears only as a
call argument and is compared against block.timestamp without
sitting in the signed struct is forgeable, because the submitter picks it.
That distinction is the finding.
Uniswap's Permit2 splits the idea in two. Both of its flows carry a signature deadline, and AllowanceTransfer allowances also carry their own expiration after which the permission is invalid. Two clocks, two jobs.
The audit is a diff between two lists.
- Grep for
deadline,expiry,validUntilandvalidAfter. - Diff the fields named in the type hash string against the fields named in the require statements.
- Any name in one list and not the other is your finding.
- Sign with a deadline one hour out, warp past it with
vm.warp, assert the revert.
Property based coverage belongs in the fuzzing harness, where the timestamp is an input rather than a fixture.
bytes32 constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
require(block.timestamp <= deadline, "expired");
Firms file this as a signature with no expiration
, or as a deadline excluded from the signed struct
.
Cross-chain replay via chain id
EIP-155 is titled Simple replay attack protection. The problem is that old. It changed the signing preimage from six elements to nine, hashing nine RLP encoded elements: nonce, gasprice, startgas, to, value, data, chainid, 0 and 0. Its goal: transactions that work on Ethereum without working on ETC or the Morden testnet. Activation was FORK_BLKNUM 2,675,000.
Read the compatibility clause carefully. Chain is encoded into v as CHAIN_ID * 2 + 35 or CHAIN_ID * 2 + 36, while legacy signatures with v of 27 or 28 stay valid forever, which means a transaction signed before the fork carries no chain binding at all and every EVM chain launched afterward will accept it.
At the application layer EIP-712 pulls the same value in, defining chainId as the EIP-155 chain id, and it pushes part of the duty onto the wallet: the user agent should refuse signing if the chain id does not match the currently active chain. OpenZeppelin's EIP712.sol caches the domain separator for gas, returning the cached value only when address(this) == _cachedThis && block.chainid == _cachedChainId and otherwise recomputing it over block.chainid and address(this). So a hand written immutable DOMAIN_SEPARATOR is a finding, not an optimization.
EIP-7702 revives the class as a deliberate feature. Its authorization tuple
is [chain_id, address, nonce, y_parity, r, s], and the
specification says that when universal deployment is preferred you simply
set chain ID to 0, noting separately that the chain ID can be set to reduce
the scope of the authorization. An
EOA delegating with chain id 0
has signed for every EVM chain at once, on purpose.
Three moves settle it.
- Grep for
DOMAIN_SEPARATORandblock.chainid. - Confirm the separator is a function, not an immutable.
- Test with
vm.chainId(999), replaying a signature captured before the switch.
function DOMAIN_SEPARATOR() public view returns (bytes32) {
return keccak256(abi.encode(TYPE_HASH, _name, _version, block.chainid, address(this)));
}
BlockSec words this variant as potential replay attacks due to the chain
hard fork
. A report shipping a cached separator without that note skipped the check.
Cross-contract replay via verifyingContract
Two deployments of the same code produce the same digest unless something separates them. EIP-712 supplies it. verifyingContract is the address of the contract that will verify the signature, and the specification states the job of the whole structure in one line: the domain separator prevents collision of otherwise identical structures.
Version is the second separator. It cuts both ways. Signatures from different versions are not compatible, so bumping version invalidates every outstanding signature at once. That is a liveness decision, not a free mitigation. The optional salt field is a disambiguating salt for the protocol, and OpenZeppelin's type hash omits it, hashing EIP712Domain(string name,string version,uint256 chainId,address verifyingContract), so an OpenZeppelin style separator and a salted one never agree.
One byte string is normative, and hand rolled code drops it constantly. EIP-712 specifies
encode(domainSeparator, message) as "\x19\x01"
followed by the domain separator and the struct hash. Omit the prefix and
your digest can collide with a raw eth_sign payload, which
turns a wallet signing request into a protocol authorization.
Read the digest, then build the collision yourself.
- Grep for
keccak256(abi.encodePacked(nearecrecover. - Read every hand built digest for three things: the prefix, the contract address and the type hash.
- Deploy the same contract twice, sign against the first, submit to the second.
- Acceptance is the bug.
Contracts that outlive a single deployment deserve the scrutiny an upgradeable contract audit gives to storage.
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
require(ECDSA.recover(digest, signature) == owner, "bad signer");
Expect the wording: a signed digest that omits the verifying contract, letting one signature authorize every deployment
.
Permit and ERC-2612 approval replay
ERC-2612 is where most teams meet signature verification. Its interface is small:
permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s),
plus nonces(address owner) and
DOMAIN_SEPARATOR(). Everything in the previous four sections applies at once. The standard adds a warning of its own: it is important to ensure owner != address(0).
Permit2 shows what a second generation looks like. SignatureTransfer uses unordered nonces tracked in a bitmap, so signatures can be spent in any order and each nonce is single use. AllowanceTransfer packs an incrementing nonce per owner, token and spender alongside the amount and an expiration. Note the contrast. A global per owner counter serializes every signature a user has outstanding, so canceling one cancels the rest. Bitmaps do not.
Tooling reaches part of this surface and no further. Slither ships no
dedicated detector for signature replay as of
. Its two signature adjacent detectors are domain-separator-collision, medium severity and high confidence, which fires when a token function collides with the ERC-2612 DOMAIN_SEPARATOR(), and arbitrary-send-erc20-permit, high severity and medium confidence, which fires when msg.sender is not the from parameter of transferFrom in a permit flow. Neither knows what a nonce is for.
Token contracts copy this code and edit it, so read it field by field.
- Grep for
permit,noncesandDOMAIN_SEPARATOR, then check the implementation against the standard field by field. - Watch for a
noncesmapping that is read but never written. - Watch too for a
permitthat returns instead of reverting. - Call
permittwice with one signature.
Our ERC-20 token audit notes cover the surrounding surface.
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0) && recovered == owner, "invalid permit");
The finding: an approval grantable twice from a single signature
, filed against permit rather than the spender.
Meta-transaction relayer replay
Relayed calls add a second party who holds your signature and decides when to submit it. ERC-2771 describes the forwarder as a contract trusted by the recipient to correctly verify signatures and nonces before forwarding the request, and it specifies delivery precisely: the forwarder must append the address of the transaction signer, 20 bytes of data, to the end of the call data, from where the recipient reads it.
Notice what the standard does not say. It never specifies how the forwarder checks the nonce or the signature, so every bit of replay protection lives in the forwarder implementation, which means that auditing an ERC-2771 system is auditing that one contract properly and then confirming the recipient trusts exactly the forwarder you audited and no other address.
Contract wallets move the check again. ERC-1271 defines isValidSignature(bytes32 _hash, bytes memory _signature) returning a bytes4 magic value, and it must return 0x1626ba7e when the function passes, which puts verification inside code the wallet owner controls and makes replayability a property of that wallet rather than of yours.
Audit the forwarder, then audit who is allowed to be one.
- Grep for
_msgSenderoverrides,trustedForwarderandisTrustedForwarder. - Read whether the appended sender is covered by the signature.
- Submit the same forwarded request from two different relayer addresses.
- Both land? The signature was bound to a payload and never to a submitter.
That shape broke a public mint in .
bytes32 structHash = keccak256(abi.encode(
FORWARD_TYPEHASH, req.from, req.to, req.value, req.gas, nonces[req.from]++, keccak256(req.data)));
Reports call this a forwarded request not bound to a single submitter
. Or a meta-transaction replayable by any relayer
.
Malleability and ecrecover(0)
Here is the trap that catches careful teams. EIP-2 declared that all
transaction signatures whose s value is greater than
secp256k1n/2 are invalid, because flipping s to
secp256k1n - s and flipping v between 27 and 28
yields a second valid signature over the same message. That rule governs
transaction signatures. It does not reach ecrecover.
OpenZeppelin says as much in a source comment: EIP-2 still allows signature
malleability for ecrecover(), and the library removes the
possibility to make the signature unique. Its bound is the literal
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
above which it returns RecoverError.InvalidSignatureS. Follow that through. Any contract deduplicating on the signature bytes, storing
keccak256(signature) in a used set instead of consuming a
nonce, is replayable through the flipped twin, because the twin is a
different 65 byte blob that recovers to the same signer.
The zero address is the other half of the trap. ecrecover
returns address(0) for an invalid signature rather than
reverting, so an unchecked return compared against an unset storage slot
compares zero to zero and passes. OpenZeppelin never returns
address(0) without an accompanying error, raising
ECDSAInvalidSignature,
ECDSAInvalidSignatureS or
ECDSAInvalidSignatureLength instead. So
require(ecrecover(...) == signer) is safe only when
signer cannot itself be zero, and
require(ecrecover(...) != address(0)) is not a signer check at
all.
Hunt for the replay defense built on the wrong key.
- Grep for
mapping(bytes32 => bool)next toecrecover, forusedSignaturesand forsignatureUsed, since each of those names stores the bytes instead of consuming a counter. - Take a valid signature, compute
secp256k1n - s, swapv, submit. - Move the dedup key onto a nonce.
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, "high s");
// and dedupe on a nonce, never on keccak256(signature)
Firms name this signature malleability
, and separately an unchecked
ecrecover return value
. Two findings, one root cause.
Named incidents
On , Optimism disclosed that an attacker had taken 20,000,000 OP tokens. Wintermute's own statement in Optimism's governance forum, posted , describes the method as replaying the Gnosis Safe MasterCopy 1.1.1 deployment from Eth mainnet. The tokens sat at a multisig address Wintermute controlled on layer one, never deployed on Optimism.
Why that deployment was replayable is EIP-155's answer rather than Optimism's. A sender who signed the six element preimage in committed to no chain id, so the same bytes stay valid on any EVM chain that later exists. Whoever rebroadcast them stood up the factory on Optimism and took the address. Wintermute recorded that the attacker used the previously deployed contract to create vaults in batches of 162, sold 1 million OP for ETH, withdrew to layer one through the Synapse and Hop bridges, and still held 19 million OP at the time of writing, with Wintermute committing to buy OP back every time the attacker sells. No final recovery figure belongs here. That statement predates any return.
The second case is smaller and closer to ordinary application code. NBA's The Association NFT mint opened on and was paused a day later, with nearly 16,000 of 18,000 tokens minted by . CoinGape, restating BlockSec, reported that the contract fails to verify that a signature can be used only once by a single user. Attackers could therefore reuse a signature belonging to a genuine user. Read that against the diagram above: two seals absent, the nonce and the binding of the signature to its submitter.
Both cases were public within hours. That is the argument for writing a disclosure policy before you need one.
What your audit should have caught
How we sampled
We measured how much of this vocabulary reaches published reports.
- Surveyed
- Population
- 23 DeFi Security Alliance member firms
- Archives
- 17 firms with a listable GitHub archive
- Sample
- Up to 10 reports per archive
- Corpus
- 155 reports, none unreadable
- Archives under ten
- Two of those seventeen held fewer than ten review files: Blaize Security at three and Rapid Labs at two.
Six member firms keep no GitHub archive at all.
- Expelee
- Callisto Network
- Source Hat
- Quantstamp
- PaladinAI
- Hacken
How often the vocabulary appears
| Term family | Reports with a hit | Share of 155 |
|---|---|---|
| nonce or replay | 50 | 32% |
| ecrecover with address(0) nearby | 41 | 26% |
| permit | 33 | 21% |
| malleability or high s | 23 | 15% |
| deadline or expiry | 22 | 14% |
| chain id or domain separator | 10 | 6% |
The strict filter
Presence of a word is not a finding. A stricter filter ran alongside it: the word replay within 400 characters of a severity word, of finding or of issue. That returns 31 reports, 20 percent of the corpus, across nine firms: BlockSec, CyStack, HashEx, OpenZeppelin, PeckShield, QuillAudits, SlowMist, SolidProof and VeriChains.
Then we read all 31 by hand under a narrow rule. A report counts as a real finding only when replay names the defect in a numbered finding entry carrying a severity or a status, so a row in a coverage matrix, a CWE checklist marked PASSED, a threat model table or boilerplate advice does not count. Three reports clear that bar. Twenty six name replay only as a category the firm says it checked. Two are unclear.
The three real findings
| Firm | Report | Finding title | Identifier or severity | Status |
|---|---|---|---|---|
| VeriChains | FCO Polygon public report | Cross-chain signature replay | Findings summary, severity column | FIXED |
| PeckShield | Memefi audit report | Possible Mint/Burn Replay in MemefiAssetController | PVE-001, severity Medium | Not stated, in the detailed results |
| BlockSec | Lista Lending | Potential replay attacks due to the chain hard fork | Severity Low | Confirmed |
Two OpenZeppelin reviews sit outside that table. The v5.4 review names nonce collisions or unintended replay vulnerabilities when a proposal id exceeds 192 bits, but it carries no finding number, no severity and no status, the bar every other entry cleared. The v5.2 review raises replayability as supporting rationale inside a different finding. Both are scored unclear rather than counted.
The 26 checklist rows
Understand the other 26. SlowMist lists Replay Attack Audit as a row in its audit class table, SolidProof prints CWE-294 Authentication Bypass by Capture-replay with the word PASSED beside it, and CyStack repeats identical advice to use replay protection across seven reports. HashEx names Replay of messages in the checklist of its Lachain consensus report, which tells you the category was in scope.
It is not a finding. Reading a checklist row as a finding inflates the count from 3 reports to 31, and the number of firms filing this class from three to nine.
Limits and handover
Three limits bound this. Publication is selective, so a zero measures what a firm chose to publish. Sampling ten reports per archive misses whatever sits outside the spread. Text presence is not a parse of a findings table, which is why the hand pass exists. Read any single report the way our guide to audit report structure recommends, then compare firms through the member firm profiles rather than through word counts.
For a commissioning team the output is a handover list.
- Ask which of the six seals each signature verifying function binds.
- Request the fork test that replays a captured signature.
- Ask whether the malleable twin was tried.
Firms publishing signature handling findings sit in the member directory, and the audit builder scopes permit and meta-transaction review.
Frequently asked questions
Can two contracts accept the same signature on purpose, and how do I make that safe?
Yes, and the way to do it is to make the shared scope explicit instead of accidental. Sign a struct that names every contract entitled to consume the message, or use a shared verifier contract as the verifyingContract so there is one place holding one nonce space. What you must not do is drop verifyingContract from the domain and call the resulting ambiguity a feature, because that also authorizes deployments you have not written yet. If the intent is one signature for a set of chains, put the chain list inside the signed struct rather than leaving chainId out.
We are adding a nonce by upgrade. What happens to signatures users already hold?
They stop working, and that is the point, so plan the break rather than discovering it. Bumping the EIP-712 version string invalidates outstanding signatures too, since signatures from different versions are not compatible, which gives you one clean cut instead of a partial one. Announce a window, keep the old path alive behind a deadline you set in advance, then close it. Any signature you honor after the upgrade should be re-signed against the new domain, not migrated, because you cannot retrofit a nonce onto a digest that never contained one.
Do ERC-1271 contract wallets change the replay analysis?
They move the check, so your assumptions about it have to move as well. An ERC-1271 wallet returns the magic value 0x1626ba7e when it considers a hash validly signed, and the logic behind that answer belongs to the wallet, which may consult a multisig threshold, a session key or a policy that changes tomorrow. Treat the return as an answer about this call at this block and never cache it. Your own nonce still has to be consumed on your side, because the wallet has no way to know how many times you intend to act on the same hash.
How do I write a Foundry test that replays a captured signature?
Sign the digest once with vm.sign against a known private key, keep the v, r and s values in memory, then call the target function twice with the identical triple and assert that the second call reverts. Three variations turn one test into a suite: warp past the deadline with vm.warp before the second call, switch the chain with vm.chainId and replay, and deploy a second instance of the contract and submit the first signature to it. Add the malleable twin as a fourth case by computing secp256k1n minus s and swapping v between 27 and 28.