Signature Malleability Remediation
How to prevent ECDSA signature malleability attacks by enforcing EIP-2 low-s values and tracking message hashes as nonces instead of signature hashes.
Signature Malleability Remediation
Overview
ECDSA signatures on the secp256k1 curve have an inherent malleability property: for any valid signature (r, s, v), the tuple (r, n - s, v') is also a mathematically valid signature for the same message and signer, where n is the secp256k1 curve order. Both forms recover to the same Ethereum address via ecrecover. Any contract that stores a signature hash as a “used” nonce to prevent replay attacks is vulnerable — an attacker observes the original signature on-chain, computes the malleable form, and replays the same logical operation under a different signature hash.
The vulnerability is classified under SWC-117. The Ethereum network itself enforces EIP-2 compliance (low-s) for externally submitted transactions, but smart contracts that call ecrecover directly do not inherit this protection unless they implement the check themselves.
Related Detector: Signature Replay Detector
Recommended Fix
Before (Vulnerable)
contract VulnerableAuth {
address public owner;
mapping(bytes32 => bool) public usedSignatures;
// Tracks signature hash as nonce — VULNERABLE to malleability
function executeWithSignature(
bytes32 messageHash,
uint8 v,
bytes32 r,
bytes32 s
) external {
address signer = ecrecover(messageHash, v, r, s);
require(signer == owner && signer != address(0), "Invalid signer");
// BUG: Attacker computes s' = n - s and v' = v ^ 1
// keccak256(r, s', v') != keccak256(r, s, v)
// So usedSignatures[sigHash'] is still false
bytes32 sigHash = keccak256(abi.encodePacked(r, s, v));
require(!usedSignatures[sigHash], "Already used");
usedSignatures[sigHash] = true;
_executeAction(messageHash);
}
}
After (Fixed)
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract SecureAuth is EIP712 {
using ECDSA for bytes32;
address public owner;
// Track MESSAGE content as nonce, not signature bytes
mapping(bytes32 => bool) public executedMessages;
constructor() EIP712("SecureAuth", "1") {}
function executeWithSignature(
bytes32 messageHash,
bytes calldata signature
) external {
// 1. Track the message hash — not the signature hash
// Both malleable forms of the signature produce the same message hash,
// so replay via the alternate form is blocked here.
require(!executedMessages[messageHash], "Already executed");
executedMessages[messageHash] = true;
// 2. Use OpenZeppelin ECDSA.recover — enforces EIP-2 low-s automatically.
// Reverts if s > n/2 or v is invalid.
address signer = messageHash.toEthSignedMessageHash().recover(signature);
require(signer == owner, "Invalid signer");
_executeAction(messageHash);
}
}
Alternative Mitigations
Include a sequential nonce inside the signed payload — this is the strongest defence and prevents all replay forms regardless of signature encoding:
contract NonceAuth {
address public owner;
mapping(address => uint256) public nonces;
function executeWithNonce(
bytes memory message,
uint256 nonce,
bytes calldata signature
) external {
// Nonce is part of the signed data — neither s-value form can be replayed
// once the nonce has been consumed.
require(nonce == nonces[msg.sender]++, "Invalid nonce");
bytes32 messageHash = keccak256(abi.encodePacked(
message,
nonce,
block.chainid, // Prevent cross-chain replay
address(this) // Prevent cross-contract replay
));
address signer = ECDSA.recover(
ECDSA.toEthSignedMessageHash(messageHash),
signature
);
require(signer == owner, "Invalid signer");
_executeAction(message);
}
}
EIP-712 typed structured data — provides domain separation and human-readable signing, and pairs well with OpenZeppelin’s ECDSA library:
contract EIP712Auth is EIP712 {
using ECDSA for bytes32;
bytes32 private constant ACTION_TYPEHASH =
keccak256("Action(bytes32 payload,uint256 nonce,address contract)");
mapping(address => uint256) public nonces;
constructor() EIP712("MyProtocol", "1") {}
function execute(bytes32 payload, bytes calldata signature) external {
bytes32 structHash = keccak256(abi.encode(
ACTION_TYPEHASH,
payload,
nonces[msg.sender]++,
address(this)
));
address signer = _hashTypedDataV4(structHash).recover(signature);
require(signer == owner, "Unauthorized");
_executeAction(payload);
}
}
Manual low-s enforcement without OpenZeppelin — if using ecrecover directly is required:
// secp256k1 curve order / 2 — the EIP-2 low-s upper bound
bytes32 constant SECP256K1_N_DIV_2 =
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
function recoverSafe(
bytes32 messageHash,
uint8 v,
bytes32 r,
bytes32 s
) internal pure returns (address) {
// Reject the high-s form — only one valid signature per (r, message) exists
require(uint256(s) <= uint256(SECP256K1_N_DIV_2), "Malleable signature");
require(v == 27 || v == 28, "Invalid v");
address signer = ecrecover(messageHash, v, r, s);
require(signer != address(0), "Invalid signature");
return signer;
}
Common Mistakes
Tracking keccak256(r, s, v) as the replay-prevention nonce — this is the root cause of the vulnerability. Always track the message content or payload hash, never the signature bytes.
Calling bare ecrecover without low-s validation — ecrecover accepts both the canonical (low-s) and malleable (high-s) forms of a signature. Pairing it with a message-hash nonce neutralises the threat, but explicit low-s enforcement provides defence in depth.
Omitting block.chainid and address(this) from the signed payload — even a non-malleable signature can be replayed on a different chain (cross-chain replay) or against a different contract with the same signing key if these fields are absent.
Using v == 0 || v == 1 — the EVM uses values 27 and 28 for v. Some libraries normalise to 0/1 internally, but the raw ecrecover precompile expects 27 or 28. Always validate v == 27 || v == 28 when calling ecrecover directly.