Web3 & smart contracts
Verified by ROOTVIEL13 minECDSA 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:
- 1A contract records used signatures by hashing the signature bytes, to prevent replay.
- 2A legitimate user submits a valid signature (r, s, v); the contract records hash((r,s,v)) as used.
- 3The attacker computes the sibling (r, n − s, v′), which verifies to the same signer but hashes differently.
- 4The attacker submits the sibling; the used-signature check does not match, so it passes.
- 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 { /* ... */ } }
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.
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.
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.
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]EIP-712: Typed structured data hashing and signing
- [2]EIP-2612: permit — signed approvals for ERC-20
- [3]EIP-2098: Compact signature representation
- [4]OpenZeppelin — ECDSA & EIP712 utilities
- [5]SWC-117: Signature Malleability
- [6]SWC-121: Missing Protection against Signature Replay Attacks
- [7]Solidity docs — ecrecover and cryptographic functions
References point to public, well-known technical sources for further study.
Related articles
