Signature Replay Vulnerabilities in Smart Contracts

Stefan Beyer
Apr 15 · 5 min read

Cryptographic signatures are a fundamental building block of blockchains. Transactions are signed with the private keys corresponding allowing the transaction senders to be linked to their account. Without this feature, the Blockchain’s bookkeeping would simply not work.

Digital signatures are also often verified directly in smart contracts deployed on Ethereum, in order to allow one or more verifiers to authorize actions by submitting signatures created off-chain (or even signatures generated by another smart contract). This is commonly used in multi-signature vaults or voting contracts, in order to submit various signatures together or delegate authorization.

A common vulnerability in such implementations is the potential for signature replay attacks. Here at , we recently came across an interesting instance of this problem in a smart contract audit for a high-profile project. In this article, we will use this example to illustrate how things can go wrong with signature verifications in smart contracts.

Vulnerabilities related to signature verifications are usually caused by misunderstanding the underlying cryptographic principles and the purpose of signatures. So before getting into details of this particular vulnerability, let’s have a quick look at how cryptographic signatures work.

Cryptographic Signatures

Most cryptographic signature schemes rely on public and private key pairs. Data can be signed with a private key and this signature can be verified with the corresponding public key. As the naming suggests, a user’s public key is publicly distributed, whereas the private key must be kept secret.

Cryptographically signing data enables two important properties:

  • The data signer can be identified. This is achieved by the ability to recover the signer’s public key.
  • Data integrity can be verified, meaning the signature can be used to prove that the data has not been modified since signing.

While these are very powerful properties, it is important to note that cryptographically signing data does not provide any additional insurances on its own. The signature does not guarantee a message’s uniqueness, nor does it mean that the sender of the message is the signer of the message. Of course, cryptographic signatures can be used to establish these facts, but the necessary checks still have to be performed by the application. Let’s investigate this fact in the case of Ethereum smart contracts.

Signature Verification in Ethereum

, like , uses the ), together with the curve. Smart contracts have access to the built-in ECDSA signature verification algorithm through the system method ecrecover. The following example illustrates the use of this function:

address signer = ecrecover(msgHash, v, r, s);

The method takes the signature values v, r and s and the hash of the signed data as arguments. It validates the integrity of the data, meaning that the signature corresponds to the hash of the data and recovers the signer’s address (Ethereum addresses are derived from public keys).

Any additional checks on whether this signer address corresponds to the expected address, or whether the signed message is unique, have to be added by the smart contract programmer. Misunderstanding this behavior of ecrecoverfrequently leads to security vulnerabilities.

Signature Replay Vulnerability

Code Example

Let’s look at the bug we encountered in the recent audit mentioned above:

function unlock(
address _to,
uint256 _amount,
uint8[] _v,
bytes32[] _r,
bytes32[] _s
)
external
{
require(_v.length >= 5);
bytes32 hashData = keccak256(_to, _amount); for (uint i = 0; i < _v.length; i++) {
address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
require(_isValidator(recAddr));
}
to.transfer(_amount);
}

The above code is a simplification of the code we audited. It has been reduced to the bare essentials to make it shorter and easier to understand. However, the actual vulnerability remains untouched.

The audited contract is part of a cross-blockchain relay bridge that allows moving digital assets from one blockchain to another. Ether is locked in the contract when the equivalent asset is created on the other chain. The unlockfunction releases previously locked Ether to an address at the same time the asset is locked or destroyed in the other chain. To this end, across-chain relayer can submit an array of validator signatures, an amount to be unlocked, and a destination address. The function requires at least five signatures to unlock the amount requested and send it to the recipient. In addition, all signatures submitted need to be valid and come from designated validators. The internal _isValidator function (implementation omitted for brevity) checks whether an address is, in fact, a validator.

Attack Scenario

The problem with the above code is in the message that is signed by the validators using the ECDSA algorithm. The message only contains the receiver address and the amount to be unlocked. There is nothing in the message that could be used to prevent the same signatures to be used multiple times. Imagine the following scenario:

  • Bob has the equivalent of 10 ETH on the connected chain, which he moves back across the bridge onto the Ethereum chain.
  • Alice is a relayer that processes this cross-blockchain transaction. She collects the necessary validator signatures, locks the appropriate amount on the linked chain and calls the unlock function causing 10 ETH to be freed from the contract and sent to Bob.
  • The transaction containing the arrays of signature values is publicly readable on the blockchain.
  • Bob can now copy the signature arrays and submit an identical unlockcall himself. This unlocking operation will succeed again, causing another 10 ETH to be sent to Bob.
  • Bob can repeat this process until the contract has been drained.

Mitigation

The above scenario is called a signature replay attack. It is possible because there is no way of checking the uniqueness of this particular signed message or whether the signed message has been used before.

A simple way to avoid this type of attack is to include a sequential message number or nonce in the signed data. The fixed version of the above code would be as follows:

public uint256 nonce;function unlock(
address _to,
uint256 _amount,
uint256 _nonce,
uint8[] _v,
bytes32[] _r,
bytes32[] _s
)
external
{
require(_v.length >= 5);
require(_nonce == nonce++);
bytes32 hashData = keccak256(_to, _amount, _nonce); for (uint i = 0; i < _v.length; i++) {
address recAddr = ecrecover(hashData, _v[i], _r[i], _s[i]);
require(_isValidator(recAddr));
}
to.transfer(_amount);
}

This code now requires a sequence number for each successful unlock call. This unique number is included in the signed message, making the signatures required for each successful call unique. This means that a previously observed message is of no use to an attacker, as replaying it would fail.

Signature Best Practice

The above example is just one example, in which non-unique signatures can be replayed. In most scenarios, it is important to make sure signatures are uniquely matched to each call, in order to prevent replay attacks. This is also the reason Ethereum transactions themselves include a nonce (just like in our solution above).

However, the code is not yet perfect. It does not follow the recommended best practice for signature verification. The reason for this is that it does not check for malleable signatures. s values that are part of accepted signatures should be checked to be in lower ranges. The recommended procedure for using the ecrecoverfunction can found in In fact, building on community audited code, such as , is always a good idea.

Are you building a blockchain-based application? , if you wish to speak about security or any other blockchain related topic.

Cryptonics

Blockchain Technology Insights

Stefan Beyer

Written by

Computer Scientist with research background in Operating Systems, Distributed Systems, Fault Tolerance and Cybersecurity.

Cryptonics

Blockchain Technology Insights