Signature malleability is a subtle but dangerous vulnerability that has plagued blockchain applications for years. While Bitcoin addressed this issue with BIP 62 and BIP 146, Ethereum smart contracts remain vulnerable unless developers explicitly implement protections. In this article, we’ll explore what signature malleability is, how attackers exploit it, and most importantly, how to defend against it in Solidity.

What is Signature Malleability?

ECDSA (Elliptic Curve Digital Signature Algorithm) signatures consist of two components: r and s.

For any given signature (r, s), there exists a mathematically equivalent signature (r, n - s) that validates against the same message and public key, where n is the order of the secp256k1 curve:

n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

This mathematical property creates what we call “signature malleability”, the ability to create multiple valid signatures for the same message without access to the private key.

The Attack Vector in Smart Contracts

Let’s have a look at the attack vector in a Solidity smart contract.

Vulnerable Contract Example

Let’s create a simple smart contract:

pragma solidity ^0.8.23;

contract SignatureMalleability {

    function checkSignatureUnsafe(bytes32 hash, bytes32 r, bytes32 s, uint8 v) public pure returns (address) {
        address recoveredAddress = ecrecover(hash, v, r, s);
        return recoveredAddress;
    }
}

Next a simple Python code which signs a message, shows the signature and then creates a malleable signature:

from web3 import Web3, EthereumTesterProvider
from eth_account.messages import encode_defunct

def to_32byte_hex(val):
  return Web3.to_hex(Web3.to_bytes(val).rjust(32, b'\0'))

w3 = Web3(EthereumTesterProvider())
msg = "I♥SF"
private_key = b"\xb2\\}\xb3\x1f\xee\xd9\x12''\xbf\t9\xdcv\x9a\x96VK-\xe4\xc4rm\x03[6\xec\xf1\xe5\xb3d"
message = encode_defunct(text=msg)
signed_message = w3.eth.account.sign_message(message, private_key=private_key)

recovered_address = w3.eth.account.recover_message(message, signature=signed_message.signature)
print(recovered_address)

ec_recover_args = (msghash, v, r, s) = (
  Web3.to_hex(signed_message.message_hash),
  signed_message.v,
  to_32byte_hex(signed_message.r),
  to_32byte_hex(signed_message.s),
)

print(f"Hashed message: {msghash}")
print(f"v: {signed_message.v}")
print(f"r: {to_32byte_hex(signed_message.r)}")
print(f"s: {to_32byte_hex(signed_message.s)}")

# order of the secp256k1 curve - it represents the number of points in the cyclic subgroup generated by the base point G.
# In elliptic curve cryptography, the order n is a fundamental parameter that defines:
# The size of the scalar field - all private keys and signature components (like s) are integers modulo n
n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
print("\nflipping s value")
s_flipped = (n - signed_message.s)
flippev_v = 28 if signed_message.v == 27 else 27

print(f"flipped s: {to_32byte_hex(s_flipped)}")
print(f"flipped v: {flippev_v}")

You can use Remix to deploy the vulnerable contract and interact with it. All you will have to do is to copy the hash, v, r, and s values from the Python output and paste them into the Remix interface to call the checkSignatureUnsafe function.

Since the private key is provided you should get the following address: 0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E

In the next step copy the flipped s value and the flipped v value from the Python output and paste them into the Remix interface to call the checkSignatureUnsafe function again. You should get the same address: 0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E

This demonstrates that the contract does not differentiate between the original and malleable signatures, allowing an attacker to exploit this vulnerability.

How the Attack Works

An attacker can exploit this contract through the following steps:

Obtain a valid signature: The attacker gets a legitimate signature (r, s, v) for a withdrawal Create malleable signature: Calculate the alternative signature (r, n - s, v') where:

n - s is the malleable s value
v' is adjusted accordingly (if v was 27, v' becomes 28, and vice versa)

Real-World Impact

Signature malleability attacks have led to significant losses in DeFi protocols:

  • Replay attacks: Users can execute the same action multiple times
  • Double spending: Withdrawal functions become vulnerable to duplicate executions
  • Nonce bypassing: Systems relying on signature uniqueness for ordering fail
  • Authorization bypass: Multi-signature wallets may accept the same approval twice

Defense Strategies

  1. Low-S Enforcement (Recommended) The most robust defense is enforcing that s values are in the lower half of the curve order:

     pragma solidity ^0.8.23;
    
     contract SignatureMalleabilitySafe {
    
         uint256 private constant CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
         uint256 private constant HALF_CURVE_ORDER = CURVE_ORDER / 2;
    
         function checkSignatureSafe(bytes32 hash, bytes32 r, bytes32 s, uint8 v) public pure returns (address) {
             require(uint256(s) <= HALF_CURVE_ORDER, "Invalid signature: s value too high");
    
             address recoveredAddress = ecrecover(hash, v, r, s);
             return recoveredAddress;
         }
     }
    
  2. Using OpenZeppelin’s ECDSA Library OpenZeppelin provides a battle-tested implementation that includes malleability protection:

     import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
    
     contract SecureContractWithOZ {
         function checkSignatureSafe(bytes32 hash, bytes32 r, bytes32 s, uint8 v) public pure returns (address) {
    
             address recoveredAddress = ECDSA.recover(
                 hash, v, r, s
             );
             return recoveredAddress;
         }
     }
    

Best Practices for Developers

  • Always use low-s enforcement or established libraries like OpenZeppelin’s ECDSA
  • Implement proper replay protection using message hashes rather than signature hashes
  • Include nonces or timestamps in signed messages to prevent replay attacks
  • Test with both high and low s values during development
  • Audit signature verification logic thoroughly before deployment
  • Consider using EIP-712 for structured data signing with built-in domain separation

Conclusion

Signature malleability represents a critical vulnerability that every Solidity developer must understand and defend against. While the mathematical properties of ECDSA make this attack possible, proper implementation practices make it entirely preventable.

The key takeaways are:

  • Never rely on signature bytes for uniqueness
  • Always enforce low-s values or use trusted libraries
  • Implement replay protection using message content, not signature format
  • Test your implementation against malleable signatures

By following these practices, you can ensure your smart contracts remain secure against this subtle but dangerous attack vector. Remember: in blockchain security, the devil is truly in the mathematical details.