Smart Contract Replay Attack in Solidity

This post provides insights into the replay attack in blockchains.

As per the wiki, a replay attack is a valid data transmission that is maliciously or fraudulently repeated or delayed.

In the case of blockchains, a replay attack means taking a transaction on a  blockchain and maliciously or fraudulently repeating it on the same or another blockchain.

The post starts with different scenarios where a replay attack can happen, followed by an example contract of a replay attack, and then finally the solution.

Let’s go! πŸƒβ€β™€οΈ

Replay Attack Scenarios

Replay attack scenarios can be best explained with MultiSig wallets.

Consider a Multisig wallet with a balance of 2 ETH and two admins or owners. Let’s call them Nils and Pils.

If Nils wants to withdraw 1 ETH from the wallet:

  • Nils can send a transaction to the contract for approval
  • followed by second transaction for approval by Pils
  • finally, another transaction to actually withdraw 1 ETH

Totally, three transactions for a single withdrawal. This is very inefficient and costly as you have to pay gas for every transaction. This is depicted as shown.

Fig: Three transactions for a single withdraw

Instead, a single transaction can be sent for withdrawal if Pils signs a message – β€œNils can withdraw 1 ETH from the wallet and signs it”  and sends the signature to Nils.

Nils can then add his signature along with the signature from Pils and send a single transaction to the wallet for withdrawal of 1 ETH as shown below.

Fig: Single transaction using signatures for withdraw

Now we know why it becomes necessary to sign a message off-chain. Even in the case of online wallets like Metamask we are signing a transaction off-chain using the wallet’s private keys.

Based on the off-chain signing for the Multisig wallet there can be three scenarios of replay attacks.

  • By taking a signed message off-chain and reusing it to claim authorization for a second action on the same contract.
  • Similar to the first, but it involves taking the same contract code on a different address.
  • Lastly, a replay attack can be done using a combination of CREATE2 (EVM opcode to create a contract) and self-destruct using kill(). After self-destructing, CREATE2 can be used again to recreate a new contract at the same address and then reuse the old messages again.

Exploit

For the exploit consider a MultiSig wallet contract. We can use the file name as MultiSig.sol.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import "@openzeppelin/contracts/utils/Address.sol";

contract MultiSig {
  using Address for address payable;
  address[2] public owners;

  struct Signature {
    uint8 v;
    bytes32 r;
    bytes32 s;
  }

  constructor(address[2] memory _owners) {
         owners = _owners;
  }

  function transfer(
    address to,
    uint256 amount,
    Signature[2] memory signatures
  ) external {
           require(verifySignature(to, amount, signatures[0]) == owners[0]);
           require(verifySignature(to, amount, signatures[1]) == owners[1]);

           payable(to).sendValue(amount);
  }

  function verifySignature(
    address to,
    uint256 amount,
    Signature memory signature
  ) public pure returns (address signer) {
         // 52 = message length
          string memory header = "\x19Ethereum Signed Message:\n52";

    // Perform the elliptic curve recover operation
          bytes32 messageHash = keccak256(abi.encodePacked(header, to, amount));

         return ecrecover(messageHash, signature.v, signature.r, signature.s);
  }

         receive() external payable {}
}

In the above contract, the transfer() function verifies if the given signatures match the owners, and on success, it transfers the amount to the address given by β€˜to’.

The details inside the verifySignature() function can be ignored as is outside the scope of this post, but in brief, it calculates and returns the signature from the given inputs (to, amount) using the Elliptical curve cryptography technique.

The above contract is prone to the replay attack because the transfer function can be called again and again with the same set of inputs to, amount and signatures.

Preventing the Attack

To prevent the replay attack, the following changes can be made

  1. Pass a nonce as an input to the transfer() function. As the nonce values are different each time, it helps create a unique message hash or in other words unique signature each time, thus preventing a replay attack on the same contract.
  2. Use address(this) as a param to calculate the message hash in keccak256(abi.encodePacked()) . This results in a unique per contract message hash, preventing the replay attack on a different address.

Thus the updated MultiSig.sol contract as given below:

//SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import "@openzeppelin/contracts/utils/Address.sol";

contract MultiSig {
  using Address for address payable;
  address[2] public owners;
  mapping(bytes32 => bool) executed;

  struct Signature {
    uint8 v;
    bytes32 r;
    bytes32 s;
  }

  constructor(address[2] memory _owners) {
         owners = _owners;
  }

  function transfer(
    address to,
    uint256 amount,
    uint256 nonce,
    Signature[2] memory signatures
  ) external {
         address sign1;
         address sign2;
         bytes32 txhash1;
         bytes32 txhash2;
         (txhash1, sign1) = verifySignature(to, amount, nonce, signatures[0]);
         (txhash2, sign2) = verifySignature(to, amount, nonce, signatures[1]);

         require(!executed[txhash1] && !(executed[txhash2]), "Signature expired");          executed[txhash1] = true;          executed[txhash2] = true;
         payable(to).sendValue(amount);
 }

  function verifySignature(
    address to,
    uint256 amount,
    uint256 nonce,
    Signature memory signature
  ) public view returns (bytes32 msghash, address signer) {
         // 52 = message length
          string memory header = "\x19Ethereum Signed Message:\n52";

    // Perform the elliptic curve recover operation
          bytes32 messageHash = keccak256(abi.encodePacked(address(this), header, to, amount, nonce));

          return (messageHash, ecrecover(messageHash, signature.v, signature.r, signature.s));
  }

    receive() external payable {}
}

Outro

In this last post of the smart contract security series, we discussed the replay attack on a Multisig contract and how using nonce can prevent the attack by creating a unique signature.

In all the last eight posts that we covered so far can be considered the most common and dominant vulnerabilities for smart contract security.

I hope this security series helps you in writing more safe, better and secured Solidity contracts. Happy preventing hacks! πŸ™‚