Web3 & smart contracts
Verified by ROOTVIEL13 minSignature Replay & Authorization
A contract accepts an off-chain signature as authorization but fails to bind it to a single use, a single contract/chain, or the right signer — so a valid signature can be replayed: submitted again, reused on another contract or chain, or accepted from the wrong recovered address.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- RootRaven
- CWE
- CWE-294
- OWASP
- SC01
Definition
Signature replay and authorization defects are the class of bugs in which an off-chain signed message, meant to authorize one specific action once, can be used in a way its signer never intended: submitted a second time (replay), reused on a different contract or a different chain (cross-domain replay), accepted even though the recovered signer is not the expected party, or manipulated because the signature’s own malleability was not accounted for.
The underlying promise of a signature-based authorization is "this exact signer approved this exact action, on this exact contract and chain, exactly once". A replay vulnerability exists whenever the on-chain verifier fails to enforce any one of those bindings — most often the "exactly once" (no nonce) or the "this contract and chain" (no domain separation).
Context
Signatures authorize an enormous amount of on-chain activity: ERC-2612 permit() approvals, meta-transactions and gasless relaying, on-chain order books and intents (sign an order off-chain, settle on-chain), multisig confirmations, airdrop/allow-list claims, and bridge or governance messages. Any flow where a user signs a message off-chain and a contract later acts on it is in scope. Cross-chain and multi-deployment systems are especially exposed, because the same signature can be valid in more than one place unless the domain is bound.
- ▸ERC-2612 permit and other gasless-approval flows.
- ▸Meta-transactions / relayers that forward a user’s signed intent.
- ▸Off-chain order books, intents, and auction bids settled on-chain.
- ▸Airdrop and allow-list claims gated by a signed voucher.
- ▸Multisig/threshold confirmations and bridge/governance messages reused across chains or deployments.
Technical fundamentals
On the EVM, ecrecover(hash, v, r, s) returns the address whose private key signed hash. By itself it proves only "someone with this key signed this 32-byte hash" — it says nothing about whether the hash was meant for this contract, this chain, or this one occasion. All of that meaning must be built into the hash and checked around the recovery.
EIP-712 provides the standard structure: a typed-data hash incorporates a domain separator (contract name, version, chainId, and verifying contract address) and the structured message fields, so a signature is cryptographically scoped to one contract on one chain. A per-signer nonce, consumed on use, provides single-use semantics so the same signed message cannot be replayed. EIP-2098/signature malleability means a given signed message can be represented by two valid (r,s) encodings, so uniqueness must be enforced on the message/nonce, not on the raw signature bytes.
- ecrecover
- The EVM precompile that returns the signer address for a hash and (v,r,s). It verifies a key signed a hash — nothing about intent, scope, or uniqueness.
- Domain separator (EIP-712)
- A hash of {name, version, chainId, verifyingContract} folded into the signed digest so a signature is valid for exactly one contract on one chain.
- Nonce
- A per-signer counter (or consumed bitmap) that makes each authorization single-use; without it, a valid signature can be replayed indefinitely.
- Signature malleability
- A signed message has more than one valid (v,r,s) encoding (the s-value can be flipped), so deduplicating on raw signature bytes fails; dedupe on the message/nonce instead.
Root cause
The defect is that the verifier accepts a recovered signer without enforcing all of the bindings the authorization requires: single-use (a consumed nonce), domain scope (chainId + verifying contract, via a domain separator), correct signer identity (comparing the recovered address to the expected one and rejecting the zero address ecrecover returns on failure), and uniqueness that is robust to malleability. Missing any one binding opens a replay or authorization-bypass path.
The SWC Registry catalogs this as missing protection against signature replay attacks (SWC-121) and, relatedly, signature malleability. OWASP Smart Contract Top 10 treats it under access control / authorization (SC01). The core is that cryptographic validity (a real signature by a real key) is being mistaken for authorization validity (that signer, this action, here, now, once).
How the attack works, step by step
- 1A user signs a message authorizing an action — say "transfer 100 tokens" or "claim this airdrop" — and the contract verifies it with ecrecover and executes.
- 2Because no nonce is consumed, the signature remains valid forever; an attacker (or the relayer) resubmits the exact same signature and the contract executes the action again — a direct replay.
- 3Cross-domain variant: the same contract is deployed on a second chain, or there is no verifyingContract/chainId binding, so a signature made for one deployment is accepted by another.
- 4Wrong-signer variant: the verifier does not compare the recovered address to the expected signer (or does not reject the zero address that ecrecover returns on malformed input), so an unauthorized or null signer passes.
- 5The attacker drains, double-claims, or executes an authorization the signer intended only once or only elsewhere.
Vulnerable example
A synthetic meta-transfer that verifies a signature with no nonce, no domain separation, and no zero-address check — so it can be replayed endlessly and across deployments.
synthetic Solidity — do not deploy; illustrates the flaw// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: no nonce, no domain separator, no signer/zero-address check. contract TokenBank { mapping(address => uint256) public balanceOf; // "from" authorizes sending "amount" to "to", off-chain. function metaTransfer( address from, address to, uint256 amount, uint8 v, bytes32 r, bytes32 s ) external { // Hash binds nothing to this contract, this chain, or a nonce. bytes32 hash = keccak256(abi.encodePacked(from, to, amount)); address signer = ecrecover(hash, v, r, s); // BUG: does not require(signer == from) and does not reject address(0); // a malformed signature recovers to address(0) and may slip through // wherever from could also be address(0) or checks are loose. require(signer != address(0) ? true : true, "placeholder"); // effectively no-op // BUG: no nonce consumed — this exact (v,r,s) works again and again, // and with no domain separator it also works on another deployment. balanceOf[from] -= amount; balanceOf[to] += amount; } }
Corrected example
Bind every dimension: EIP-712 typed data with a domain separator (chainId + this contract), a per-signer nonce consumed on use, an explicit signer comparison that rejects the zero address, and a deadline. Uniqueness is enforced on the nonce, so signature malleability cannot create a second usable authorization.
synthetic Solidity — EIP-712 domain separation + nonces// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // SAFER: typed-data hashing, domain binding, per-signer nonce, deadline. contract TokenBank { mapping(address => uint256) public balanceOf; mapping(address => uint256) public nonces; // single-use per signer bytes32 private immutable _DOMAIN_SEPARATOR; bytes32 private constant TRANSFER_TYPEHASH = keccak256( "MetaTransfer(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)" ); constructor() { // Binds signatures to THIS contract on THIS chain. _DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes("TokenBank")), keccak256(bytes("1")), block.chainid, address(this) )); } function metaTransfer( address from, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { require(block.timestamp <= deadline, "expired"); bytes32 structHash = keccak256(abi.encode( TRANSFER_TYPEHASH, from, to, amount, nonces[from], deadline )); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _DOMAIN_SEPARATOR, structHash)); address signer = ecrecover(digest, v, r, s); require(signer != address(0) && signer == from, "bad signer"); // identity + zero-addr nonces[from] += 1; // consume: this signature can never replay balanceOf[from] -= amount; balanceOf[to] += amount; } }
Why it works: the domain separator ties the signature to this contract and chainId, so it cannot be reused on another deployment; the nonce is incremented on every use, so resubmitting the same signature fails the next recovery (the digest no longer matches a current nonce); the explicit require(signer == from) and the zero-address rejection block wrong-signer and malformed-signature cases; and the deadline bounds the window. Because uniqueness is enforced on the nonce rather than on raw signature bytes, s-value malleability cannot manufacture a second valid use.
Limits of the fix: EIP-712 and nonces stop replay and cross-domain reuse, but they do not validate that the signed action is itself safe or that the key was not phished — a correctly scoped signature over a malicious payload is still honored. If the domain separator caches chainId instead of reading block.chainid, a chain fork can break the binding; recompute on fork-sensitive deployments. And nonces must be strictly consumed on every path, or one unprotected entrypoint reopens replay. A clean verifier is UNKNOWN about key custody and payload intent, not a guarantee.
Impact
- ▸Repeated execution: a one-time approval, transfer, or claim is replayed to drain funds or double-claim an airdrop.
- ▸Cross-chain / cross-deployment theft: a signature intended for one contract authorizes an action on another.
- ▸Authorization bypass: a wrong-signer or zero-address acceptance lets an unauthorized party act as if signed-for.
- ▸Permit abuse: a replayable or mis-scoped permit() grants or reuses allowances the owner never intended.
Detection
Static analysis can flag the structural tells: an ecrecover whose result is not compared to an expected signer, a missing zero-address rejection, a signed hash that omits a nonce or a domain separator (no chainId / verifyingContract), and signature uniqueness enforced on raw bytes rather than a consumed nonce. These are strong leads. Confirmation is dynamic: on a local chain, capture a valid signature, submit it once, then submit the identical signature again (replay), submit it to a second deployment (cross-domain), and submit a malleated (s-flipped) variant — and observe whether any is accepted. An accepted second use is a Finding; a missing nonce that some wrapper still guards is only a signal.
How RootRaven analyzes it
- Support level
- Verified — signature authorization is a core execute-and-verify family: replay is demonstrated by actually resubmitting a real signature.
- The truth it decides
- Whether an off-chain signed authorization was used within its valid, single-use policy — covering replay, nonce reuse, wrong/zero signer acceptance, permit abuse, and missing domain separation.
- Evidence required
- An executed sequence in which a single valid signature produces its authorized effect more than once, or on a scope it was not signed for, confirmed by a deterministic verifier comparing effects to the single-use policy.
RootRaven turns an Observation (ecrecover with no nonce or domain binding) into a Hypothesis ("this signature can be replayed or reused out of scope"), and has the AI compose the candidate — produce one valid signature, then attempt a second submission, a cross-deployment submission, and a malleated variant. The AI only proposes; it does not decide truth. The candidate executes on a disposable local blockchain or a local fork of real on-chain state (never a live public network), and an AI-free verifier checks the hard rule: did one signature cause its effect to occur more than once, or on a contract/chain it was not scoped to? Only a confirmed out-of-policy use becomes a Finding.
The verifier decides on an observed double/out-of-scope effect, not on the look of the code. A missing explicit nonce that is nonetheless unreachable for replay yields a signal, not a Finding, and a clean run is reported as "no replay or out-of-scope use proven within the tested scope", never "the signature scheme is secure". This family is mature for standard EIP-712/permit patterns; exotic aggregated or threshold schemes carry a lower maturity caveat.
RootRaven does not evaluate key custody or whether the signed payload is itself benign — a correctly scoped signature over a harmful action is out of this family’s truth and is not implied by a clean result.
Mitigation
Bind the signature to exactly one signer, one action, one domain, and one use — and consume the nonce on every path. The structural control is that cryptographic validity never stands in for authorization validity.
- ▸Preventive: use EIP-712 typed data with a domain separator (name, version, chainId, verifyingContract) so a signature is scoped to one contract on one chain.
- ▸Preventive: consume a per-signer nonce (counter or bitmap) on every use, and enforce uniqueness on the nonce/message — not on raw signature bytes — to neutralize malleability.
- ▸Preventive: require(signer == expected) and reject the zero address ecrecover returns on failure; include a deadline/expiry.
- ▸Preventive: prefer a vetted library (OpenZeppelin ECDSA + EIP712, ERC-2612 permit) over hand-rolled ecrecover handling.
- ▸Corrective: on fork-sensitive chains, recompute the domain separator from block.chainid rather than caching it; add monitoring for repeated identical authorizations.
Verifying the mitigation
Verify by trying to replay. On a local chain, obtain one valid signature and confirm: the first use succeeds; an identical second submission reverts (nonce consumed); submission to a second deployment or with a different chainId reverts (domain bound); a signature from the wrong signer and a zero-address case revert; and an s-flipped (malleated) variant does not produce a second accepted use. State the result as "replay and out-of-scope attempts failed within the tested scope", not "the scheme is safe", and separately note that key custody and payload safety were not evaluated.
Negative tests must cover every entrypoint that consumes a signature; one path that forgets to consume the nonce reopens replay even if the main path is correct.
Common mistakes
- ▸Calling ecrecover but never comparing the recovered address to the expected signer, and not rejecting the zero address returned on failure.
- ▸Omitting a nonce, so a valid signature is replayable forever.
- ▸Omitting the domain separator (chainId / verifyingContract), so a signature is reusable across chains or deployments.
- ▸Deduplicating on raw signature bytes, which signature malleability (an s-flipped encoding) defeats — dedupe on the nonce instead.
- ▸Caching the domain separator across a chain fork so the chainId binding silently breaks.
- ▸Assuming a correctly scoped signature means the authorized action is safe, ignoring phishing and malicious payloads.
References
- [1]SWC Registry — SWC-121 Missing Protection against Signature Replay Attacks
- [2]SWC Registry (overview)
- [3]EIP-712 — Typed Structured Data Hashing and Signing
- [4]EIP-2612 — permit: signed approvals for ERC-20
- [5]OpenZeppelin Contracts — ECDSA and EIP712 utilities
- [6]MITRE CWE-294 (Authentication Bypass by Capture-replay)
References point to public, well-known technical sources for further study.
Related articles
