ECDSA (Elliptic Curve Digital Signature Algorithm) is a cryptographic algorithm widely used for signing and verifying messages in blockchain systems. It is built on top of elliptic curve cryptography, which uses the algebraic structure of elliptic curves over finite fields to provide security guarantees for digital signatures.
In blockchain ecosystems like Ethereum, ECDSA signatures are foundational to transaction authentication, access control, and off-chain authorization schemes. Smart contracts frequently rely on ecrecover to validate that a message was signed by a specific address. However, a subtle mathematical property of elliptic curves introduces a vulnerability that many developers overlook: signature malleability.
This article examines what signature malleability is, why it is dangerous for smart contracts, how attackers exploit it, and the proven solutions for eliminating this class of vulnerability.
What Is ECDSA?
ECDSA is the signing algorithm used across Ethereum and many other blockchain platforms. When a user signs a message or transaction, the algorithm produces a signature consisting of three components:
r- The x-coordinate of a random point on the elliptic curve, derived during the signing processs- A value computed from the message hash, the private key, and the random nonce used to generaterv- A recovery identifier (27 or 28 in Ethereum) that indicates which of two possible public keys corresponds to the signature
Together, (v, r, s) form the complete signature. Given a message hash and these three values, Ethereum's ecrecover precompile can recover the signer's address without needing the public key directly.
// Recover the signer address from a signature
address signer = ecrecover(messageHash, v, r, s);
The security of ECDSA depends on the assumption that producing a valid signature requires knowledge of the private key. This holds true. However, what many developers do not realize is that for any valid signature, a second mathematically valid signature exists - and producing it requires no knowledge of the private key at all.
Understanding Signature Malleability
Signature malleability is a property of ECDSA that arises from the mathematical symmetry of elliptic curves. Elliptic curves of the form y^2 = x^3 + ax + b are symmetric about the x-axis. This means that for every point (x, y) on the curve, the point (x, -y) also lies on the curve.
This symmetry has a direct consequence for signatures: if (r, s) is a valid ECDSA signature for a given message, then (r, -s mod n) is also a valid signature for the same message, where n is the order of the elliptic curve (for secp256k1, the curve used by Ethereum, n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141).
In practical terms, given a valid signature (v, r, s), anyone can compute an alternate valid signature as follows:
// Original valid signature: (v, r, s)
// Malleable counterpart:
uint8 v_malleable = v == 27 ? 28 : 27;
bytes32 s_malleable = bytes32(
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - uint256(s)
);
// r remains unchanged
// Both signatures recover to the same address:
ecrecover(hash, v, r, s) == ecrecover(hash, v_malleable, r, s_malleable);
The ecrecover function will verify both signatures as valid because both correspond to the same public key. The signer's address recovered from either signature is identical. This is not a bug in ecrecover - it is an inherent mathematical property of elliptic curve cryptography.
Signature malleability does not allow an attacker to forge signatures for arbitrary messages. The attacker cannot sign new messages - they can only produce an alternate encoding of an existing valid signature. The danger lies in smart contracts that treat signatures as unique identifiers.
Why Is Signature Malleability Dangerous?
The danger of signature malleability becomes apparent when smart contracts use signatures as unique identifiers to prevent replay or to track whether a specific authorization has been consumed. Consider this common pattern:
mapping(bytes => bool) public usedSignatures;
function claimReward(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external {
bytes memory signature = abi.encodePacked(r, s, v);
// Check if this signature has been used
require(!usedSignatures[signature], "Signature already used");
// Verify the signature
address signer = ecrecover(hash, v, r, s);
require(signer == authorizedSigner, "Invalid signer");
// Mark signature as used
usedSignatures[signature] = true;
// Execute reward logic
_sendReward(msg.sender);
}
This contract tracks used signatures to prevent double-claiming. However, an attacker who observes the original signature (v, r, s) can compute the malleable counterpart (v', r, s') and call claimReward a second time. The malleable signature is different bytes, so it passes the usedSignatures check. It also recovers to the same signer address, so it passes the verification check. The attacker claims the reward twice.
Real-World Attack Scenarios
Signature malleability enables several categories of attacks:
- Double-claiming rewards or airdrops: If a protocol uses signatures to authorize one-time claims and tracks "used" signatures rather than "used" messages, an attacker can claim multiple times with malleable signature variants.
- Replay attacks on off-chain authorizations: Many DeFi protocols use signed messages for gasless transactions (meta-transactions), permit approvals (EIP-2612), and order books. If the replay protection relies on signature uniqueness, malleability breaks it.
- Transaction ID manipulation: Historically, Bitcoin was affected by transaction malleability where the transaction hash could be changed by modifying the signature. This disrupted systems that tracked transactions by their hash before confirmation.
- Bypassing signature-based access control: Any smart contract function that gates access based on a unique signature and marks that signature as consumed is potentially vulnerable.
The vulnerability is subtle because the contract logic appears correct at first glance. The signature verification succeeds, the signer is correct, and the replay check is in place. The issue is that the replay check uses the wrong identifier - the signature bytes rather than the signed message.
EIP-2098 Compact Signatures and Additional Risk
EIP-2098 introduced a compact signature format that encodes (r, s, v) into 64 bytes instead of the traditional 65 bytes. It does this by embedding the v recovery bit into the top bit of the s value.
This introduces an additional malleability vector. A signature can be submitted in either the traditional 65-byte format or the compact 64-byte EIP-2098 format. If a contract accepts both formats and tracks signatures by their raw bytes, an attacker can submit the same logical signature in two different encodings.
This exact issue affected OpenZeppelin's ECDSA library prior to version 4.7.3. The ECDSA.recover and ECDSA.tryRecover functions that accepted a single bytes argument were vulnerable to this format-switching attack. The security advisory GHSA-4h98-2769-gh6h documents this in detail.
// Traditional 65-byte signature: abi.encodePacked(r, s, v)
// EIP-2098 compact 64-byte signature:
// - First 32 bytes: r
// - Next 32 bytes: vs (v is encoded in the top bit of s)
// Converting between formats creates two different byte sequences
// that both represent the same logical signature.
// Systems that track "used signatures" by bytes are vulnerable.
Common Vulnerable Patterns
The following patterns are vulnerable to signature malleability and should be avoided:
Pattern 1: Tracking Used Signatures by Bytes
// VULNERABLE - Do not use
mapping(bytes => bool) public usedSignatures;
function executeWithSig(bytes calldata signature, bytes32 hash) external {
require(!usedSignatures[signature], "Already used");
address signer = ECDSA.recover(hash, signature);
require(signer == authorizedSigner, "Invalid");
usedSignatures[signature] = true;
// ... execute logic
}
Pattern 2: Raw ecrecover Without s-Value Validation
// VULNERABLE - Do not use
function verifySignature(bytes32 message, uint8 v, bytes32 r, bytes32 s)
public pure returns (bool)
{
address signer = ecrecover(message, v, r, s);
return signer != address(0);
}
This function accepts any valid s value across the full curve order. Both s and its complement n - s will pass verification, allowing two distinct valid signatures for every signed message.
Pattern 3: Missing Nonce in Signed Data
Even with s-value checks, if the signed message does not include a unique nonce, the same authorization can be replayed. Proper replay protection requires both canonical signature enforcement and nonce-based message uniqueness.
Solutions and Mitigations
Solution 1: Use OpenZeppelin's ECDSA Library
The most straightforward and recommended solution is to use OpenZeppelin's ECDSA library, which handles signature malleability automatically. The library enforces that the s value falls within the lower half of the curve order, rejecting any signature where s exceeds secp256k1n / 2.
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SecureSignatureVerifier {
using ECDSA for bytes32;
mapping(bytes32 => bool) public usedHashes;
function verifyAndExecute(
bytes32 messageHash,
bytes memory signature
) external {
// Track by message hash, not by signature bytes
require(!usedHashes[messageHash], "Already executed");
// ECDSA.recover enforces canonical s-value
address signer = messageHash.toEthSignedMessageHash().recover(signature);
require(signer == authorizedSigner, "Invalid signer");
usedHashes[messageHash] = true;
// ... execute logic
}
}
OpenZeppelin's ECDSA library implements the following checks internally:
- s-value range check: Requires
s <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0(the lower half of the curve order) - v-value validation: Restricts
vto exactly 27 or 28 - Zero address check: Rejects signatures that recover to
address(0)
Always use OpenZeppelin contracts version 4.7.3 or later to include the EIP-2098 compact signature malleability fix. Earlier versions were vulnerable to format-switching attacks even with the s-value check in place.
Solution 2: Manual s-Value Validation
If you cannot use OpenZeppelin's library (for example, in a non-standard environment or a gas-optimized contract), you can implement the check manually:
function verifySignature(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) public pure returns (address) {
// Enforce lower-half s-value (canonical form)
require(
uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"Invalid signature: s-value too high"
);
// Enforce valid v-value
require(v == 27 || v == 28, "Invalid signature: v-value");
// Recover signer
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "Invalid signature: zero address");
return signer;
}
The constant 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 is exactly secp256k1n / 2. By requiring s to be at or below this value, only one of the two possible s values is accepted for any given signature. Most cryptographic libraries already produce signatures with s in the lower half, so legitimate signers are unaffected.
Solution 3: Track Message Hashes Instead of Signatures
The most architecturally sound approach is to never use signature bytes as identifiers at all. Instead, track the message hash (or a nonce embedded in the message):
// SECURE - Track by message hash with nonce
mapping(bytes32 => bool) public executedMessages;
mapping(address => uint256) public nonces;
function executeOrder(
address user,
uint256 amount,
uint256 nonce,
uint8 v,
bytes32 r,
bytes32 s
) external {
// Build the message hash with domain-specific data
bytes32 messageHash = keccak256(
abi.encodePacked(user, amount, nonce, address(this), block.chainid)
);
// Verify nonce (prevents replay even without signature tracking)
require(nonce == nonces[user], "Invalid nonce");
nonces[user]++;
// Track by message hash as additional safety
require(!executedMessages[messageHash], "Already executed");
executedMessages[messageHash] = true;
// Verify signature with canonical check
address signer = _verifySignature(messageHash, v, r, s);
require(signer == user, "Invalid signer");
// Execute the order
_processOrder(user, amount);
}
This approach is resilient because even if an attacker produces a malleable signature variant, the message hash and nonce remain identical. The replay protection catches the duplicate regardless of which signature encoding is used.
Historical Context: Bitcoin's Transaction Malleability
Signature malleability is not a new problem. Bitcoin suffered from transaction malleability for years before the issue was addressed by Segregated Witness (SegWit) in 2017. The attack worked because Bitcoin's transaction ID (txid) was computed from the entire transaction, including the signature. An attacker could intercept a broadcast transaction, modify its signature to the malleable counterpart, and rebroadcast it. If the modified version was mined first, the transaction had a different txid than expected.
This caused practical problems for exchanges and wallets that tracked deposits by transaction ID. The Mt. Gox exchange famously cited transaction malleability as a contributing factor in its collapse, though the full picture was more complex.
SegWit fixed the issue by moving signature data outside the transaction hash computation, ensuring that txids are stable regardless of signature encoding. Ethereum's transaction model is not affected in the same way because transaction hashes in Ethereum include the sender's address (derived from the signature) rather than the raw signature bytes. However, smart contracts that implement their own signature-based logic face exactly the same class of vulnerability.
Audit Checklist for Signature Malleability
When auditing smart contracts for signature malleability, we check for the following at Zokyo:
- Is
ecrecoverused directly? If the contract callsecrecoverwithout wrapping it in OpenZeppelin's ECDSA library, check whethers-value validation is implemented manually. - Are signatures used as unique identifiers? Search for mappings keyed by
bytes(signature bytes) rather thanbytes32(message hashes). Anymapping(bytes => bool)that tracks "used signatures" is a red flag. - Does the contract accept both compact and standard signatures? If yes, verify that format-switching cannot bypass replay protection.
- Is there proper nonce management? Even with canonical signature checks, contracts that accept signed messages without nonces are vulnerable to simple replay attacks (a separate but related issue).
- Does the signed data include chain ID and contract address? Without domain separation, signatures valid on one chain or contract can be replayed on another (cross-chain replay).
- Is the OpenZeppelin version up to date? Contracts using OpenZeppelin ECDSA prior to version 4.7.3 are vulnerable to the EIP-2098 compact signature malleability vector.
EIP-712 Typed Structured Data
For modern smart contract development, EIP-712 provides a standardized approach to signing structured data that naturally incorporates domain separation. An EIP-712 signature includes:
- Domain separator: Encodes the contract name, version, chain ID, and contract address
- Type hash: A schema descriptor for the signed data structure
- Message data: The actual payload being signed
Using EIP-712 in combination with OpenZeppelin's ECDSA library and nonce-based replay protection provides defense in depth against signature malleability and related attacks:
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SecurePermit is EIP712 {
using ECDSA for bytes32;
bytes32 private constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce)"
);
mapping(address => uint256) public nonces;
constructor() EIP712("SecurePermit", "1") {}
function permit(
address owner,
address spender,
uint256 value,
uint8 v,
bytes32 r,
bytes32 s
) external {
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++)
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = digest.recover(v, r, s);
require(signer == owner, "Invalid signature");
// Grant approval...
}
}
Summary of Best Practices
Signature malleability is a well-understood vulnerability with straightforward solutions. The key principles for eliminating this attack surface are:
- Never use signature bytes as unique identifiers. Track consumed authorizations by message hash or nonce, not by the raw signature encoding.
- Always enforce canonical signatures. Use OpenZeppelin's ECDSA library (version 4.7.3+) or manually validate that
s <= secp256k1n / 2andvis 27 or 28. - Include nonces in signed messages. Even with canonical signature enforcement, nonces provide an independent layer of replay protection.
- Use EIP-712 for structured signing. Domain separation prevents cross-chain and cross-contract replay.
- Keep dependencies updated. The EIP-2098 compact signature vulnerability in OpenZeppelin pre-4.7.3 demonstrates that even well-audited libraries can have malleability issues.
At Zokyo, signature malleability is part of our standard audit checklist for every EVM smart contract engagement. While the vulnerability is well-documented, we continue to find it in production code - particularly in custom signature verification logic that bypasses established libraries. The fix is simple, but it requires awareness that the problem exists in the first place.
If you are building a protocol that relies on off-chain signatures, reach out to our team for a comprehensive security review.