CtrlK
BlogDocsLog inGet started
Tessl Logo

signature-replay

Signature replay attacks — missing nonces, missing chain ID, ecrecover zero address, signature malleability, cross-chain replay.

66

Quality

80%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

SKILL.md
Quality
Evals
Security

Signature Replay Playbook

EIP-712 signatures, permits, meta-transactions, and cross-chain bridges all use ecrecover (or its variants). Each has well-known bugs.

Bug classes

1. Missing nonce → replay

function execute(uint256 amount, bytes calldata sig) external {
    bytes32 h = keccak256(abi.encodePacked(msg.sender, amount));
    address signer = ecrecover(h, ...);
    require(signer == owner);
    // ... withdraw amount
}

A valid signature can be replayed forever — anyone who saw it once can re-submit it. Fix: include a per-user / per-message nonce:

mapping(address => uint256) public nonces;
function execute(uint256 amount, uint256 nonce, bytes calldata sig) external {
    require(nonce == nonces[msg.sender]);
    nonces[msg.sender]++;
    bytes32 h = keccak256(abi.encodePacked(msg.sender, amount, nonce));
    // ...
}

2. Missing chain ID → cross-chain replay

A signature valid on Ethereum mainnet shouldn't work on Optimism, Base, Arbitrum, etc. If chainid() not in the signed payload, signatures replay across chains:

// VULNERABLE
bytes32 h = keccak256(abi.encodePacked(amount, deadline, nonce));

// SAFE — EIP-712 domain separator includes chainid
bytes32 h = _hashTypedDataV4(...);  // OZ helper

This is especially bad for bridges — a signed message intended for chain A executes on chain B.

3. Signature malleability (ecrecover variants)

ecrecover accepts two valid s values for any signed message (s and -s mod n). This means each signature has two valid forms. If your contract uses the signature itself as a uniqueness key (e.g., usedSigs[sig] = true), an attacker can find the malleable version and bypass:

// VULNERABLE — uses sig as uniqueness key
mapping(bytes => bool) public used;
function execute(bytes calldata sig) external {
    require(!used[sig]);
    used[sig] = true;
    address signer = ecrecover(...);
    // ...
}

Fix: use the message hash (not the signature) as uniqueness key. Modern OZ ECDSA library rejects high-s values, but home-rolled ecrecover does not.

4. ecrecover zero address on invalid sig

address signer = ecrecover(h, v, r, s);
if (signer == owner) { /* approve */ }

Bug: if (v,r,s) is invalid, ecrecover returns address(0). If owner is somehow address(0) (uninitialized!) → any malformed signature works. Fix: require(signer != address(0)).

5. Domain separator without chainid

EIP-712 domain separator should bind to the chain. If a protocol caches DOMAIN_SEPARATOR in storage at deployment, hard-forks (which change chainid) break it. OZ's EIP712 handles this; custom code often doesn't.

// VULNERABLE — cached at deploy
bytes32 immutable DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(...)"),
    keccak256(bytes("Name")),
    keccak256(bytes("1")),
    1,  // ← hardcoded chainid
    address(this)
));

// SAFE — re-derive when chainid changes
function _domainSeparator() internal view returns (bytes32) {
    return keccak256(abi.encode(..., block.chainid, ...));
}

6. Permit signature replay across forks / hard forks

ERC2612 permit is meta-tx for ERC20 approvals. If permit doesn't include the EIP-712 domain separator correctly, it can be replayed on:

  • Forked test chains
  • L2s that copied the contract
  • Hard forks (ETC, ETHW)

This is mostly resolved by OZ ERC20Permit, but custom permit impls are often wrong.

7. Replay across function selectors

A signed payload keccak256(amount, recipient) is the same regardless of which function it authorizes. If two functions share the same hash structure but different effects, the signature replays:

function withdraw(uint256 amt, address to, bytes sig) external {
    bytes32 h = keccak256(abi.encodePacked(amt, to));
    address s = ecrecover(h, ...);
    require(s == owner);
    payable(to).transfer(amt);
}

function mint(uint256 amt, address to, bytes sig) external {
    bytes32 h = keccak256(abi.encodePacked(amt, to));  // SAME HASH
    address s = ecrecover(h, ...);
    require(s == owner);
    _mint(to, amt);
}
// A withdraw signature works for mint, and vice versa

Fix: include function selector or unique typeID in the signed payload.

Audit steps

1. Find every ecrecover

grep -rn 'ecrecover\|ECDSA.recover\|recover(' src/

2. For each call site, verify the payload contains

  • A nonce (or other per-user unique value, like deadline + msg.sender)
  • block.chainid (or use EIP-712 domain separator)
  • A function discriminator (if multiple functions use signatures)
  • address(this) (so signature is bound to THIS contract)

3. Verify signature validation

  • Reject address(0) return from ecrecover
  • Use OZ ECDSA library or equivalent that rejects high-s malleable signatures
  • Domain separator re-derived from block.chainid if cached

4. Look for the dangerous patterns

# Custom ecrecover w/o OZ — often missing s-malleability check
grep -rn 'ecrecover' src/ | grep -v 'ECDSA.sol'

# Signature as uniqueness key
grep -rE 'mapping\(bytes' src/ | grep -i 'used\|nonce'

# Domain separator cached at deploy
grep -rn 'DOMAIN_SEPARATOR.*=.*keccak256' src/ | grep -v 'view\|pure'

PoC templates (Foundry)

Cross-chain replay

function test_cross_chain_replay() public {
    // 1. Sign payload on chain A (chainid 1)
    bytes32 hash = target.getSignableHash(amount, deadline);
    bytes memory sig = sign(privKey, hash);

    target.execute(amount, deadline, sig);  // works on chain A

    // 2. Switch to chain B (chainid 10)
    vm.chainId(10);

    // 3. Deploy same contract bytecode to chain B
    Target targetB = new Target(...);

    // 4. Same signature should be rejected if chainid is in the hash
    vm.expectRevert("invalid sig");
    targetB.execute(amount, deadline, sig);

    // If it DOES NOT revert → cross-chain replay bug
}

Malleability

function test_malleability() public {
    bytes32 hash = ...;
    (uint8 v, bytes32 r, bytes32 s) = sign(privKey, hash);

    target.execute(amount, abi.encodePacked(r, s, v));

    // Compute the malleable counterpart
    bytes32 sPrime = bytes32(uint256(SECP256K1_N) - uint256(s));
    uint8 vPrime = v == 27 ? 28 : 27;

    // If target doesn't reject high-s, this also works
    target.execute(amount, abi.encodePacked(r, sPrime, vPrime));
}

Missing nonce

function test_replay() public {
    target.execute(amount, sig);  // first call succeeds
    target.execute(amount, sig);  // if no nonce check, succeeds again
    // Attacker can drain by replaying N times
}

CVSS

  • Missing nonce + signed withdraw fn: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8
  • Cross-chain replay on bridge: 10.0 (Critical)
  • Malleability + sig as uniqueness key: 8.0-9.0
  • Domain separator missing chainid: 8.0
  • ecrecover zero-address acceptance: 9.0 (engagement-ending if owner is 0)

Defender remediation

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

contract Safe is EIP712 {
    using ECDSA for bytes32;

    mapping(address => uint256) public nonces;

    bytes32 constant TYPEHASH = keccak256(
        "Execute(address user,uint256 amount,uint256 nonce,uint256 deadline)"
    );

    constructor() EIP712("MyApp", "1") {}

    function execute(uint256 amount, uint256 deadline, bytes calldata sig) external {
        require(block.timestamp <= deadline, "expired");

        bytes32 structHash = keccak256(abi.encode(
            TYPEHASH, msg.sender, amount, nonces[msg.sender]++, deadline
        ));
        bytes32 hash = _hashTypedDataV4(structHash);
        address signer = hash.recover(sig);

        require(signer == owner, "bad sig");
        // OZ ECDSA already rejects address(0) and malleable s
    }
}

Known exemplars

  • Wormhole (Feb 2022): $325M — signature verification bypass (different bug but same family)
  • Nomad (Aug 2022): $190M — initial-merkle-root acceptance bug, replay-adjacent
  • Multichain (Jul 2023): $126M — private-key compromise + over-broad signature acceptance
  • Ronin (Mar 2022): $625M — validator-set takeover via cross-chain replay-like vector
  • Cream V2 / Cover (Dec 2020): infinite-mint via nonce replay
  • Compound c.Token reentrancy + sig-driven liquidations (smaller)
  • ParaSwap (Sep 2021): signature malleability mitigated in ERC1271 wallet integrations
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.