Skip to content

Signature-related Attacks

Signatures serve critical functions in smart contract systems. The EVM exposes the ecrecover precompile for native signature validity checks and recovery, which underpins authorization, data validity checks, and gas-less transactions. Signature systems malfunction in a number of distinct ways, and the consequences are usually severe.

Missing Validation

The most common vulnerability is missing validation of the address ecrecover returns on error.

1
2
3
4
5
function recover(uint8 v, bytes32 r, bytes32 s, bytes32 hash) external {
    // @audit ecrecover returns address(0) on failure and the result is never checked
    address signer = ecrecover(hash, v, r, s);
    //Do more stuff with the hash
}

The check for address(0) is absent, which lets an attacker submit invalid signatures carrying arbitrary payloads and have them pass as valid. A single guard closes the gap:

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

OpenZeppelin's ECDSA library is the better option, since it reverts on invalid signatures automatically.

Replay Attacks

Replay attacks occur where a signature and the system consuming it share no deduplication mechanism, typically because signatures are never invalidated or because the system carries no nonce. The following examples work through the attack angles on a signature system and its successive 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
25
// 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 {
        // @audit the signed payload carries no nonce and is never invalidated
        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
    }
}

An attacker in possession of the owner's signature performs the same action repeatedly. Where the owner signed a transfer of funds, replaying the signature drains the contract. A mapping that invalidates each submitted signature after its first execution fixes the immediate problem, and a nonce encoded into the signed payload restores the owner's ability to authorize the same action more than once. Changing the final signature while the payload data stays the same is the nonce's only purpose.

The following code adds both the invalidation mechanism and 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 the enhanced contract is not secure. Deployment on multiple chains, or reuse of the signer address in other contexts on different chains, leaves signature replay open.

Cross-chain Replay Attacks

Cross-chain replay attacks arise where signatures are reusable across different blockchain systems. A signature already used and invalidated on one chain is copied to another and triggers an unwanted state change there, which puts every smart contract system deployed with identical code across chains at risk.

Encoding the chain ID in the signature payload and validating it against the current chain closes the gap:

 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
    }
}

Signatures over EIP712 typed data payloads already carry the chain ID inside the domain separator value.

Frontrunning

Attackers monitor the mempool for transactions carrying ECDSA signatures, particularly in systems that pay a reward to third parties for executing a payload. Depending on what the signature payload covers, an attacker frontruns the original transaction, manipulates the parameters it left out, and exploits the system.

Applied to the example above, a signature becomes vulnerable to frontrunning once the hash is calculated like this:

1
2
// @audit param1 is absent from the signed payload
bytes32 hash = keccak256(abi.encodePacked(_param2, _nonce, _chainId));

With param1 missing, a frontrunning attacker sets its value arbitrarily. Every parameter participating in the business logic that the signature triggers belongs inside the signed payload.

Signature Malleability

Signature malleability is a characteristic of digital signatures. An ECDSA signature on Ethereum consists of two 32-byte values, r and s, plus a one-byte recovery value v. The symmetric structure of elliptic curves means no signature is unique, so a malleable signature is alterable without being invalidated.

For every parameter set {r, s, v} behind a signature, a distinct set {r', s', v'} produces an equivalent one. A smart contract system calling ecrecover directly rather than through a well-known library such as OpenZeppelin's ECDSA therefore has to detect and discard malleable signatures itself.

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

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

The check stops malleability attacks, because signatures from current libraries carry an s-value in the lower half order and are therefore unique. No signature validation library is complete without it.

EIP-2098 Compact Signatures

The ECDSA.recover and ECDSA.tryRecover methods are susceptible to a specific form of malleability, because they process both EIP-2098 compact signatures and the conventional 65-byte format. The problem is confined to the overloads accepting a single bytes argument and does not reach those taking {r, v, s} or {r, vs} as separate arguments.

Affected contracts are those implementing signature reuse or replay protection by marking the signature itself as used rather than the signed message. A user takes an already submitted signature, resubmits it in the compact format, and circumvents the protection.

The issue affects OpenZeppelin contracts in versions before 4.7.3. The related security advisory covers the details.