Skip to main content
Sigvex

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

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 "@audited/utils/cryptography/ECDSA.sol";
import "@audited/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's 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’s library — 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 validationecrecover 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.

References