# 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. 🔐