Cryptography

Replay Attacks & Signature Malleability

by VirtualZero Research · 11 min read · ECDSA · EIP-712 · Nonces

Signatures are the backbone of identity in EVM blockchains. But if you don't handle them correctly, an attacker can replay a signature on a different chain, or malleate it to produce a valid but different signature. This article covers the essential patterns.

1. Cross‑Chain Replay

The most common replay attack: a signature that authorises an action on one chain (e.g., Ethereum mainnet) is re‑submitted on another chain (e.g., Polygon, Optimism, Goerli).

// ❌ VULNERABLE — no chain identifier
function permit(address user, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
    bytes32 digest = keccak256(abi.encode(
        "Permit", user, amount, deadline
    ));
    // ... recover & verify
    // This digest is the same on every chain!
}
Real‑world example: The Wormhole bridge exploit (Feb 2022, $325M) was partially caused by missing chain‑ID validation in signature verification.

Mitigation: Always include the chain ID and the contract address in the signed message.

// ✅ SAFE — EIP-712 with chainId
function permit(...) external {
    bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
        PERMIT_TYPEHASH, user, amount, nonces[user]++, deadline
    )));
    // _hashTypedDataV4 includes domainSeparator = keccak256(abi.encode(
    //     EIP712_DOMAIN_TYPEHASH, name, version, block.chainid, address(this)
    // ));
    // ...
}

2. ECDSA Signature Malleability

Every ECDSA signature (r, s, v) has a malleable twin: (r, N - s, 27 ^ 1). If the contract doesn't check the s range, an attacker can create a different signature that also recovers to the same signer.

// ❌ VULNERABLE — accepts any s value
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
    return ecrecover(hash, v, r, s);
}

// ✅ SAFE — enforce low s
function verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
    require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
        "Signature: invalid s");
    require(v == 27 || v == 28, "Signature: invalid v");
    return ecrecover(hash, v, r, s);
}

Why it matters: If you use signatures as identifiers (e.g., in a mapping), a malleated signature would be treated as a different one, potentially allowing replay or double‑spending.

3. Missing or Predictable Nonces

Without a nonce, the same signature can be used multiple times. If the nonce is predictable, an attacker can pre‑compute signatures.

// ❌ VULNERABLE — no nonce
function transferWithSig(bytes memory sig, address to, uint256 amount) external {
    bytes32 message = keccak256(abi.encodePacked(to, amount));
    // ... recover
    // No nonce → same signature can be replayed
}

// ✅ SAFE — incrementing nonce
mapping(address => uint256) public nonces;

function transferWithSig(bytes memory sig, address to, uint256 amount) external {
    uint256 nonce = nonces[msg.sender]++;
    bytes32 message = keccak256(abi.encodePacked(to, amount, nonce));
    // ... recover
}

4. EIP‑712 Typehash Pitfalls

EIP‑712 is the standard for typed structured data signing. Two common mistakes:

// Correct EIP-712 pattern
bytes32 constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

function domainSeparator() internal view returns (bytes32) {
    return keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes(name)),
        keccak256(bytes(version)),
        block.chainid,
        address(this)
    ));
}

5. Defensive Checklist

← Back to Research