Understanding Signature Replay Attack

6 min read

Understanding how signature replay attacks occur in Solidity, and ways to prevent them.

The objective of this blog post is to provide a detailed understanding of the vulnerability caused by Signature Replay Attack and ways to prevent them. This is an addition to the series from our earlier blog, where we shared the details of Integer Overflow and Underflow, Front Running Attack, Understanding Block Timestamp Manipulation, Issues with Authorization Using tx.origin, Precision Loss in Solidity Operations, and issues with Visibility modifiers in Solidity

Introduction#

Digital signatures serve as a unique 'fingerprint' in the world of blockchain transactions, providing a means of authentication in an environment that heavily relies on trust. These signatures form the backbone of transactions in blockchain ecosystems, especially in the operation of smart contracts. Nevertheless, if the security measures in place for these smart contracts are not robust enough, they become potential targets for 'signature replay attacks'. Such attacks can enable unauthorized transactions and even the theft of funds, thereby posing a considerable risk to the overall security and trustworthiness of decentralized applications.

Vulnerability#

Let's assume that Bob, who is on the receiving end of Alice's transaction, has malicious intentions. Bob finds and copies Alice's signature—her unique digital "fingerprint"—from the previous transaction. If the smart contract's security is not up to par, Bob could exploit this captured signature, analogous to how an unscrupulous individual might use a copied house key to gain unauthorized entry.

What does Bob do next with Alice's captured signature? Bob opts to replay or duplicate this transaction on another chain—the Arbitrum network, which is also supported by the same DeFi app. The outcome is an unintended transfer of 50 ETH from Alice's account to Bob's, but this time on the Arbitrum network. This constitutes a signature replay attack: Alice, completely unaware of this covert activity, suffers an unforeseen loss without her knowledge or approval.

There are numerous factors that can render smart contracts vulnerable to signature replay attacks. One fundamental weakness is the lack of a 'nonce'. Essentially a unique identifier or a 'number used once' for each transaction, a nonce is integral to security. Its absence can allow someone like Bob to repeatedly exploit Alice's captured signature for fraudulent transactions.

Another potential weak point is the failure to implement specific checks for different blockchain networks. This situation can be likened to using the same type of lock on every door, rendering all doors accessible if a single key is copied. Hence, every transaction signature should also encapsulate a network ID, a unique identifier for the specific network, to prevent cross-chain misuse of signatures.

Moreover, the code of the smart contract itself might be vulnerable even with the nonce and network ID checks if it fails to correctly verify signatures. Therefore, it is vital to employ well-tested smart contract development frameworks such as OpenZeppelin to avoid making silly mistakes.

Attack Scenario 1: Nonce and Chain ID Missing#

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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Vault {
    using ECDSA for bytes32;

        address public owner;

    constructor() payable {
        owner = msg.sender;
    }

    function transfer(address to, uint amount, bytes[2]memory sigs) external {
        bytes32 hashed = computeHash(to, amount);
        require(validate(sigs, hashed), "invalid sig");

        (bool sent, ) = to.call{ value: amount } ("");
        require(sent, "Failed to send Ether");
    }

    function computeHash(address to, uint amount) public pure returns(bytes32) {
        return keccak256(abi.encodePacked(to, amount));
    }

    function validate(bytes[2]memory sigs, bytes32 hash) private view returns(bool) {
        bytes32 ethSignedHash = hash.toEthSignedMessageHash();

        address signer = ethSignedHash.recover(sigs[0]);
        bool valid = signer == owner;

        if (!valid) {
            return false;
        }

        return true;
    }
}

In the `transfer` function, the contract checks for the validity of the provided signatures by recovering the signer's address from the signed message hash and comparing it to the owner's address.

However, the code does not include any mechanism to check if a validly signed message is maliciously reused in a different context on other networks. A malicious attacker can thus capture a validly signed transaction and replay it multiple times, resulting in unauthorized transfers of funds.

The exploit arises from the fact that the contract only checks if the recovered signer's address matches the owner's address without considering whether the signature has been used before. As a result, an attacker can capture a legitimately signed transaction and repeatedly submit it to the contract, successfully executing the `transfer` function multiple times on other blockchain networks.

Attack Scenario 2: Chain ID Missing#

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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Vault {
    using ECDSA for bytes32;

    address public owner;
    mapping(bytes32 => bool) public executed;

    constructor() payable {
        owner = msg.sender;
    }

    function transfer(address to, uint amount, uint nonce, bytes[2] memory sigs) external {
        bytes32 hashed = computeHash(to, amount, nonce);
        require(!executed[hashed], "tx already executed");
        require(validate(sigs, hashed), "invalid sig");

        executed[hashed] = true;

        (bool sent, ) = to.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }

    function computeHash(address to, uint amount, uint nonce) public view returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), to, amount, nonce));
    }

    function validate(bytes[2] memory sigs, bytes32 hash) private view returns (bool) {
        bytes32 ethSignedHash = hash.toEthSignedMessageHash();

        address signer = ethSignedHash.recover(sigs[0]);
        bool valid = signer == owner;

        if (!valid) {
            return false;
        }

        return true;
    }
}

In the above example, the contract implements `nonce` which makes replaying existing signatures impossible because a `nonce` once used, becomes invalid for future transactions. Although the above contract is safe from signature-replay attacks on a single chain, it is still vulnerable to signature-replay attacks if it is deployed on multiple chains.

Secure Contract#

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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Vault {
    using ECDSA for bytes32;

    address public owner;
    mapping(bytes32 => bool) public executed;

    constructor() payable {
        owner = msg.sender;
    }

    function transfer(address to, uint amount, uint nonce, uint chainId, bytes[2] memory sigs) external {
        bytes32 hashed = computeHash(to, amount, nonce, chainId);
        require(!executed[hashed], "tx already executed");
        require(validate(sigs, hashed), "invalid sig");

        executed[hashed] = true;

        (bool sent, ) = to.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }

    function computeHash(address to, uint amount, uint nonce, uint chainId) public view returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), to, amount, nonce, chainId));
    }

    function validate(bytes[2] memory sigs, bytes32 hash) private view returns (bool) {
        bytes32 ethSignedHash = hash.toEthSignedMessageHash();

        address signer = ethSignedHash.recover(sigs[0]);
        bool valid = signer == owner;

        if (!valid) {
            return false;
        }

        return true;
    }
}

In the above example, the contract checks both `nonce` and `chainId` for the signature to be deemed valid. In contrast to previous examples where it would just ask a user for their signature, this strategy enables you to be a little more specific before asking a user to sign a transaction. By using `nonce` and `chainId` together in the signing process, you are providing a much more detailed payload to the user to sign. The chances of `nonce` and `chainId` being reused are extremely low.

Prevention#

There are several ways to prevent Signature Replay Attack in Solidity:

  • A nonce is a unique number that is created for each transaction. The use of a unique nonce for each transaction ensures that each transaction is distinct and cannot be replicated.
  • Using a time-based value as a component of the signature is another method to prevent the Signature Replay Attack. This ensures that each transaction is unique and cannot be replayed after a certain amount of time. This can be accomplished in Solidity by adding a timestamp to the signature.
  • Another way to prevent replay attacks is to use a chain-specific signature scheme such as EIP-155, which includes the chain ID in the signed message. This will prevent transactions signed on one chain from being valid on another chain with a different ID.

Conclusion#

In conclusion, signature replay attacks pose a significant threat to the security of smart contracts. It is crucial for smart contract developers and users to understand the risks associated with this vulnerability and take appropriate measures to mitigate them.

By reusing a valid signature, attackers can execute unauthorized transactions on the blockchain network, resulting in financial losses and other harmful consequences. However, by using unique nonces, time-based values, signature verification schemes, and well-audited frameworks like OpenZeppelin, developers can effectively deter signature replay attacks. 

By

Tags