ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

ECDSA Verification & Signature Malleability

ECDSA signatures on Ethereum are malleable — for a valid (r,s,v) there is a second valid (r, n−s, v′) — and ecrecover returns address(0) for bad input; verification code that ignores these facts, or that signs without domain separation and a nonce, can be bypassed or replayed.

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-347, CWE-20
OWASP
SC01

Definition

This class covers defects in how a contract verifies an off-chain signature with ECDSA. Three recurring faults: signature malleability — for any valid signature (r, s, v) there exists a second, equally valid signature (r, n − s, v′) over the same message, because the curve order n makes s and n − s symmetric; the missing zero-address check — the EVM precompile ecrecover returns address(0) for malformed input, so code that does not reject that result can treat a bogus signature as "signed by address(0)"; and missing domain/nonce binding — a signature with no chain id, verifying-contract, or per-use nonce can be replayed on another chain, another contract, or the same contract again.

The common thread is treating "ecrecover returned an address" as "this exact, single-use, this-contract authorization is valid", which it is not without additional constraints.

Context

Signature verification underpins meta-transactions, permit-style approvals (EIP-2612), off-chain orders, gasless relaying, multisig attestations, and bridge message authentication. Anywhere a contract trusts "the owner signed this", the verification details decide whether that trust is sound. The malleability and zero-address facts are properties of the EVM precompile and the secp256k1 curve, not of any one application.

  • ▸EIP-2612 permit() approvals and other EIP-712 typed-data authorizations.
  • ▸Off-chain order books where a filled order is marked by its signature.
  • ▸Meta-transaction / relayer systems that verify a user’s signature on-chain.
  • ▸Multisig and bridge attestation schemes counting signatures toward a threshold.

Technical fundamentals

An ECDSA signature is a pair (r, s) with a recovery id v. The verification equation is satisfied by both s and n − s (where n is the secp256k1 curve order), so every signature has a sibling with the same r, the complementary s, and the flipped v. Neither reveals the private key, but they are two distinct byte strings that both verify. If a contract uses the signature bytes (or their hash) as a uniqueness key — "has this signature been used?" — an attacker can present the sibling to bypass that check. EIP-2 and the Homestead rules, and later EIP-2098 (compact signatures), formalize constraints; the practical rule is to accept only the low-s form (s ≤ n/2) and a canonical v.

ecrecover(hash, v, r, s) returns the signer address or address(0) on failure. Because uninitialized/implicit mappings often make address(0) a privileged-looking default, failing to reject address(0) can turn a malformed signature into an apparent authorization. Finally, what is signed matters: EIP-712 domain separation folds a domain (name, version, chain id, verifying contract) into the digest so a signature is bound to one chain and one contract, and a per-signer nonce makes it single-use. Without these, a valid signature is replayable across chains, contracts, and time.

Malleability (low-s rule)
Both s and n−s verify; accept only s ≤ n/2 (canonical/low-s) so each signature has one accepted form.
ecrecover zero-address
The precompile returns address(0) on bad input; verification must reject address(0) explicitly.
EIP-712 domain separator
Binds a signed message to a chain id and verifying contract, preventing cross-domain reuse.
Nonce
A per-signer counter folded into the signed data so a signature authorizes exactly one use.

Root cause

The root cause is incomplete signature verification: the code checks that ecrecover returns the expected signer but omits one or more of the mandatory constraints — reject address(0), enforce low-s canonical form, bind to an EIP-712 domain, and consume a nonce. Each omission maps to a concrete bypass: no zero-address check → forged "address(0)" authorization; no low-s check → signature-hash uniqueness bypass via the sibling; no domain binding → cross-chain/cross-contract replay; no nonce → same-contract replay.

It is a data-authenticity defect: the contract draws a stronger conclusion ("this is a unique, this-contract, one-time authorization by X") than its checks actually support.

How the attack works, step by step

The malleability-against-a-signature-keyed-nonce path:

  1. 1A contract records used signatures by hashing the signature bytes, to prevent replay.
  2. 2A legitimate user submits a valid signature (r, s, v); the contract records hash((r,s,v)) as used.
  3. 3The attacker computes the sibling (r, n − s, v′), which verifies to the same signer but hashes differently.
  4. 4The attacker submits the sibling; the used-signature check does not match, so it passes.
  5. 5The authorized effect executes a second time — the replay protection, keyed on malleable bytes, was defeated.

The zero-address path: an attacker submits a malformed signature for which ecrecover returns address(0); if the contract compares the result to an owner that is (or defaults to) address(0), or simply does not reject address(0), the check passes. The domain/nonce paths: a signature valid on chain A is replayed on chain B, or re-used on the same contract because no nonce was consumed.

Vulnerable example

A synthetic, illustrative verifier that keys replay protection on the malleable signature bytes, omits the zero-address check, and signs a digest with no domain or nonce:

synthetic — do not use; illustrates the flaw
// SPDX-License-Identifier: MIT // UNSAFE: malleable-keyed replay guard, no zero-address check, no domain/nonce. pragma solidity ^0.8.20; contract NaiveVerifier { address public owner; mapping(bytes32 => bool) public usedSig; // keyed on signature bytes (bad) function act(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // digest omits chain id, this contract, and any nonce bytes32 digest = keccak256(abi.encodePacked(to, amount)); address rec = ecrecover(digest, v, r, s); require(rec == owner, "bad sig"); // BUG: address(0) not rejected bytes32 k = keccak256(abi.encodePacked(v, r, s)); require(!usedSig[k], "replay"); // BUG: sibling (r, n-s, v') has a different k usedSig[k] = true; _transfer(to, amount); // replayable via the sibling / across chains } function _transfer(address, uint256) internal { /* ... */ } }
Note

The sibling signature verifies to the same owner but produces a different k, bypassing the used-signature guard. With owner ever address(0), a malformed signature also passes. And the digest binds to neither chain nor contract nor a nonce, so it replays across deployments.

Impact

  • ▸Replay: a one-time authorization is executed twice (sibling signature or missing nonce) — double spend, double mint, double approval.
  • ▸Forgery: a malformed signature accepted as address(0) authorization bypasses owner checks.
  • ▸Cross-domain abuse: a signature valid on one chain/contract is reused on another with no authorization there.
  • ▸Quorum dilution: in multisig/bridge counting, malleable or duplicate signatures can be counted more than once toward a threshold.

Detection

Static analysis flags raw ecrecover uses missing a subsequent address(0) rejection, replay guards keyed on signature bytes rather than on a nonce or message id, digests lacking an EIP-712 domain separator, and verification flows with no nonce consumption. These are precondition findings. A static flag of raw ecrecover is a signal, not proof of exploitability.

Dynamic detection on a local chain computes the sibling (r, n − s, v′) and submits it against a signature-keyed guard to observe a double-effect; submits a crafted address(0)-recovering input; and submits a foreign-chain/contract signature. An observed bypass is a reproduced finding. Differentiate a static missing-check finding, a tool counterexample, and a lab-reproduced replay.

How autonomous agents investigate it

An autonomous agent reconstructs the full verification path: the exact bytes that are hashed, whether an EIP-712 domain is folded in, how ecrecover’s result is checked, how replay is prevented, and whether a nonce is consumed. It enumerates the four constraints (reject zero-address, enforce low-s, bind domain, consume nonce) and marks which are present.

A missing constraint is a SIGNAL. The agent then composes the matching concrete attack — deterministically computing the low-s sibling, crafting a zero-address input, or porting a signature across domains — and predicts the bypass. Only after executing it on a disposable local chain and observing the unauthorized or duplicated effect does it record a FINDING. A verification path that has all four constraints present is not declared "safe" on inspection alone: untested argument combinations stay UNKNOWN until exercised.

How RootRaven analyzes it

Support level
Verified — malleability, zero-address, and domain/nonce defects are deterministically detectable and reproducible; RootRaven both flags the pattern and executes the bypass.
Evidence model
Evidence-first. A missing check is a signal; a Finding requires executing the sibling-signature/zero-address/cross-domain attack on a disposable local chain or fork and observing a forged or replayed effect.
Declared property
“A signature authorizes exactly one use, by the recovered non-zero signer, on this chain and this contract.”

RootRaven uses AI only to compose the candidate attack; the cryptographic step — computing the low-s sibling (r, n − s, v′) or a zero-address-recovering input — is deterministic. It deploys the verifier on a local blockchain or local fork and executes the candidate. A deterministic, AI-free verifier then checks the declared property against raw state: did a second, sibling, or foreign-domain signature produce an effect, or did ecrecover’s address(0) pass an owner check? Only a reproduced forgery or replay is a Finding. There is no live-network attack mode — proof is obtained only against disposable local state.

Partial

A clean run means no bypass was reproduced on the paths exercised — UNKNOWN, not "safe". Coverage of exotic signing schemes (ERC-1271 smart-contract signatures, aggregated/threshold schemes) varies; keep the maturity caveat.

Mitigation

Use a vetted library and enforce all four constraints; never hand-roll ecrecover handling.

  • ▸Preventive: use OpenZeppelin ECDSA.recover, which reverts on malformed signatures and enforces the low-s canonical form, removing both the zero-address and malleability paths.
  • ▸Preventive: key replay protection on a per-signer nonce or a message id — never on the signature bytes — and consume it before the effect.
  • ▸Preventive: build the digest with EIP-712 domain separation (name, version, chain id, verifying contract) so a signature is bound to one chain and contract; use EIP-2612 permit for token approvals.
  • ▸Preventive: support ERC-1271 for smart-contract signers where needed, and for EIP-2098 compact signatures ensure the library handles them canonically.

Corrected example

The verifier uses OpenZeppelin ECDSA (reverts on bad/high-s signatures), an EIP-712 domain-bound digest, and a per-signer nonce consumed before the effect:

synthetic — ECDSA library + EIP-712 + nonce
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; contract SaferVerifier is EIP712 { address public owner; mapping(address => uint256) public nonces; // per-signer, single-use bytes32 private constant ACT_TYPEHASH = keccak256("Act(address to,uint256 amount,uint256 nonce)"); constructor(address _owner) EIP712("SaferVerifier", "1") { owner = _owner; } function act(address to, uint256 amount, uint256 nonce, bytes calldata sig) external { require(nonce == nonces[owner], "bad nonce"); // ordered, single-use bytes32 structHash = keccak256(abi.encode(ACT_TYPEHASH, to, amount, nonce)); bytes32 digest = _hashTypedDataV4(structHash); // binds chain id + address(this) address rec = ECDSA.recover(digest, sig); // reverts on malformed / high-s require(rec == owner, "bad sig"); // address(0) impossible here nonces[owner]++; // consume BEFORE effect _transfer(to, amount); } function _transfer(address, uint256) internal { /* ... */ } }

Why it works: ECDSA.recover rejects the high-s sibling and malformed inputs (so no sibling-replay and no address(0) forgery); _hashTypedDataV4 folds the chain id and this contract into the digest (no cross-domain reuse); the per-signer nonce, consumed before the transfer, makes each signature authorize exactly one use.

Note

Limits: the library handles EOA (secp256k1) signatures; smart-contract signers need ERC-1271 and a different verification path. The nonce must be strictly managed (ordered or tracked in a set); and EIP-712 only binds the domain correctly if name/version/chainid/address are set as intended — a mis-set domain re-opens cross-domain reuse.

Verifying the mitigation

Verify on a local chain: submit a valid signature, then its computed low-s sibling, and confirm the second reverts (library rejects high-s and the nonce is consumed); submit a malformed signature and confirm ECDSA.recover reverts rather than yielding address(0); submit a signature built for another chain id or contract and confirm rejection; replay an already-used nonce and confirm it reverts. Report "no forgery or replay reproduced within the tested scope", never immunity.

Note

Verifying EOA paths does not cover ERC-1271 smart-contract signers; test those separately if the contract accepts them.

Limitations & honesty

  • ▸Reproduction covers the signature schemes and argument combinations exercised; untried paths (e.g. ERC-1271) stay UNKNOWN.
  • ▸A correct library removes malleability/zero-address, but a mis-set EIP-712 domain or mis-managed nonce still allows replay.
  • ▸A clean run is UNKNOWN, not a guarantee of correct verification everywhere.
  • ▸No figures, incident counts, or CVE/DOI identifiers are asserted beyond the referenced, verifiable sources (SWC-117/121 named as signature issues).

References

  1. [1]EIP-712: Typed structured data hashing and signing
  2. [2]EIP-2612: permit — signed approvals for ERC-20
  3. [3]EIP-2098: Compact signature representation
  4. [4]OpenZeppelin — ECDSA & EIP712 utilities
  5. [5]SWC-117: Signature Malleability
  6. [6]SWC-121: Missing Protection against Signature Replay Attacks
  7. [7]Solidity docs — ecrecover and cryptographic functions

References point to public, well-known technical sources for further study.

Related articles