Free snapshot slots open this week info@inversez.com
DetectionVulnerabilitiesToolsAuditsPricingBlogFree monitoring assessmentContact
Home/Vulnerabilities/Signature replay
SWC-121 · Typically high

Signature replay

A signature is just data. Once it is on-chain, anyone can read it and submit it again. If your contract does not track which signatures it has already honoured, and does not bind them to itself and to this chain, the same signature can be replayed until the funds run out.

Vulnerable code

Do not ship this
function claim(
    address to,
    uint256 amt,
    bytes memory sig
) external {
    bytes32 h = keccak256(abi.encodePacked(to, amt));
    require(_recover(h, sig) == signer, "bad signature");
    token.transfer(to, amt);      // no nonce, no domain
}

Why it breaks

Three separate failures. There is no nonce, so the same signature can be submitted repeatedly to claim the amount over and over. There is no contract address in the hash, so a signature intended for one deployment works on another. And there is no chain ID, so a signature from mainnet is valid on every fork and testnet. On top of that, abi.encodePacked with dynamic types can produce hash collisions between different inputs.

Watch the attack run

The same bug as a stepped sequence. Play it, or walk through with the arrow keys.

The fix

Corrected
mapping(address => uint256) public nonces;
bytes32 private immutable DOMAIN_SEPARATOR;

constructor() {
    DOMAIN_SEPARATOR = keccak256(abi.encode(
        keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
        keccak256("InverseZeroClaim"),
        block.chainid,
        address(this)
    ));
}

function claim(address to, uint256 amt, bytes memory sig) external {
    bytes32 structHash = keccak256(abi.encode(
        CLAIM_TYPEHASH, to, amt, nonces[to]++
    ));
    bytes32 digest = keccak256(
        abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
    );
    require(ECDSA.recover(digest, sig) == signer, "bad signature");
    token.safeTransfer(to, amt);
}

The invariant

The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.

InvariantEVM · signature replay

Nonces strictly increase per signer, and no signed digest is ever accepted twice.

invariant  nonce(signer, t) > nonce(signer, t-1)
invariant  digest not in seen_digests
invariant  block.timestamp <= deadline
Backtest: not yet run. This entry carries the reasoning, not the evidence. When the invariant has been replayed against the real transactions around the exploit, the firing block, how far ahead it fired, and its false-positive rate over a clean control period will be published here, along with the recording of the run.

How to test for it

A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.

function testSignatureCannotBeReplayed() public {
    bytes memory sig = _sign(alice, 100e18, 0);

    airdrop.claim(alice, 100e18, sig);     // succeeds

    vm.expectRevert("bad signature");
    airdrop.claim(alice, 100e18, sig);     // nonce consumed
}

Where it has caused real losses

Cross-chain replay became a practical concern after the Ethereum merge, when signatures valid on mainnet were also valid on the proof-of-work fork. Contracts that omitted block.chainid from their domain separator allowed messages signed for one chain to be executed on the other.

What we check during review

Also check signature malleability: for any valid ECDSA signature there is a second valid form with a flipped s value. If you use the signature bytes themselves as a replay key, an attacker submits the malleable twin and bypasses your check. OpenZeppelin's ECDSA library rejects the high-s form; use it rather than calling ecrecover directly.

Want us to check your contract for this?

A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.

Free monitoring assessment

Found this useful?

We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.

  • Reply written by the auditor who read your code
  • No sales sequence, no drip campaign, no retargeting
  • We’ll tell you if you don’t need a paid audit yet
  • Report published only with your written permission

Request your snapshot

We read every submission. If it doesn’t fit the free tier we’ll say so and quote you instead, no obligation.