Signature Replay Attacks

Common vulnerabilities leading to signature replay attacks

·

6 min read

Signatures can be used in Ethereum transactions to validate computation performed off-chain, helping to minimize on-chain gas fees. Signatures are primarily used to authorize transactions on behalf of the signer and to prove that a signer signed a specific message. Signature replay attacks allow an attacker to replay a previous transaction by copying its signature and passing the validation check.

Missing Nonce Replay

Consider the following code from Ondo's code4rena contest:

function addKYCAddressViaSignature( 
    uint256 kycRequirementGroup,
    address user,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s ) external {
    // ...
    bytes32 structHash = keccak256(
      abi.encode(_APPROVAL_TYPEHASH, kycRequirementGroup, user, deadline)
    );

    bytes32 expectedMessage = _hashTypedDataV4(structHash);

    address signer = ECDSA.recover(expectedMessage, v, r, s);
    // ...
}

addKYCAddressViaSignature() uses a signature to grant KYC status to a user (the signer). Yet what would happen if KYC status was subsequently revoked? The user could simply replay the original signature & KYC status would be granted again.

To prevent signature replay attacks, smart contracts must:

  • keep track of a nonce,

  • make the current nonce available to signers,

  • validate the signature using the current nonce,

  • once a nonce has been used, save this to storage such that the same nonce can't be used again.

This requires signers to sign their message including the current nonce, and hence signatures that have already been used are unable to be replayed, as the old nonce will have been marked in storage as having been used & will no longer be valid. An example can be seen in OpenZeppelin's ERC20Permit implementation:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual override {
    // ...
    bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
    // incorporates chain_id (ref next section Cross Chain Replay)
    bytes32 hash = _hashTypedDataV4(structHash);
    // ...
}

function _useNonce(address owner) internal virtual returns (uint256 current) {
    Counters.Counter storage nonce = _nonces[owner];
    current = nonce.current();
    nonce.increment();
}

More examples of missing nonce signature replay attacks: [1, 2, 3, 4, 5]

Cross Chain Replay

Many smart contracts operate on multiple chains from the same contract address and users similarly operate the same address across multiple chains. Biconomy's code4rena contest had the following code:

function getHash(UserOperation calldata userOp)
public pure returns (bytes32) {
    //can't use userOp.hash(), since it contains also the paymasterAndData itself.
    return keccak256(abi.encode(
            userOp.getSender(),
            userOp.nonce,
            keccak256(userOp.initCode),
            keccak256(userOp.callData),
            userOp.callGasLimit,
            userOp.verificationGasLimit,
            userOp.preVerificationGas,
            userOp.maxFeePerGas,
            userOp.maxPriorityFeePerGas
        ));
}

Since a UserOperation is not signed nor verified using the chain_id, a valid signature that was used on one chain could be copied by an attacker and propagated onto another chain, where it would also be valid for the same user & contract address! To prevent cross-chain signature replay attacks, smart contracts must validate the signature using the chain_id, and users must include the chain_id in the message to be signed. More examples: [1, 2]

Missing Parameter

Consider a signature where a signer permitted a contract to spend some of their tokens - the amount of tokens to be spent must be part of the signature to prevent an arbitrary amount from being used! Consider this gas refund code also from Biconomy's code4rena contest:

function encodeTransactionData(
    Transaction memory _tx,
    FeeRefund memory refundInfo,
    uint256 _nonce
) public view returns (bytes memory) {
    bytes32 safeTxHash =
        keccak256(
            abi.encode(
                ACCOUNT_TX_TYPEHASH,
                _tx.to,
                _tx.value,
                keccak256(_tx.data),
                _tx.operation,
                _tx.targetTxGas,
                refundInfo.baseGas,
                refundInfo.gasPrice,
                refundInfo.gasToken,
                refundInfo.refundReceiver,
                _nonce
            )
        );
    return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);
}

This allows a user to sign a transaction that permits a gas refund to the transaction submitter. Yet when that refund is calculated, an additional parameter tokenGasPriceFactor is used to calculate the actual amount:

function handlePaymentRevert(
    uint256 gasUsed,
    uint256 baseGas,
    uint256 gasPrice,
    uint256 tokenGasPriceFactor,
    address gasToken,
    address payable refundReceiver
) external returns (uint256 payment) {
    // ...
    payment = (gasUsed + baseGas) * (gasPrice) / (tokenGasPriceFactor);
    // ...
}

As the tokenGasPriceFactor is not part of the user's signature parameters, the transaction submitter getting the gas refund can set tokenGasPriceFactor to a number large enough to drain the user's funds, while still passing the contract's signature verification check, since this parameter isn't included in the signature. Smart contract auditors should carefully verify that all required parameters for a function also form part of the signature.

To prevent missing parameter signature attacks, users must always sign their messages including the specific message parameters. More examples: [1]

No Expiration

Signatures signed by users should always have an expiration or timestamp deadline, such that after that time the signature is no longer valid. If there is no signature expiration, a user by signing a message is effectively granting a "lifetime license". Consider this code from NFTPort's sherlock audit which lacks an expiration:

function call(
    address instance,
    bytes calldata data,
    bytes calldata signature
)
    external
    payable
    operatorOnly(instance)
    signedOnly(abi.encodePacked(msg.sender, instance, data), signature)
{
    _call(instance, data, msg.value);
}

And the fixed version which includes an expiration:

function call(CallRequest calldata request, bytes calldata signature)
    external
    payable
    operatorOnly(request.instance)
    validRequestOnly(request.metadata)
    signedOnly(_hash(request), signature)
{
    _call(request.instance, request.callData, msg.value);
}

function _hash(CallRequest calldata request)
    internal
    pure
    returns (bytes32)
{
    return
        keccak256(
            abi.encode(
                _CALL_REQUEST_TYPEHASH,
                request.instance,
                keccak256(request.callData),
                _hash(request.metadata)
            )
        );
}

function _hash(RequestMetadata calldata metadata)
    internal
    pure
    returns (bytes32)
{
    return
        keccak256(
            abi.encode(
                _REQUEST_METADATA_TYPEHASH,
                metadata.caller,
                metadata.expiration // signature expiration
            )
        );
}

To help prevent replay attacks, signature implementations should always include an expiration timestamp and aim to conform to EIP-712. Some audited & well-tested building blocks for implementing EIP-712 into your smart contracts are available in OpenZeppelin's utility EIP712.sol.

Unchecked ecrecover() return

Solidity's ecrecover() function returns either the signing address or 0 if the signature is invalid; the return value of ecrecover() must be checked to detect invalid signatures! Consider this code [1, 2] from Swivel's code4rena audit:

function validOrderHash(Hash.Order calldata o, Sig.Components calldata c) internal view returns (bytes32) {
    bytes32 hash = Hash.order(o);
    // ...
    require(o.maker == Sig.recover(Hash.message(domain, hash), c), 'invalid signature');
    // ...
}

// Sig.recover
function recover(bytes32 h, Components calldata c) internal pure returns (address) {
    // ...
    return ecrecover(h, c.v, c.r, c.s);
}

validOrderHash() checks that o.maker == Sig.recover(), where Sig.recover() returns ecrecover(), hence the check is effectively o.maker == ecrecover(). This allows an attacker to simply pass 0 for o.maker and make this check pass for an invalid signature since ecrecover() returns 0 for an invalid signature! More examples: [1]

Signature Malleability

The elliptic curve used in Ethereum for signatures is symmetrical, hence for every [v,r,s] there exists another [v,r,s] that returns the same valid result. Therefore two valid signatures exist which allows attackers to compute a valid signature without knowing the signer's private key. ecrecover() is vulnerable to signature malleability [1, 2] so it can be dangerous to use it directly. Consider this code from Larva Lab's code4rena audit:

function verify(address signer, bytes32 hash, bytes memory signature) internal pure returns (bool) {
    require(signature.length == 65);

    bytes32 r;
    bytes32 s;
    uint8 v;

    assembly {
        r := mload(add(signature, 32))
        s := mload(add(signature, 64))
        v := byte(0, mload(add(signature, 96)))
    }

    if (v < 27) {
        v += 27;
    }

    require(v == 27 || v == 28);

    return signer == ecrecover(hash, v, r, s);
}

An attacker can compute another corresponding [v,r,s] that will make this check pass due to the symmetrical nature of the elliptic curve. The easiest way to prevent this issue is to use OpenZeppelin’s ECDSA.sol library and reading the comments above ECDSA's tryRecover() function provides very useful information on correctly implementing signature checks to prevent signature malleability vulnerabilities. More examples: [1, 2]

When using OpenZeppelin's ECDSA library, special care must be taken to use version 4.7.3 or greater, since previous versions contained a signature malleability bug.

Another great resource on preventing signature malleability vulnerabilities is this excellent article by ImmuneFi.