ecrecover and Signature Malleability
ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address) is the EVM precompile that recovers the address that signed a 32-byte hash with ECDSA over secp256k1. It is the foundation of virtually every signature scheme on Ethereum. It is also subtle enough that misuse is one of the most common audit findings in the entire industry.
The Primitive
ecrecover does not verify a signature against a known signer. It recovers the address that produced the signature, given the hash and signature components, and returns it. The caller is then responsible for comparing the returned address to whatever address was expected.
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s, address expected) internal pure returns (bool) {
address recovered = ecrecover(hash, v, r, s);
return recovered == expected && recovered != address(0);
}
Two failure modes hide in this two-line function. They appear constantly in audits.
Failure Mode 1: Forgetting the address(0) Check
If ecrecover is given invalid inputs (malformed v, hash collision after attacker manipulation, etc.), it returns address(0). If the contract is checking recovered == expected and the attacker can manipulate expected to also be address(0) (e.g., uninitialized state, default mapping value, freshly constructed struct), the check passes trivially.
// Vulnerable:
mapping(uint256 => address) public proposer; // defaults to address(0)
function verifyProposal(uint256 id, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external view returns (bool) {
return ecrecover(hash, v, r, s) == proposer[id]; // both can be address(0)
}
If proposer[id] has never been set for some id, and the attacker submits a malformed signature, ecrecover returns address(0) and the check passes. Any function downstream that trusts this verification is fooled.
Fix:
address recovered = ecrecover(hash, v, r, s);
require(recovered != address(0), "invalid signature");
require(recovered == expected, "wrong signer");
Or use OpenZeppelin's ECDSA.recover, which reverts on address(0) internally.
Failure Mode 2: Signature Malleability
ECDSA over secp256k1 has a structural quirk: for any valid signature (v, r, s), the signature (v', r, n - s) (where n is the curve order and v' is the flipped recovery byte) is also a valid signature for the same hash by the same signer.
This means: the same signed message produces two valid 65-byte signatures. Anyone observing one can compute the other without knowing the private key.
For most signature use cases — proving authentication, authorizing a one-shot action — this doesn't matter, because the contract enforces single-use via a nonce or by consuming the signed message. But if the contract uses the signature itself as a unique identifier — for example, storing the signature in a mapping to track "already used" status, or hashing the signature into a transaction ID — malleability lets an attacker take a legitimate signature, transform it, and reuse it as a "new" signed message.
The most famous instance: Bitcoin's transaction-malleability era pre-SegWit, where transaction IDs (which included signatures) could be mutated by intermediaries, breaking some protocols that referenced transactions by ID.
In EVM:
// Vulnerable: signature used as a unique identifier
mapping(bytes => bool) public usedSignatures;
function claim(bytes32 hash, uint8 v, bytes32 r, bytes32 s, bytes calldata signature) external {
require(!usedSignatures[signature], "used");
address recovered = ecrecover(hash, v, r, s);
require(recovered == authorized, "bad signer");
usedSignatures[signature] = true;
// ... claim funds
}
An attacker observes a legitimate (v, r, s), computes the malleable variant (v', r, n - s), calls claim with the new signature — usedSignatures[newSignature] is false, the signature still recovers the legitimate signer, and the claim succeeds a second time.
EIP-2: The s-Value Restriction
EIP-2 (Homestead, 2016) addressed signature malleability in transactions by restricting s to the lower half of the curve order. Of the two valid signatures (v, r, s) and (v', r, n - s), only the one with s <= n/2 is canonical; the other is rejected.
This restriction applies at the transaction level — transactions with high-s signatures are rejected by consensus. But ecrecover itself, exposed as a precompile, does not enforce the restriction. A contract that uses ecrecover to verify off-chain signatures is responsible for enforcing the low-s rule itself:
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, "high s");
require(v == 27 || v == 28, "bad v");
address recovered = ecrecover(hash, v, r, s);
require(recovered != address(0), "invalid sig");
OpenZeppelin's ECDSA.recover (since v4.7) enforces both s ≤ n/2 and v ∈ {27, 28}, and rejects address(0). Use it; do not roll your own.
Failure Mode 3: Using ecrecover Directly Without OpenZeppelin
Code that calls ecrecover directly should be flagged immediately for review. The questions:
- Is the
svalue validated to be in the lower half? - Is the
vvalue validated to be 27 or 28? (Some libraries normalize to 0/1; the precompile expects 27/28.) - Is the recovered address checked against
address(0)? - Is the hash being passed actually what it should be (EIP-191 prefix, EIP-712 typehash, etc.)?
The fix in 99% of cases is: replace direct ecrecover with OpenZeppelin's ECDSA.recover(hash, signature) or ECDSA.tryRecover(...). This is a routine remediation in audit reports.
Compact Signatures (EIP-2098)
EIP-2098 defines a 64-byte "compact signature" representation: r and vs (where vs packs v into the top bit of s). It saves 1 byte and is increasingly used in calldata-sensitive contexts (rollups, gas-optimized signature verification).
Audit notes:
- The verification logic must correctly unpack
vsintovands. - The unpacked
sis implicitly in the lower half (because the top bit is the flipped recovery bit), which gives malleability resistance for free — but only if the unpacker is correct. - OpenZeppelin's
ECDSA.recover(hash, r, vs)overload handles this; custom unpackers should be reviewed carefully.
verifyingContract Confusion
A subtle bug class: a contract that verifies signatures for itself (e.g., a Permit-style approval) hashes the signed message together with address(this) for domain separation. If the contract is deployed at multiple addresses (e.g., proxy + implementation, or factory-deployed instances), and the user signs against one address but the code on a different address checks the signature, the verification fails legitimately — but in some cases the implementation's address(this) differs from the proxy's address(this) (e.g., when the implementation is called directly). Always verify which contract's address is in the domain separator and that the signing UI displays the right one.
This is particularly an issue for cloned contracts (EIP-1167 minimal proxies) where many instances share the same implementation; each clone should compute its domain separator dynamically using address(this) at runtime, not at deploy time.
Auditor Quick Reference
When reviewing any function that calls ecrecover:
-
Uses
ECDSA.recoverfrom OpenZeppelin (or equivalent vetted library) rather than rawecrecover. -
If using raw
ecrecover: validatess ≤ n/2, validatesv ∈ {27, 28}, rejectsaddress(0). - If signatures are stored or used as identifiers: a single-use marker is keyed on the message (or nonce), not on the signature bytes.
- The hash being verified includes domain separation (chain ID, contract address, function-specific typehash).
-
Returns or behavior on invalid signature is
revert, not silent acceptance. -
No
unchecked { }block wrapping the recovery or the signer check.
Get these right and ecrecover is safe. Get any of them wrong and the system has a signature bug, full stop.