Docs / Application security

Field guide · Smart contract security

Smart contract security

Signature replay: when a valid signature is used twice

By Abhimanyu Gupta, Founder & Principal Operator

On this page

A transaction is protected by the account nonce; a bare off-chain signature is not. Unless a contract adds a nonce and an EIP-712 domain, the same signature works twice, on another chain, and on a sister contract.

Read with a purpose. Consume a nonce so a signed message works once, and bind an EIP-712 domain with the chain id and contract address so it works nowhere else. Reach for audited libraries, and remember contract wallets sign through ERC-1271.

At a glance

The bug
A contract accepts a signed message and acts on it, but never marks that message as used, so the same signature can be submitted again for a second payout
The wider bug
The same signature working on another chain, or on a sister contract, because the signed data did not pin down where it was meant to be used
The tool
EIP-712 typed data with a domain separator: name, version, chain id and contract address, so a signature is valid in exactly one place
The fix
A nonce per signer that the contract consumes on first use, plus the domain, so a message works once and only where intended
The habit
For every signature a contract verifies, ask: what stops this being replayed, on this chain and every other?

Why contracts trust signatures at all

Not every action needs a transaction. A user can sign a message off-chain, for free, and hand that signature to someone else to submit on-chain later. It is how gasless approvals work, how meta-transactions let a relayer pay the gas, and how an exchange can let you authorise a trade without a wallet pop-up for every step. The contract recovers the signer with ecrecover and, if it matches, treats the message as that person's instruction.

The power is obvious and so is the risk. A transaction is protected by the account nonce, which the network enforces. A bare signature has none of that. It is just a number that proves who signed some bytes, and unless the contract adds protection of its own, the same proof works as many times as it is presented.

The replay, in three flavours

Here is an airdrop that pays out to anyone who presents a signature from the trusted signer. Read it for what is missing.

Vulnerable.solsolidity
function claim(address to, uint256 amount, bytes memory sig) external {
    bytes32 hash = keccak256(abi.encodePacked(to, amount));
    address signer = recover(hash, sig);
    require(signer == trustedSigner, "bad signature");

    token.transfer(to, amount);   // nothing records that this sig was used
}

The signature is checked and the tokens are sent, but nothing is written down. Call claim again with the same three arguments and it passes again, and again, until the contract is empty. That is the first flavour: no nonce, so one signature is an unlimited coupon.

There are two more, and they are subtler because the signed data itself is incomplete. If the hash does not include the chain id, a signature made on a testnet, or on a fork, is equally valid on mainnet: cross-chain replay. If it does not include the contract address, a signature meant for one deployment works on an identical sister contract that shares the same signer: cross-contract replay. The signer proved they signed these bytes, but never said for which chain and which contract.

one signature pays out pays out again nothing records the signature as spent

EIP-712: signing something you can read, in one place only

The standard answer to all three flavours is EIP-712. Instead of signing an opaque hash, the user signs structured, typed data under a domain separator, a preamble that names exactly where the signature is valid: the contract's name and version, the chain id, and the verifying contract's address. Bind those into the hash and a signature is worthless anywhere but the one chain and the one contract it was made for. Cross-chain and cross-contract replay are closed by construction.

The domain that pins a signature downsolidity
bytes32 DOMAIN = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("OverWatch Airdrop")),
    keccak256(bytes("1")),
    block.chainid,          // pins the chain
    address(this)           // pins the contract
));

The fix: a nonce the contract consumes

The domain stops it being replayed elsewhere. To stop it being replayed here, the signed message must include a nonce, and the contract must record that nonce as spent the first time it sees it.

Fixed.solsolidity
mapping(address => uint256) public nonces;

function claim(address to, uint256 amount, uint256 nonce, bytes memory sig) external {
    bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, to, amount, nonce));
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN, structHash));
    require(recover(digest, sig) == trustedSigner, "bad signature");

    require(nonce == nonces[to], "nonce used or out of order");
    nonces[to]++;             // consume it: this signature will never pass again

    token.transfer(to, amount);
}

Now the signature is valid once, for this chain, for this contract, and never again. Do not hand-roll the primitives if you can help it: OpenZeppelin's EIP712 and ECDSA build the domain and recover the signer correctly, including the malleability and zero-address checks that a raw ecrecover misses.

Contract wallets sign differently. A smart-contract wallet cannot use ecrecover; it validates signatures through ERC-1271, by asking the wallet contract "is this signature valid?" If your verification only handles raw keys, contract wallets break, and if you add ERC-1271 without a nonce, its signatures replay just like any other. Whichever path you support, the nonce and the domain still apply.

The short version. A signature proves who signed some bytes, nothing more. A contract that acts on one must add what the bytes do not carry: a nonce it consumes, so the message works once, and an EIP-712 domain binding the chain id and its own address, so the message works nowhere else. Reach for audited libraries for the recovery and the domain, and remember that contract wallets sign through ERC-1271 and need the same protections.

References & further reading

  1. Ethereum Improvement Proposals, EIP-712: Typed structured data hashing and signing. The domain separator and why it scopes a signature.
  2. OpenZeppelin, Cryptography utilities. ECDSA and EIP712, with the checks a raw ecrecover lacks.
  3. Ethereum Improvement Proposals, EIP-1271: Standard signature validation for contracts. How smart-contract wallets prove a signature.
  4. Solidity documentation, ecrecover and signature malleability. The sharp edges of the raw primitive.
All guides Web app penetration testing

Want this tested on you?

Reading about it is one thing. Seeing it proven on your own systems is another.