Sécurité·9 min de lecture·Par Solingo

Attaques de Replay de Signature — Quand EIP-712 Dérape

EIP-712 prévient la plupart des attaques de replay — si vous faites les choses correctement. Erreurs courantes et comment les éviter.

# Attaques de Replay de Signature — Quand EIP-712 Dérape

EIP-712 est le standard pour les signatures structurées en Solidity. Permit, meta-transactions, votes off-chain — tout l'utilise.

Mais EIP-712 ne vous protège pas automatiquement contre les attaques de replay. Si vous oubliez un seul élément, vos signatures peuvent être rejouées à l'infini.

Voici les 5 erreurs les plus courantes et comment les éviter.

Comment EIP-712 Fonctionne

EIP-712 définit un format standard pour signer des données structurées.

// 1. Domain Separator

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

block.chainid,

address(this)

));

// 2. Type Hash

bytes32 PERMIT_TYPEHASH = keccak256(

"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"

);

// 3. Struct Hash

bytes32 structHash = keccak256(abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

nonce,

deadline

));

// 4. Digest Final

bytes32 digest = keccak256(abi.encodePacked(

"",

DOMAIN_SEPARATOR,

structHash

));

// 5. Vérification

address signer = ecrecover(digest, v, r, s);

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

Protection par défaut :

  • ``chainId` → empêche replay cross-chain
  • `verifyingContract` → empêche replay cross-contract
  • `structHash` → empêche modification des paramètres

Mais ça ne suffit pas toujours.

Erreur 1 : Pas de Nonce

Le cas classique : oublier le nonce.

// ❌ VULNÉRABLE

contract VulnerablePermit {

bytes32 public DOMAIN_SEPARATOR;

bytes32 public constant PERMIT_TYPEHASH = keccak256(

"Permit(address owner,address spender,uint256 value,uint256 deadline)"

);

function permit(

address owner,

address spender,

uint256 value,

uint256 deadline,

uint8 v, bytes32 r, bytes32 s

) external {

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

bytes32 structHash = keccak256(abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

deadline

));

bytes32 digest = keccak256(abi.encodePacked(

"",

DOMAIN_SEPARATOR,

structHash

));

address signer = ecrecover(digest, v, r, s);

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

allowance[owner][spender] = value;

}

}

Exploit : l'attaquant peut rejouer la même signature plusieurs fois (tant que `deadline` n'est pas expiré).

// Alice signe permit(alice, bob, 100, deadline)

// Bob utilise la signature

permit(alice, bob, 100, deadline, v, r, s); // allowance = 100

// Bob REJOUE la signature

permit(alice, bob, 100, deadline, v, r, s); // allowance = 100 (écrase, pas de problème ici)

// Mais si le contrat ajoute l'allowance au lieu de l'écraser :

// allowance[alice][bob] += value;

// → Bob obtient 200, 300, ... à l'infini

Fix : ajouter un nonce.

// ✅ SAFE

contract SafePermit {

mapping(address => uint256) public nonces;

bytes32 public constant PERMIT_TYPEHASH = keccak256(

"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"

);

function permit(

address owner,

address spender,

uint256 value,

uint256 nonce,

uint256 deadline,

uint8 v, bytes32 r, bytes32 s

) external {

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

require(nonce == nonces[owner]++, "Invalid nonce"); // ← Nonce

bytes32 structHash = keccak256(abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

nonce,

deadline

));

// ...

}

}

Leçon : toujours inclure un nonce dans le typehash.

Erreur 2 : ChainID Oublié

Le `DOMAIN_SEPARATOR` doit inclure `block.chainid` pour empêcher le replay cross-chain.

// ❌ VULNÉRABLE

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

address(this)

// ❌ Pas de chainId

));

Exploit : une signature valide sur Ethereum fonctionne aussi sur Polygon, Arbitrum, etc. (si le contrat est déployé à la même adresse).

Alice signe sur Ethereum mainnet (chainId 1)

→ Attaquant rejoue la signature sur Polygon (chainId 137)

→ Signature valide (même signer, même structHash)

Fix : inclure `block.chainid` dans le domain separator.

// ✅ SAFE

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

block.chainid, // ← ChainID

address(this)

));

Attention : si votre contrat est upgradable ou peut migrer de chain, recalculez le domain separator dynamiquement.

function _domainSeparator() internal view returns (bytes32) {

return keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

block.chainid, // ← Dynamique

address(this)

));

}

Erreur 3 : Collision d'Addresses Cross-Chain

Même avec `chainId`, vous n'êtes pas toujours safe si le contrat est déployé à la même adresse sur plusieurs chains.

Contrat déployé à 0xABCD... sur :
  • Ethereum (chainId 1)
  • Polygon (chainId 137)
  • Arbitrum (chainId 42161)

Si le domain separator est calculé à la construction (et non dynamiquement), le replay est possible après un fork.

// ❌ VULNÉRABLE (domain separator calculé à la construction)

constructor() {

DOMAIN_SEPARATOR = keccak256(abi.encode(

// ...

block.chainid, // Fixé à la construction

address(this)

));

}

// Scenario :

// 1. Contrat déployé sur Ethereum (chainId 1)

// 2. Ethereum fork → nouvelle chain (chainId 12345)

// 3. DOMAIN_SEPARATOR reste celui de chainId 1

// → Signatures de chainId 1 valides sur chainId 12345

Fix : recalculer le domain separator dynamiquement.

// ✅ SAFE

function DOMAIN_SEPARATOR() public view returns (bytes32) {

return keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

block.chainid, // ← Toujours à jour

address(this)

));

}

Ou utilisez OpenZeppelin EIP712 qui gère ça automatiquement.

Erreur 4 : Replay Cross-Contract

Même chain, même chainId, mais contrats différents.

// Contrat A et B ont le même domain separator

// (même nom, version, chainId, mais adresses différentes)

// Alice signe pour Contrat A

permit(alice, bob, 100, nonce, deadline, v, r, s);

// ❌ Attaquant rejoue sur Contrat B (si pas de verifyingContract)

Fix : toujours inclure `address(this)` dans le domain separator.

// ✅ SAFE

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(

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

keccak256(bytes("MyContract")),

keccak256(bytes("1")),

block.chainid,

address(this) // ← Empêche cross-contract replay

));

Erreur 5 : Nonces Non-Invalidables

Un nonce séquentiel (`nonces[owner]++`) force l'utilisateur à utiliser les signatures dans l'ordre.

// Problème : Alice signe 3 permits (nonce 0, 1, 2)

// Elle veut seulement utiliser le nonce 2

// → Impossible (doit d'abord utiliser 0 et 1)

Fix : utiliser un bitmap de nonces (pattern OpenZeppelin).

// ✅ Pattern Nonces OpenZeppelin

contract FlexibleNonces {

mapping(address => mapping(uint256 => uint256)) private _nonces;

function nonces(address owner, uint256 word) external view returns (uint256) {

return _nonces[owner][word];

}

function _useNonce(address owner, uint256 nonce) internal {

uint256 word = nonce / 256;

uint256 bit = nonce % 256;

uint256 bitmap = _nonces[owner][word];

require((bitmap & (1 << bit)) == 0, "Nonce used");

_nonces[owner][word] = bitmap | (1 << bit);

}

}

Avantages :

  • Nonces non-séquentiels (utilisables dans n'importe quel ordre)
  • Révocation possible (marquer un nonce comme utilisé avant son usage)

Code Complet : Permit Safe

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

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

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

contract SafePermit is EIP712, Nonces {

bytes32 public constant PERMIT_TYPEHASH = keccak256(

"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"

);

mapping(address => mapping(address => uint256)) public allowance;

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

function permit(

address owner,

address spender,

uint256 value,

uint256 deadline,

uint8 v, bytes32 r, bytes32 s

) external {

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

bytes32 structHash = keccak256(abi.encode(

PERMIT_TYPEHASH,

owner,

spender,

value,

_useNonce(owner), // ← OpenZeppelin Nonces (séquentiel)

deadline

));

bytes32 digest = _hashTypedDataV4(structHash);

address signer = ecrecover(digest, v, r, s);

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

allowance[owner][spender] = value;

}

}

Protection :

  • ✅ Nonce (via OpenZeppelin `Nonces`)
  • ✅ ChainID (via `EIP712`)
  • ✅ VerifyingContract (via `EIP712`)
  • ✅ Deadline

Tests avec Foundry

function testPermitReplayProtection() public {

uint256 privateKey = 0xabc123;

address owner = vm.addr(privateKey);

uint256 nonce = permit.nonces(owner);

uint256 deadline = block.timestamp + 1 days;

// Créer la signature

bytes32 structHash = keccak256(abi.encode(

permit.PERMIT_TYPEHASH(),

owner,

bob,

100e18,

nonce,

deadline

));

bytes32 digest = permit.DOMAIN_SEPARATOR(); // (simplifié)

(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);

// Utiliser la signature

permit.permit(owner, bob, 100e18, deadline, v, r, s);

assertEq(permit.allowance(owner, bob), 100e18);

// ❌ Rejouer → fail (nonce invalidé)

vm.expectRevert("Invalid nonce");

permit.permit(owner, bob, 100e18, deadline, v, r, s);

}

Conclusion

EIP-712 est puissant, mais ne protège pas automatiquement contre tous les replays.

Checklist de sécurité :

  • Nonce dans le typehash
  • ChainID dans le domain separator (dynamique)
  • VerifyingContract dans le domain separator
  • Deadline pour expirer les signatures
  • OpenZeppelin EIP712 + Nonces (évite les bugs)

Patterns recommandés :

  • Hériter de `EIP712` (OpenZeppelin)
  • Hériter de `Nonces`` (OpenZeppelin)
  • Tester les cas de replay avec Foundry

Signez safe. 🔐

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement