Skip to content

Signature-related Attacks

In smart contract systems, signatures are powerful tools that serve critical functions. The EVM features the ecrecover precompile, allowing for native signature validity checks and recovery. This function is predominantly used in the context of authorization, data validity checks, and facilitating gas-less transactions. However, signature systems in smart contracts can malfunction in various ways, often leading to severe consequences.

Missing Validation

One of the most common vulnerabilities is missing validation when ecrecover encounters errors and returns an invalid address.

1
2
3
4
function recover(uint8 v, bytes32 r, bytes32 s, bytes32 hash) external {
    address signer = ecrecover(hash, v, r, s);
    //Do more stuff with the hash
}

A crucial check for address(0) is absent in this instance. This omission allows an attacker to submit invalid signatures with arbitrary payloads yet pass as valid. A simple yet effective solution to this issue would be to include a check like the following:

1
require(signer != address(0), "invalid signature");

Even better, OpenZeppelin's ECDSA library should be used because it automatically reverts when invalid signatures are encountered.

Replay Attacks

Replay attacks occur when a signature and the system consuming it have no deduplication mechanism. A cause for replay attack vulnerabilities is when signatures are not properly invalidated or a nonce is absent from the system. The following examples underline the different attack angles on a smart contract signature system and its iterations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract OwnerAction {
    using ECDSA for bytes32;

    address public owner;

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

    function action(uint256 _param1, bytes32 _param2, bytes memory _sig) external {
        bytes32 hash = keccak256(abi.encodePacked(_param1, _param2));
        bytes32 signedHash = hash.toEthSignedMessageHash();
        address signer = signedHash.recover(_sig);

        require(signer == owner, "Invalid signature");

        // use `param1` and `param2` to perform authorized action
    }
}

In this scenario, an attacker possessing the owner's signature can perform the same action multiple times. For instance, if the owner signed a transaction authorizing a transfer of funds, the attacker could replay the signature and drain the contract by transferring funds repeatedly. To mitigate this issue, a mapping can invalidate each submitted signature after its first execution. However, a nonce must be encoded into the signed payload to allow the owner to sign the same action multiple times. The sole purpose of the nonce value is to change the final signature when the payload data remains the same.

The following code adds the signature invalidation mechanism as well as the nonce-related business logic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract OwnerAction {
    using ECDSA for bytes32;

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

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

    function action(uint256 _param1, bytes32 _param2, uint256 _nonce, bytes memory _sig) external {
        bytes32 hash = keccak256(abi.encodePacked(_param1, _param2, _nonce));
        require(!seenSignatures[hash], "Signature has been used");

        bytes32 signedHash = hash.toEthSignedMessageHash();
        address signer = signedHash.recover(_sig);
        require(signer == owner, "Invalid signature");

        seenSignatures[hash] = true;

        // use `param1` and `param2` to perform authorized action
    }
}

Even this enhanced contract is not entirely secure. If the system is deployed on multiple chains or if the signer address is used in other contexts on different chains, signature replay attacks are still a potential threat.

Cross-chain Replay Attacks

Cross-chain replay attacks arise when signatures can be reused across different blockchain systems. Once a signature has been used and invalidated on one chain, an attacker can still copy it, use it on another, and trigger an unwanted state change. This poses a significant threat to smart contract systems deployed across chains with identical code.

To mitigate this risk, the chain ID should be encoded in the signature payload and validated against the current chain ID where the action is executed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract OwnerAction {
    using ECDSA for bytes32;

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

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

    function action(uint256 _param1, bytes32 _param2, uint256 _nonce, uint256 _chainId, bytes memory _sig) external {
        require(_chainId == block.chainid, "Invalid chain ID");

        bytes32 hash = keccak256(abi.encodePacked(_param1, _param2, _nonce, _chainId));
        require(!seenSignatures[hash], "Signature has been used");

        bytes32 signedHash = hash.toEthSignedMessageHash();
        address signer = signedHash.recover(_sig);
        require(signer == owner, "Invalid signature");

        seenSignatures[hash] = true;

        // use `param1` and `param2` to perform authorized action
    }
}

It's worth noting that when signatures are used with EIP712 typed data payloads, the domain separator value already includes the chain ID.

Frontrunning

Another common issue is frontrunning. Attackers can monitor the mempool for transactions using ECDSA signatures in certain systems, such as those where a reward is paid out for third parties executing a payload. Depending on the information in the signature payload, an attacker can frontrun the original transaction, manipulate specific parameters, and exploit the system.

Regarding the example above, a signature vulnerable to frontrunning attacks would emerge if the valid signature hash were to be calculated as follows:

1
bytes32 hash = keccak256(abi.encodePacked(_param2, _nonce, _chainId));

With the param1 parameter missing, a frontrunning attacker can arbitrarily set the value of param1 and potentially exploit the system. It is paramount that all parameters participating in the execution of business logic triggered by the signature are included therein.

Signature Malleability

Signature malleability is a characteristic of digital signatures. In Ethereum, an ECDSA signature is represented by two 32-byte sized, r and s values, and a one-byte recovery value, v. The symmetric structure of elliptic curves implies that no signature is unique. A consequence of these "malleable" signatures is that they can be altered without being invalidated.

For every set of parameters {r, s, v} used to create a signature, another distinct set {r', s', v'} results in an equivalent signature. Therefore, when a smart contract system uses ecrecover directly instead of a well-known library like OpenZeppelin's ECDSA, detecting and discarding malleable signatures is essential.

OpenZeppelin's ECDSA library contains the following code to prevent forged signatures:

1
2
3
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
    return (address(0), RecoverError.InvalidSignatureS);
}

This measure stops signature malleability attacks since most signatures from current libraries yield a unique signature with an s-value in the lower half order. It is vital to the signature validation library that this check is in place.

EIP-2098 Compact Signatures

The ECDSA.recover and ECDSA.tryRecover methods are susceptible to a specific form of signature malleability, owing to their ability to process both EIP-2098 compact signatures and the conventional 65-byte signature format. However, this issue is relevant only to the functions which accept a single byte argument and does not impact the ones that take {r, v, s} or {r, vs} as separate arguments.

The contracts that could be affected are those that implement strategies of signature reuse or replay protection by marking the signature itself as 'used' instead of the signed message. In this case, a user might take an already submitted signature, re-submit it in a different format, such as a compact signature, and circumvent the established protection mechanism.

This issue only affects the OpenZeppelin contracts below and not including version 4.7.3. The related security advisory can be found here.