DeFi Security Alliance

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.

Two identical blue wax seals on side by side documents with a stamp above them, illustrating a signature replay attack on a smart contract.

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.

The seven variants of signature replay, the field each one turns on and the check that catches it.
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
Six seals on a signed message and the replay each missing seal opens A signed message is drawn as an envelope carrying six seals. Each seal is one field the digest must bind: nonce, deadline, chain id, verifying contract, signer and the function with its arguments. Beside every seal sits the replay that opens when the field is missing: a signature spendable any number of times, a signature held for months and then spent, a signature valid on every other EVM chain, a signature accepted by a sibling deployment, a signature submitted by whoever captured it, and a signature redirected into a different call. Signed message what the digest binds Replay opened when the seal is absent nonce Spent any number of times deadline Held for months and then spent chain id Valid on every other EVM chain verifying contract Accepted by a sibling deployment signer Submitted by whoever captured it function and arguments Redirected into a different call
The six fields a signed message has to bind, and the replay that opens when one of them is left out of the digest.
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.

  1. Grep for ecrecover, ECDSA.recover and isValidSignature.
  2. For each hit walk back to the line that builds the digest and ask which value in it changes between two otherwise identical calls.
  3. Nothing changes? It replays.
  4. Prove it in a fork test: capture one valid signature, call the function, call it again with the same bytes and assert the revert.
The nonce guard: a counter consumed by post increment inside the require.
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.

  1. Grep for deadline, expiry, validUntil and validAfter.
  2. Diff the fields named in the type hash string against the fields named in the require statements.
  3. Any name in one list and not the other is your finding.
  4. 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.

The deadline guard: an expiry named in the type hash and checked against block time.
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.

  1. Grep for DOMAIN_SEPARATOR and block.chainid.
  2. Confirm the separator is a function, not an immutable.
  3. Test with vm.chainId(999), replaying a signature captured before the switch.
The chain id guard: a separator recomputed over block.chainid on every call.
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.

  1. Grep for keccak256(abi.encodePacked( near ecrecover.
  2. Read every hand built digest for three things: the prefix, the contract address and the type hash.
  3. Deploy the same contract twice, sign against the first, submit to the second.
  4. Acceptance is the bug.

Contracts that outlive a single deployment deserve the scrutiny an upgradeable contract audit gives to storage.

The verifying contract guard: the normative prefix and the domain separator inside the digest.
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.

  1. Grep for permit, nonces and DOMAIN_SEPARATOR, then check the implementation against the standard field by field.
  2. Watch for a nonces mapping that is read but never written.
  3. Watch too for a permit that returns instead of reverting.
  4. Call permit twice with one signature.

Our ERC-20 token audit notes cover the surrounding surface.

The permit guard: a recovered address checked against zero and against the owner.
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.

  1. Grep for _msgSender overrides, trustedForwarder and isTrustedForwarder.
  2. Read whether the appended sender is covered by the signature.
  3. Submit the same forwarded request from two different relayer addresses.
  4. Both land? The signature was bound to a payload and never to a submitter.

That shape broke a public mint in .

The forwarder guard: the signer's own nonce hashed into the forwarded request.
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.

  1. Grep for mapping(bytes32 => bool) next to ecrecover, for usedSignatures and for signatureUsed, since each of those names stores the bytes instead of consuming a counter.
  2. Take a valid signature, compute secp256k1n - s, swap v, submit.
  3. Move the dedup key onto a nonce.
The malleability guard: an s bound plus a dedup key that is never the signature itself.
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

Replay vocabulary in 155 sampled member firm reports, September 4, 2026. Each row counts reports containing at least one term from that family.
Term family Reports with a hit Share of 155
nonce or replay5032%
ecrecover with address(0) nearby4126%
permit3321%
malleability or high s2315%
deadline or expiry2214%
chain id or domain separator106%

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

The three reports of 155 that file signature replay as a finding under the rule above.
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.