All articles
Shadow ArenaMay 29, 20269 min read

Flux Finance's KYC Signature Replay: EIP-712 Without a Nonce

Flux Finance's KYC verification uses EIP-712 signatures but omits the nonce. Once signed, the same KYC approval can be replayed indefinitely.

By Carlos (Bloqarl)

TL;DR

  • Flux Finance is a Compound V2 fork extended with KYC verification and Ondo's CASH stablecoin (4,365 SLOC, beginner difficulty, 6 documented findings in Shadow Arena).
  • M-04: the KYC verification scheme uses EIP-712 typed signatures but the signed struct has no nonce field. Once a KYC approval signature is issued, it can be replayed indefinitely.
  • The replay attack: a user gets KYC'd once, then the same signature authorizes any future action that depends on KYC, even if the protocol intended each action to require a fresh approval.
  • The fix is well-known: include a nonce field in the EIP-712 struct, store a per-signer nonce mapping, increment on each successful verification.
  • This bug class recurs across permit-style flows. ERC-2612 (token permits) gets it right; many custom signature schemes get it wrong.

Why this matters

EIP-712 makes typed signatures usable. Users sign structured data, wallets display human-readable previews, contracts verify deterministically. The standard is widely adopted: token permits, Cowswap orders, OpenSea trades, all run on EIP-712.

The standard does not, by itself, prevent replay attacks. EIP-712 specifies the encoding of the signed data, not which fields the data must contain. If you forget the nonce, the signature is replayable, and EIP-712 says nothing.

The bug class shows up whenever a team designs a custom signature scheme without copying ERC-2612's pattern wholesale. Flux Finance's KYC scheme is one such case. Many others ship to mainnet and get caught by audits, but some ship and stay in production until a researcher exploits the replay.

If you're operating a protocol that uses signed approvals (KYC, governance votes, off-chain orders), this article is the audit checklist.

How EIP-712 typed signatures work

EIP-712 defines how to hash structured data so a wallet can display it (TypedSignType.signTypedData) and a contract can verify it (ECDSA.recover over the same hash). The protocol works in three layers:

1. Domain separator

A per-contract value that prevents signatures intended for one contract from being valid on another:

DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes(name)),
        keccak256(bytes(version)),
        block.chainid,
        address(this)
    )
);

The chainId and verifyingContract bind the signature to a specific deployment. A signature created for Flux Finance on Ethereum is not valid for a copy of Flux Finance on Optimism.

2. Struct hash

The actual data being signed, hashed according to its type:

bytes32 structHash = keccak256(
    abi.encode(
        TYPEHASH,
        field1,
        field2,
        // ...
    )
);

TYPEHASH is the hash of the struct's type string (e.g., keccak256("KYCApproval(address user,uint256 expiresAt)")).

3. Final digest and recover

The EIP-712 digest combines the domain separator and the struct hash:

bytes32 digest = keccak256(
    abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
address signer = ECDSA.recover(digest, signature);

If signer matches the expected KYC authority, the contract treats the action as authorized.

This is mechanically correct. What it doesn't do is prevent the same signature from being used twice.

Where Flux Finance's M-04 lives

Flux Finance's KYC verification function takes a signed approval struct and confirms the signer is the protocol's KYC authority. Roughly:

struct KYCApproval {
    address user;
    uint256 expiresAt;
    // NO nonce field
}

bytes32 constant KYC_TYPEHASH = keccak256(
    "KYCApproval(address user,uint256 expiresAt)"
);

function verifyKYC(
    KYCApproval memory approval,
    bytes memory signature
) external view returns (bool) {
    bytes32 structHash = keccak256(abi.encode(
        KYC_TYPEHASH,
        approval.user,
        approval.expiresAt
    ));
    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR,
        structHash
    ));
    address signer = ECDSA.recover(digest, signature);
    require(signer == kycAuthority, "INVALID_SIGNATURE");
    require(approval.expiresAt > block.timestamp, "EXPIRED");
    return true;
}

This compiles, verifies, and works for the happy case: the KYC authority signs (user, expiresAt), the user submits the signature alongside their action, the contract validates.

The bug: the signature is just (user, expiresAt). No nonce. The same signature can be replayed for as many actions as the user wants, until expiresAt passes.

If the protocol intended "this signature authorizes ONE action", that intention isn't encoded. If the protocol intended "this signature authorizes any KYC-gated action until expiry", that's a different design choice but should be explicit in the docs.

The replay attack scenario

Specific scenarios where the missing nonce causes problems:

Scenario 1: per-action approvals

If the protocol intended each KYC-gated action to require a fresh authority signature (a common design when KYC has tier levels or the authority needs to revoke between actions), the missing nonce breaks the model. The user submits the signature once, then keeps submitting the same signature for every subsequent action. The authority can't revoke until expiresAt.

Scenario 2: revoked KYC

The KYC authority discovers the user is on a sanctions list mid-flight. They want to immediately revoke. Without per-signature nonces, the only way to revoke is to advance expiresAt to the past for ALL signatures (since the signature itself doesn't reference any state the authority can mutate after issuance). That's a hammer where they wanted a scalpel.

Scenario 3: signature theft

The user's wallet is compromised. The attacker has the EIP-712 signature stored in the wallet. With per-signature nonces, the attacker uses the signature once and the nonce increments, neutralizing the stolen credential. Without nonces, the attacker uses the signature forever (until expiry).

Scenario 4: cross-action confusion

If multiple KYC-gated actions exist (deposit, borrow, withdraw), and they all use the same struct format, a signature intended to authorize one action authorizes all of them. The user gets a deposit signature, then uses it for a withdrawal too. The protocol's intended granularity is undermined.

In Flux Finance's M-04, the specific impact category depended on which actions actually gated on KYC and how the surrounding flow worked. The audit caught the bug before production exploitation, but the structural risk is real.

The fix

The standard fix is the same fix used by ERC-2612 token permits:

struct KYCApproval {
    address user;
    uint256 nonce;        // ← added
    uint256 expiresAt;
}

bytes32 constant KYC_TYPEHASH = keccak256(
    "KYCApproval(address user,uint256 nonce,uint256 expiresAt)"
);

mapping(address => uint256) public nonces;

function verifyKYC(
    KYCApproval memory approval,
    bytes memory signature
) external returns (bool) {  // changed to non-view
    require(approval.nonce == nonces[approval.user], "INVALID_NONCE");
    
    bytes32 structHash = keccak256(abi.encode(
        KYC_TYPEHASH,
        approval.user,
        approval.nonce,
        approval.expiresAt
    ));
    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        DOMAIN_SEPARATOR,
        structHash
    ));
    address signer = ECDSA.recover(digest, signature);
    require(signer == kycAuthority, "INVALID_SIGNATURE");
    require(approval.expiresAt > block.timestamp, "EXPIRED");
    
    nonces[approval.user]++;
    return true;
}

Three changes:

  1. Add nonce to the struct.
  2. Add nonce to the type hash.
  3. Add nonces[user] storage; check on entry, increment on success.

The function changes from view to non-view because it mutates state. Callers handle the state cost.

Audit checklist for EIP-712 schemes

When reviewing any custom signature scheme:

1. Replay protection

The struct must contain a nonce or an equivalent uniqueness guarantee. Acceptable patterns:

  • Per-signer nonce: one counter per address, incremented on each use. Standard for permits.
  • Per-action nonce: one counter per (address, action-type), useful when actions have different cadences.
  • One-shot bitmap: each signature has an unguessable id, contract stores a bitmap of used ids. Useful for offline-issued bulk signatures (e.g., airdrops).

If none of these are present, the signature is replayable, and that's almost always a bug.

2. Domain separator correctness

The domain separator must include chainId and verifyingContract. Without chainId, signatures are valid across chains (cross-chain replay). Without verifyingContract, signatures are valid across copies of the same contract.

3. Expiry handling

Even with a nonce, an expiry is usually desirable to prevent stale signatures from haunting the protocol. Confirm block.timestamp is compared correctly (<= vs <, off-by-one risks).

4. Type hash correctness

The string used in TYPEHASH = keccak256("...") must exactly match the field order, types, and names in the struct. A mismatch produces signatures that wallets and contracts compute differently. The wallet's signed value is rejected by the contract; legitimate users can't authenticate.

5. Signature malleability

Use ECDSA.recover from OpenZeppelin's library, which rejects signatures with high-s values (malleability). Old custom recovery code may accept both (r, s) and (r, n - s) for the same signature, which is a different replay vector.

6. Authority management

Confirm the kycAuthority (or equivalent) can be rotated, paused, or revoked. If the only way to revoke a leaked authority key is to redeploy the contract, that's a serious operational risk.

7. Cross-action protection

If multiple actions use the same signature scheme, confirm an action-type field is in the struct. Otherwise, a signature for action A is also valid for action B.

Where this bug class shows up

EIP-712 without nonce is one of the most common signature scheme bugs. Real-world examples beyond Flux Finance:

  • Several smaller permit implementations that copy ERC-2612 selectively, sometimes omitting the nonce when the team thinks "this signature is one-shot anyway".
  • Off-chain order books for niche DEXs where order replay was discovered weeks or months after launch.
  • Governance vote delegation schemes where a delegate could re-vote with the same signature across proposals.

The class is not subtle once you know to look for it. The challenge is that "EIP-712 with proper signature struct" can pass a quick audit because the cryptographic primitives look right; only careful reading of the struct contents reveals the missing nonce.

Related questions

Why doesn't EIP-712 itself require a nonce? EIP-712 is the encoding standard. The semantic protections (replay safety, expiry) are protocol-specific design choices. Different protocols have different replay-resistance needs (some are fine with replayable signatures, e.g., ENS resolver records). Mandating a nonce in the standard would be too prescriptive.

Can I use a deadline-only scheme without a nonce? Yes, if the action is genuinely intended to be replay-safe within the deadline. Example: an off-chain price quote that's valid for 60 seconds. Replaying the same quote within the window is fine. Deadline alone is acceptable when the action's effect is naturally idempotent.

What's the minimum nonce strategy? A per-signer counter that increments on each use. ERC-2612's pattern. Cheapest to implement, sufficient for most cases.

How do I handle off-chain bulk signatures (e.g., merkle airdrops)? Use a bitmap of used indices. The signature contains the index, the contract checks the bit, sets it. This handles bulk pre-signing without per-recipient state.

What's the gas cost of adding a nonce? One SLOAD + one SSTORE per verification. Roughly 5,000 gas (warm) or 22,100 gas (cold) on Ethereum. Cheap insurance.

Where to see this in Academy

Flux Finance is one of six Shadow Arena audit targets in Zealynx Academy. Auditing the same code with the bug list visible lets you practice the EIP-712 audit checklist above, against real production-like code.

The KYC nonce bug is M-04 in the documented set; the other five Flux Finance findings cover access control, the cToken donation attack pattern (we covered that in the Compound V2 fork donation attack article), and Compound V2 fork inheritance issues.

After working through Flux Finance, the EIP-712 audit pattern transfers to any protocol with custom signed approvals. ERC-2612 permits, governance delegation, off-chain orders, KYC schemes, all benefit from the same checklist.

Tagged

Shadow ArenaFlux FinanceSignature ReplayEIP-712