Security·9 min का पठन·Solingo द्वारा

Signature Replay Attacks — जब EIP-712 गलत हो जाता है

EIP-712 replay attacks से बचाता है — लेकिन details सही होने पर। Common mistakes और कैसे बचें।

# Signature Replay Attacks — जब EIP-712 गलत हो जाता है

EIP-712 signatures powerful हैं — gasless transactions, meta-transactions, permits। लेकिन implementation गलत हो जाए तो catastrophic replay attacks हो सकते हैं।

Signature Replay क्या है?

Attacker एक valid signature को दोबारा use करता है — different context में।

// User signs: "Transfer 100 USDC to Bob"

bytes memory signature = sign(message, userPrivateKey);

// ❌ Vulnerable contract

function transfer(address to, uint amount, bytes memory sig) external {

bytes32 hash = keccak256(abi.encode(to, amount));

address signer = recoverSigner(hash, sig);

// No replay protection!

balances[signer] -= amount;

balances[to] += amount;

}

Attack: Bob signature को 100 बार replay कर सकता है। 100 USDC की जगह 10,000 USDC!

---

EIP-712 Basics

EIP-712 structured data signing standard है। यह हमें protect करता है:

struct EIP712Domain {

string name;

string version;

uint256 chainId;

address verifyingContract;

}

struct Transfer {

address to;

uint amount;

uint nonce; // ✅ Replay protection

}

Domain Separator

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),

keccak256(bytes("MyToken")),

keccak256(bytes("1")),

block.chainid,

address(this)

));

यह signature को specific contract + chain से bind करता है।

---

Common Mistakes

1. Missing Nonce

// ❌ Vulnerable — no nonce

struct Permit {

address owner;

address spender;

uint value;

uint deadline;

}

// Signature replay होगा!

Fix: Nonce add करो।

// ✅ Safe

struct Permit {

address owner;

address spender;

uint value;

uint deadline;

uint nonce; // ✅ Unique per signature

}

mapping(address => uint) public nonces;

function permit(..., bytes memory sig) external {

bytes32 structHash = keccak256(abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

deadline,

nonces[owner]++ // ✅ Increment nonce

));

// Verify signature...

}

2. Missing Chain ID

// ❌ Vulnerable — no chainId in domain

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

DOMAIN_TYPEHASH,

keccak256(bytes(name)),

keccak256(bytes(version)),

address(this)

// ⚠️ Missing chainId!

));

Attack: Signature Ethereum mainnet पर valid है, तो Polygon पर भी valid होगा!

// ✅ Fix: Include chainId

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

DOMAIN_TYPEHASH,

keccak256(bytes(name)),

keccak256(bytes(version)),

block.chainid, // ✅ Chain-specific

address(this)

));

3. Address Collision

Multiple contracts same address पर deploy हो सकते हैं (different chains पर)।

// TokenA deployed at 0x123... on Ethereum

// TokenB deployed at 0x123... on Polygon (CREATE2)

// ❌ Signature cross-chain replay हो सकता है!

Fix: ChainId automatically इससे protect करता है — domain separator में शामिल करना जरूरी है।

4. Cross-Contract Replay

// Two contracts, same chain

contract TokenA {

bytes32 DOMAIN_SEPARATOR = keccak256(...); // Uses address(this)

}

contract TokenB {

bytes32 DOMAIN_SEPARATOR = keccak256(...); // Different address

}

यह safe है क्योंकि address(this) different है। लेकिन अगर आप domain separator hardcode करते हैं:

// ❌ NEVER hardcode domain separator!

bytes32 constant DOMAIN_SEPARATOR = 0x1234...;

Fix: हमेशा runtime पर calculate करो।

---

Real-World Example: ERC20 Permit

OpenZeppelin का ERC20Permit implementation देखें:

import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract MyToken is ERC20Permit {

constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {}

}

Under the Hood

function permit(

address owner,

address spender,

uint value,

uint deadline,

uint8 v,

bytes32 r,

bytes32 s

) public virtual {

require(block.timestamp <= deadline, "Permit expired");

bytes32 structHash = keccak256(

abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

_useNonce(owner), // ✅ Nonce automatically increments

deadline

)

);

bytes32 hash = _hashTypedDataV4(structHash); // ✅ Includes domain separator

address signer = ECDSA.recover(hash, v, r, s);

require(signer == owner, "Invalid signature");

_approve(owner, spender, value);

}

Protections:

  • ✅ Nonce via _useNonce()
  • ✅ ChainId in domain separator
  • ✅ Contract address in domain separator
  • ✅ Deadline expiration
  • ✅ Malleability protection (ECDSA.recover)
  • ---

    Implementing Secure Signatures

    Step 1: Define Structs

    // EIP-712 domain
    

    bytes32 private constant DOMAIN_TYPEHASH = keccak256(

    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"

    );

    // Your message type

    bytes32 private constant WITHDRAW_TYPEHASH = keccak256(

    "Withdraw(address user,uint256 amount,uint256 nonce,uint256 deadline)"

    );

    Step 2: Compute Domain Separator

    function DOMAIN_SEPARATOR() public view returns (bytes32) {
    

    return keccak256(abi.encode(

    DOMAIN_TYPEHASH,

    keccak256(bytes("MyVault")), // name

    keccak256(bytes("1")), // version

    block.chainid, // ✅ Current chain

    address(this) // ✅ This contract

    ));

    }

    Step 3: Hash Typed Data

    function getWithdrawHash(
    

    address user,

    uint amount,

    uint nonce,

    uint deadline

    ) public view returns (bytes32) {

    bytes32 structHash = keccak256(abi.encode(

    WITHDRAW_TYPEHASH,

    user,

    amount,

    nonce,

    deadline

    ));

    // EIP-712 final hash

    return keccak256(abi.encodePacked(

    "\x19\x01", // EIP-712 prefix

    DOMAIN_SEPARATOR(),

    structHash

    ));

    }

    Step 4: Verify Signature

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

    import "@openzeppelin/contracts/utils/Nonces.sol";

    contract SecureVault is Nonces {

    using ECDSA for bytes32;

    function withdrawWithSignature(

    address user,

    uint amount,

    uint deadline,

    bytes memory signature

    ) external {

    require(block.timestamp <= deadline, "Signature expired");

    bytes32 hash = getWithdrawHash(

    user,

    amount,

    _useNonce(user), // ✅ Auto-increment nonce

    deadline

    );

    address signer = hash.recover(signature); // ✅ ECDSA protections

    require(signer == user, "Invalid signature");

    // Execute withdrawal

    balances[user] -= amount;

    payable(user).transfer(amount);

    }

    }

    ---

    Testing Replay Protection

    Test 1: Nonce Replay

    function testCannotReplaySignature() public {
    

    uint amount = 100e18;

    uint deadline = block.timestamp + 1 hours;

    bytes32 hash = vault.getWithdrawHash(user, amount, 0, deadline);

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash);

    bytes memory sig = abi.encodePacked(r, s, v);

    // First call succeeds

    vault.withdrawWithSignature(user, amount, deadline, sig);

    // Second call with same signature fails

    vm.expectRevert("Invalid signature"); // Nonce changed

    vault.withdrawWithSignature(user, amount, deadline, sig);

    }

    Test 2: Cross-Chain Replay

    function testCannotReplayCrossChain() public {
    

    // Sign on Ethereum (chainId 1)

    vm.chainId(1);

    bytes32 hash = vault.getWithdrawHash(user, 100e18, 0, deadline);

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash);

    // Try to use on Polygon (chainId 137)

    vm.chainId(137);

    vm.expectRevert("Invalid signature");

    vault.withdrawWithSignature(user, 100e18, deadline, abi.encodePacked(r,s,v));

    }

    Test 3: Deadline Expiration

    function testExpiredSignature() public {
    

    uint deadline = block.timestamp + 1 hours;

    bytes memory sig = signWithdraw(user, 100e18, 0, deadline);

    // Warp past deadline

    vm.warp(deadline + 1);

    vm.expectRevert("Signature expired");

    vault.withdrawWithSignature(user, 100e18, deadline, sig);

    }

    ---

    OpenZeppelin Helpers

    Instead of manual implementation, use OpenZeppelin:

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

    import "@openzeppelin/contracts/utils/Nonces.sol";

    contract MyVault is EIP712, Nonces {

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

    function withdrawWithSignature(...) external {

    bytes32 structHash = keccak256(abi.encode(

    WITHDRAW_TYPEHASH,

    user,

    amount,

    _useNonce(user), // ✅ Built-in nonce management

    deadline

    ));

    bytes32 hash = _hashTypedDataV4(structHash); // ✅ EIP-712 magic

    address signer = ECDSA.recover(hash, signature);

    require(signer == user, "Invalid signature");

    // ...

    }

    }

    Benefits:

    • EIP712 base class domain separator manage करता है
    • Nonces nonce tracking handle करता है
    • ECDSA.recover malleability protection
    • Battle-tested code

    ---

    Advanced: Batch Nonces

    कभी-कभी sequential nonces limiting हैं। Users parallel signatures चाहते हैं।

    contract AdvancedNonces {
    

    // mapping(user => mapping(nonce => used))

    mapping(address => mapping(uint => bool)) public usedNonces;

    function withdraw(uint nonce, ..., bytes memory sig) external {

    require(!usedNonces[user][nonce], "Nonce used");

    usedNonces[user][nonce] = true;

    // Verify signature with this nonce

    // ...

    }

    }

    Users अब arbitrary nonces choose कर सकते हैं — parallel signing possible!

    ---

    Conclusion

    EIP-712 powerful है, लेकिन devils in the details। Replay attacks devastating हो सकते हैं।

    Security Checklist:

    • ✅ Nonces use करो (OpenZeppelin Nonces)
    • ✅ ChainId domain separator में
    • ✅ Contract address domain separator में
    • ✅ Deadlines implement करो
    • ✅ ECDSA.recover use करो (malleability protection)
    • ✅ OpenZeppelin EIP712 base class prefer करो

    Never:

    • ❌ Domain separator hardcode मत करो
    • ❌ Nonces skip मत करो
    • ❌ ChainId omit मत करो
    • ❌ Raw signature verification manually मत करो

    Signatures सही तरीके से implement करो — या भारी कीमत चुकाओ। 🔐

    Practice में लगाने के लिए तैयार हैं?

    Solingo पर interactive exercises के साथ इन concepts को apply करें।

    मुफ्त में शुरू करें