2026-04-01·14 min read

Signature replay and permit attacks: testing EIP-712 and ERC-2612 with fuzzing

Signature Replay and Permit Attacks: Testing EIP-712 and ERC-2612 with Fuzzing

By deivitto — Security Researcher at Recon

Signatures are everywhere in DeFi. Token approvals, gasless transactions, governance votes, meta-transactions — they all rely on off-chain signatures verified on-chain. And every one of them is a potential attack surface if the implementation isn't tight.

I've audited protocols where the signature logic looked correct on first pass but fell apart under fuzzing. Replay attacks across chains, nonce gaps, permit griefing, deadline issues, these bugs hide in the interaction between off-chain signing and on-chain verification. Let's break down each pattern, look at the real damage they've caused, and build the properties that catch them.

How EIP-712 structured signing works

Before we talk attacks, let's make sure the foundation is clear.

EIP-712 defines a standard for signing typed structured data. Instead of signing a raw hash (which users can't verify in their wallet), you sign a structured object with a domain separator:

bytes32 constant DOMAIN_TYPEHASH = keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

bytes32 constant PERMIT_TYPEHASH = keccak256(
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);

function DOMAIN_SEPARATOR() public view returns (bytes32) {
    return keccak256(abi.encode(
        DOMAIN_TYPEHASH,
        keccak256(bytes(name)),
        keccak256(bytes("1")),
        block.chainid,
        address(this)
    ));
}

The domain separator binds the signature to a specific contract on a specific chain. The typehash binds it to a specific action. Together they should prevent replay, but "should" and "do" aren't the same thing.

Attack 1: cross-Chain replay

The mechanism

When a protocol deploys on multiple chains (Ethereum, Arbitrum, Polygon, etc.), every signed message needs to be chain-specific. If the domain separator doesn't include chainId, a signature valid on Ethereum is also valid on Arbitrum.

// VULNERABLE -- no chainId in domain separator
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    DOMAIN_TYPEHASH,
    keccak256(bytes(name)),
    keccak256(bytes("1")),
    // chainId missing!
    address(this)
));

Even worse: some protocols compute the domain separator at deploy time and cache it. If the chain forks (like ETH/ETH Classic), the cached separator is valid on both chains.

Real impact

After the Ethereum/Ethereum Classic split, several tokens had cross-chain replay issues. More recently, L2 deployments have reintroduced this, protocols that hardcode chainId = 1 in their domain separator and then deploy on Arbitrum (chainId 42161) are vulnerable.

Wintermute lost ~$20M on Optimism partly due to a replay-adjacent issue where the deployment address was different but the signature scheme wasn't chain-specific enough.

The invariant

// Property: domain separator must include current chain ID
function invariant_domainSeparatorIncludesChainId() public returns (bool) {
    bytes32 separator = token.DOMAIN_SEPARATOR();

    // Compute expected separator with current chainId
    bytes32 expected = keccak256(abi.encode(
        token.DOMAIN_TYPEHASH(),
        keccak256(bytes(token.name())),
        keccak256(bytes("1")),
        block.chainid,
        address(token)
    ));

    return separator == expected;
}

// Property: changing chainId should invalidate all existing signatures
function invariant_chainForkInvalidatesSignatures(
    uint256 fakeChainId
) public returns (bool) {
    // Simulate chain fork by changing chainId
    vm.chainId(fakeChainId);

    // Any previously valid signature should now fail
    bytes32 newSeparator = token.DOMAIN_SEPARATOR();
    return newSeparator != ghost_originalSeparator;
}

Attack 2: same-Chain replay (Nonce reuse)

The mechanism

ERC-2612 permits use nonces to prevent replay. Each signature includes a nonce, and the contract increments the user's nonce after use. But what happens if the nonce tracking is broken?

// VULNERABLE -- nonce not incremented
function permit(
    address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
    require(block.timestamp <= deadline);

    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR(),
        keccak256(abi.encode(
            PERMIT_TYPEHASH, owner, spender, value,
            nonces[owner], // reads nonce
            deadline
        ))
    ));

    address recovered = ecrecover(digest, v, r, s);
    require(recovered == owner);

    _approve(owner, spender, value);
    // BUG: forgot to increment nonces[owner]!
}

Without the nonce increment, the same signature can be submitted over and over. In the context of an approval, the attacker can re-approve themselves after the user revokes.

Fuzzing nonce correctness

// Property: nonce must increment after every successful permit
function invariant_nonceAlwaysIncrements() public returns (bool) {
    for (uint256 i = 0; i < actors.length; i++) {
        uint256 currentNonce = token.nonces(actors[i]);
        uint256 permitCount = ghost_permitCount[actors[i]];

        // Nonce should equal the number of successful permits
        if (currentNonce != permitCount) return false;
    }
    return true;
}

// Property: same signature can never be used twice
function invariant_noSignatureReplay() public returns (bool) {
    // Track all used signatures in a ghost variable
    // If any signature hash appears twice, it's a replay
    for (uint256 i = 0; i < ghost_usedSignatures.length; i++) {
        for (uint256 j = i + 1; j < ghost_usedSignatures.length; j++) {
            if (ghost_usedSignatures[i] == ghost_usedSignatures[j]) {
                return false;
            }
        }
    }
    return true;
}

Attack 3: permit frontrunning (Griefing)

This one's subtle and often misunderstood. It's not about stealing funds, it's about griefing.

The mechanism

  1. Alice signs a permit for a protocol to spend her tokens.
  2. Alice submits a transaction that calls permit() then transferFrom() in one call.
  3. Eve sees the pending transaction, extracts the permit signature, and submits it first.
  4. Eve's permit() call succeeds, the approval is now set.
  5. Alice's transaction executes, calls permit() again with the same nonce, it reverts because the nonce was already used.
  6. Alice's entire transaction fails.

The attacker didn't steal anything, but they bricked Alice's transaction. In protocols where the permit and the action are in the same transaction, this can be seriously disruptive.

// VULNERABLE -- permit failure causes entire transaction to revert
function depositWithPermit(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // If this permit was already used (frontrun), entire tx fails
    token.permit(msg.sender, address(this), amount, deadline, v, r, s);
    token.transferFrom(msg.sender, address(this), amount);
    _deposit(msg.sender, amount);
}

// FIXED -- try/catch on permit, check allowance
function depositWithPermitSafe(
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    // Try permit -- if it fails (frontrun), check existing allowance
    try token.permit(msg.sender, address(this), amount, deadline, v, r, s) {
        // Permit succeeded
    } catch {
        // Permit failed -- maybe frontrun. Check if allowance exists
        require(
            token.allowance(msg.sender, address(this)) >= amount,
            "Insufficient allowance"
        );
    }
    token.transferFrom(msg.sender, address(this), amount);
    _deposit(msg.sender, amount);
}

The invariant

// Property: depositWithPermit should never revert if the user
// has sufficient balance and the allowance is already set
function invariant_permitFrontrunResistant() public returns (bool) {
    // If actor has allowance >= deposit amount,
    // depositWithPermit should succeed regardless of
    // whether permit() itself succeeds or fails
    for (uint256 i = 0; i < actors.length; i++) {
        uint256 allowance = token.allowance(actors[i], address(vault));
        uint256 balance = token.balanceOf(actors[i]);

        // If both sufficient, a deposit call should never revert
        // (we track reverts in ghost variables during fuzzing)
        if (allowance >= MIN_DEPOSIT && balance >= MIN_DEPOSIT) {
            if (ghost_depositReverted[actors[i]]) return false;
        }
    }
    return true;
}

Attack 4: signature malleability

The mechanism

ECDSA signatures have a malleability property: for any valid signature (v, r, s), there's another valid signature (v', r, s') where s' = secp256k1.n - s and v' = v ^ 1. Both recover to the same address.

If a protocol uses the raw signature hash as a unique identifier (e.g., to mark it as "used"), an attacker can submit the malleable variant and bypass the check:

// VULNERABLE -- uses signature hash for uniqueness
mapping(bytes32 => bool) public usedSignatures;

function executeWithSig(bytes memory sig, bytes memory data) external {
    bytes32 sigHash = keccak256(sig);
    require(!usedSignatures[sigHash], "Already used");

    address signer = recoverSigner(data, sig);
    require(signer == authorized);

    usedSignatures[sigHash] = true;
    _execute(data);
}

The attacker submits the original signature, then creates the malleable version (different s value, same signer) and submits again. Different hash, same authorization.

The fix

OpenZeppelin's ECDSA.recover already enforces low-s values (rejecting the malleable form). But custom implementations often miss this:

// From OpenZeppelin ECDSA
function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s)
    internal pure returns (address)
{
    // EIP-2: restrict s to lower half of curve order
    if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
        return address(0); // reject malleable signature
    }
    // ...
}

Fuzzing for malleability

// Property: malleable signatures should always be rejected
function invariant_rejectMalleableSignatures(
    bytes32 r, bytes32 s, uint8 v
) public returns (bool) {
    // Create malleable version
    uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
    bytes32 sMalleable = bytes32(n - uint256(s));
    uint8 vMalleable = v == 27 ? 28 : 27;

    // If original is accepted, malleable MUST be rejected
    bool originalAccepted = tryExecute(v, r, s);
    bool malleableAccepted = tryExecute(vMalleable, r, sMalleable);

    if (originalAccepted && malleableAccepted) return false;
    return true;
}

Attack 5: deadline bypass

The mechanism

Signatures should expire. ERC-2612 includes a deadline parameter, but some implementations check it wrong, or don't check it at all:

// VULNERABLE -- no deadline check
function permit(address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s) external
{
    // deadline parameter exists but is never checked!
    bytes32 digest = buildDigest(owner, spender, value, nonces[owner], deadline);
    require(ecrecover(digest, v, r, s) == owner);
    _approve(owner, spender, value);
    nonces[owner]++;
}

// Also vulnerable -- using <= instead of <, combined with
// block.timestamp manipulation
function permitAlsoWrong(...) external {
    require(deadline >= block.timestamp); // Should this be > or >=?
    // On some L2s, block.timestamp can be manipulated by sequencer
}

A leaked or stolen signature with no deadline (or deadline = type(uint256).max) is valid forever. Even if the user revokes their approval, the attacker can re-permit at any future time.

Fuzzing deadline enforcement

// Property: expired permits must always revert
function invariant_expiredPermitReverts(
    uint256 deadline,
    uint256 warpTime
) public returns (bool) {
    deadline = bound(deadline, 1, block.timestamp + 365 days);
    warpTime = bound(warpTime, 0, 730 days);

    // Create valid signature with specific deadline
    (uint8 v, bytes32 r, bytes32 s) = createPermitSig(
        ownerKey, spender, amount, deadline
    );

    // Warp past deadline
    vm.warp(block.timestamp + warpTime);

    if (block.timestamp > deadline) {
        // Permit MUST revert after deadline
        try token.permit(owner, spender, amount, deadline, v, r, s) {
            return false; // Should have reverted!
        } catch {
            return true; // Correctly rejected
        }
    }
    return true;
}

Building the full signature fuzzing harness

Here's how I put it all together for a real audit:

contract SignatureFuzzTest is Test {
    Token token;
    uint256 ownerKey = 0xA11CE;
    address owner;
    address spender = address(0xBOB);

    function setUp() public {
        owner = vm.addr(ownerKey);
        token = new Token("Test", "TST");
        token.mint(owner, 1_000_000e18);
    }

    // Handler: normal permit flow
    function handler_permit(
        uint256 value, uint256 deadline
    ) external {
        value = bound(value, 0, token.balanceOf(owner));
        deadline = bound(deadline, block.timestamp, block.timestamp + 30 days);

        uint256 nonce = token.nonces(owner);
        bytes32 digest = buildPermitDigest(
            owner, spender, value, nonce, deadline
        );
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest);

        token.permit(owner, spender, value, deadline, v, r, s);
        ghost_permitCount[owner]++;
    }

    // Handler: attempt replay with old signature
    function handler_attemptReplay(uint256 sigIndex) external {
        if (ghost_signatures.length == 0) return;
        sigIndex = bound(sigIndex, 0, ghost_signatures.length - 1);

        SavedSig memory sig = ghost_signatures[sigIndex];

        try token.permit(
            sig.owner, sig.spender, sig.value,
            sig.deadline, sig.v, sig.r, sig.s
        ) {
            ghost_replaySucceeded = true;
        } catch {
            // Expected -- replay should fail
        }
    }

    // Handler: advance time past deadline
    function handler_advanceTime(uint256 seconds_) external {
        seconds_ = bound(seconds_, 1, 365 days);
        vm.warp(block.timestamp + seconds_);
    }

    // Handler: simulate chain fork
    function handler_changeChainId(uint256 newChainId) external {
        newChainId = bound(newChainId, 1, 100000);
        vm.chainId(newChainId);
    }

    // Invariants
    function invariant_noReplayEver() public {
        assertFalse(ghost_replaySucceeded, "Signature replay succeeded");
    }

    function invariant_noncesMonotonic() public {
        assertEq(
            token.nonces(owner),
            ghost_permitCount[owner],
            "Nonce mismatch"
        );
    }

    function invariant_approvalMatchesLatestPermit() public {
        assertEq(
            token.allowance(owner, spender),
            ghost_lastPermitValue,
            "Allowance doesn't match last permit"
        );
    }
}

Practical tips for fuzzing permit flows

  1. Generate real signatures. Use vm.sign() in Foundry to create valid ECDSA signatures. Don't mock the signing, test the real ecrecover path.

  2. Include chain ID changes. Use vm.chainId() to simulate fork scenarios. A lot of protocols break here.

  3. Test with multiple signers. One user's permit shouldn't affect another's nonce space. Sounds obvious, but cross-contamination happens in custom implementations.

  4. Time travel aggressively. Deadlines of 0, type(uint256).max, block.timestamp - 1, block.timestamp, and block.timestamp + 1 are all interesting boundary values.

  5. Store and replay. Save every successful signature and periodically try to reuse them. This is the most direct test for replay resistance.

  6. Fuzz the v value. Valid values are 27 and 28, but some implementations accept other values. Your property should ensure only 27/28 work.

The bottom line

Signature bugs are sneaky because the code often looks right. The domain separator is there, the nonce is there, the deadline is there, but is the domain separator computed correctly? Is the nonce actually incremented? Is the deadline actually checked?

Invariant testing answers these questions by trying thousands of sequences: permit, replay, time warp, chain change, permit again. The properties don't encode specific attacks, they encode what should always hold. And when something doesn't hold, you've found your bug.

Need to test your EIP-712 and permit implementations? Try Recon Pro for automated fuzzing campaigns. Or request an audit if you want experts to write and run the full signature security test suite for you.

Further reading

Related Posts

Related Glossary Terms

Test your signature handling