Équipe de sécurité Cobo : Les risques cachés et les opportunités d'arbitrage lors du hard fork d'ETH
TechFlow SélectionTechFlow Sélection
Équipe de sécurité Cobo : Les risques cachés et les opportunités d'arbitrage lors du hard fork d'ETH
Cet article analysera, à travers ces deux incidents, l'impact des attaques de répétition (replay attacks) sur les chaînes issues de fork, ainsi que la manière dont les protocoles devraient se prémunir contre de telles attaques.
Cet article est fourni par l'équipe de sécurité blockchain Cobo, dont les membres proviennent de sociétés renommées spécialisées dans la sécurité blockchain et possèdent une riche expérience dans l'audit de contrats intelligents. L'équipe a déjà découvert des vulnérabilités critiques dans plusieurs projets DeFi. Actuellement, elle se concentre principalement sur la sécurité des contrats intelligents et celle du DeFi, tout en menant des recherches et partageant des technologies avancées en matière de sécurité blockchain.
Nous souhaitons également inviter tous les apprenants passionnés par les cryptomonnaies, dotés d’un esprit scientifique et d’une méthodologie rigoureuse, à rejoindre nos rangs afin de contribuer activement à la réflexion sectorielle et partager leurs analyses !
Il s'agit du 16ᵉ article de Cobo Global.
Introduction
Avec la mise à niveau d’ETH vers le consensus PoS, la chaîne ETH utilisant l’ancien mécanisme PoW a réussi un hard fork soutenu par certaines communautés (désormais appelée ETHW). Toutefois, certains protocoles n’ayant pas été conçus pour anticiper un tel scénario de hard fork, présentent désormais des risques de sécurité sur la chaîne ETHW issue du fork. Le plus grave de ces risques est l’attaque par rejeu (replay attack).
Après la finalisation du hard fork, au moins deux attaques exploitant le mécanisme de rejeu ont eu lieu sur le réseau principal ETHW : l’attaque par rejeu sur OmniBridge et celle sur Polygon Bridge. Cet article analyse ces deux incidents afin d’illustrer l’impact des attaques par rejeu sur les chaînes issues de fork, ainsi que les mesures préventives que les protocoles devraient adopter.
Types d’attaques par rejeu
Avant toute analyse, il convient de distinguer deux types principaux d’attaques par rejeu : le rejeu de transaction et le rejeu de message signé. Voici une explication détaillée de chacun.
Rejeu de transaction
Le rejeu de transaction consiste à copier-coller une transaction effectuée sur la chaîne initiale vers une chaîne cible. Ce type d’attaque opère au niveau de la transaction, qui peut être exécutée normalement et validée sur la chaîne cible. L’un des cas les plus célèbres est l’attaque subie par Wintermute sur Optimism, ayant entraîné la perte de plus de 20 millions de jetons OP. Toutefois, depuis l’implémentation de l’EIP-155, les signatures de transactions incluent désormais un identifiant chainId (un marqueur permettant de distinguer une chaîne de ses forks). Ainsi, si le chainId de la chaîne cible diffère, la transaction ne peut plus être rejouée avec succès.
Rejeu de message signé
Contrairement au rejeu de transaction, le rejeu de message signé concerne uniquement le message lui-même signé via une clé privée (par exemple « Cobo is the best »). L’attaquant n’a pas besoin de rejouer toute la transaction, mais simplement de réutiliser le message signé. Prenons l’exemple du message « Cobo is the best » : celui-ci ne contient aucun paramètre lié à une chaîne spécifique, donc sa signature reste valide sur n’importe quelle chaîne issue d’un fork, ce qui permet de passer la vérification. Pour éviter cela, il est recommandé d’inclure le chainId dans le contenu du message, comme « Cobo is the best + chainId() ». En intégrant un identifiant de chaîne spécifique, le contenu du message varie selon les forks, rendant chaque signature unique et empêchant ainsi son rejeu.
Principe des attaques contre OmniBridge et Polygon Bridge
Analysons maintenant les principes sous-jacents aux attaques contre OmniBridge et Polygon Bridge. Tout d’abord, précisons que ces deux incidents ne relèvent pas d’un rejeu de transaction, car ETHW utilise un chainId différent de celui du réseau principal ETH, empêchant toute validation directe d’une transaction copiée. La seule possibilité restante est donc le rejeu de message signé. Examinons donc comment chacun de ces ponts a été victime d’un tel rejeu sur la chaîne ETHW.
OmniBridge
OmniBridge est un pont utilisé pour transférer des actifs entre xDAI et le réseau principal ETH, reposant sur des validateurs désignés pour soumettre des messages inter-chaînes. Dans OmniBridge, la logique de soumission des messages de validation est la suivante :
function executeSignatures(bytes _data, bytes _signatures) public { _allowMessageExecution(_data, _signatures); bytes32 msgId; address sender; address executor; uint32 gasLimit; uint8 dataType; uint256[2] memory chainIds; bytes memory data; (msgId, sender, executor, gasLimit, dataType, chainIds, data) = ArbitraryMessage.unpackData(_data); _executeMessage(msgId, sender, executor, gasLimit, dataType, chainIds, data); }
Dans cette fonction, la ligne #L2 vérifie que la signature a bien été émise par un validateur autorisé, puis à la ligne #L11, le message data est décodé. On remarque que les données décodées contiennent un champ chainId. Cela suffit-il à empêcher un rejeu ? Analysons davantage.
function _executeMessage( bytes32 msgId, address sender, address executor, uint32 gasLimit, uint8 dataType, uint256[2] memory chainIds, bytes memory data ) internal { require(_isMessageVersionValid(msgId)); require(_isDestinationChainIdValid(chainIds[1])); require(!relayedMessages(msgId)); setRelayedMessages(msgId, true); processMessage(sender, executor, msgId, gasLimit, dataType, chainIds[0], data); }
En examinant la fonction _executeMessage, on observe à la ligne #L11 une vérification de la validité du chainId :
function _isDestinationChainIdValid(uint256 _chainId) internal returns (bool res) { return _chainId == sourceChainId(); } function sourceChainId() public view returns (uint256) { return uintStorage[SOURCE_CHAIN_ID]; }
On constate que la vérification du chainId n’utilise pas l’opcode natif EVM chainid, mais lit une valeur stockée dans la variable uintStorage. Cette valeur étant définie manuellement par un administrateur, le message lui-même ne contient pas véritablement d’identifiant de chaîne. Il devient donc théoriquement possible de procéder à un rejeu de message signé.
Pendant un hard fork, tous les états antérieurs sont conservés inchangés sur les deux chaînes. Si l’équipe xDAI n’intervient pas après le fork, l’état du contrat OmniBridge sera identique sur ETHW et sur ETH. Les validateurs resteront donc les mêmes. Par conséquent, une signature émise par un validateur sur le réseau principal peut être validée sur ETHW. Comme le message signé ne contient pas de chainId, un attaquant peut exploiter ce rejeu pour retirer des actifs du même contrat sur ETHW.
Polygon Bridge
À l’instar d’OmniBridge, Polygon Bridge permet le transfert d’actifs entre Polygon et le réseau principal ETH. Contrairement à OmniBridge, il repose sur des preuves de blocs pour les retraits, selon la logique suivante :
function exit(bytes calldata inputData) external override { //... logique non essentielle omise // vérifier l'inclusion du reçu require( MerklePatriciaProof.verify( receipt.toBytes(), branchMaskBytes, payload.getReceiptProof(), payload.getReceiptRoot() ), "RootChainManager: INVALID_PROOF" ); // vérifier l'inclusion du bloc _checkBlockMembershipInCheckpoint( payload.getBlockNumber(), payload.getBlockTime(), payload.getTxRoot(), payload.getReceiptRoot(), payload.getHeaderNumber(), payload.getBlockProof() ); ITokenPredicate(predicateAddress).exitTokens( _msgSender(), rootToken, log.toRlpBytes() ); }
La logique du contrat montre qu’il effectue deux vérifications : l’inclusion du transactionRoot et du blockNumber, garantissant que la transaction a bien eu lieu sur la chaîne enfant (Polygon). La première vérification peut être contournée, car n’importe qui peut construire un transactionRoot à partir des données de transaction. En revanche, la deuxième vérification semble plus robuste. Mais examinons la fonction _checkBlockMembershipInCheckpoint :
function _checkBlockMembershipInCheckpoint( uint256 blockNumber, uint256 blockTime, bytes32 txRoot, bytes32 receiptRoot, uint256 headerNumber, bytes memory blockProof ) private view returns (uint256) { ( bytes32 headerRoot, uint256 startBlock, , uint256 createdAt, ) = _checkpointManager.headerBlocks(headerNumber); require( keccak256( abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot) ) .checkMembership( blockNumber.sub(startBlock), headerRoot, blockProof ), "RootChainManager: INVALID_HEADER" ); return createdAt; }
Le headerRoot est extrait du contrat _checkpointManager. Suivons la logique jusqu’à l’endroit où _checkpointManager définit headerRoot :
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external { (address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi .decode(data, (address, uint256, uint256, bytes32, bytes32, uint256)); require(CHAINID == _borChainID, "Invalid bor chain id"); require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA"); // vérifier si mieux vaut garder en stockage local IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress()); uint256 _reward = stakeManager.checkSignatures( end.sub(start).add(1), /** préfixe 01 aux données 01 représente un vote positif, 00 un vote négatif un validateur malveillant pourrait tenter d'envoyer 2/3 sur vote négatif, d'où ajout de 01 */ keccak256(abi.encodePacked(bytes(hex"01"), data)), accountHash, proposer, sigs ); //.... reste de la logique omis
À la ligne #L2, on observe que les données signées ne vérifient que le borChainId, sans contrôle du chainId de la chaîne elle-même. Comme le message est signé par un proposer désigné, un attaquant peut théoriquement rejouer cette signature sur une chaîne issue de fork, soumettre un headerRoot valide, puis utiliser Polygon Bridge sur ETHW pour appeler la fonction exit avec une preuve Merkle et réussir le retrait après validation du headerRoot.
Prenons l’adresse 0x7dbf18f679fa07d943613193e347ca72ef4642b9 comme exemple. Elle a réussi à exploiter ETHW en suivant ces étapes :
-
Retirer des fonds depuis un exchange principal grâce à des moyens financiers puissants.
-
Déposer des fonds sur la chaîne Polygon via la fonction depositFor de Polygon Bridge.
-
Appeler la fonction exit sur le réseau principal ETH pour retirer des fonds.
-
Copier le headerRoot soumis par le proposer sur le réseau principal.
-
Rejouer sur ETHW la signature du proposer extraite à l’étape précédente.
-
Appeler exit sur Polygon Bridge d’ETHW pour retirer des fonds.
Pourquoi cela s’est-il produit ?
D’après les deux exemples analysés, on constate que ces protocoles ont subi des attaques par rejeu sur ETHW en raison d’un manque de protection adéquate. Leurs actifs ont ainsi été vidés sur la chaîne issue du fork. Toutefois, comme ces ponts ne supportent pas officiellement ETHW, les utilisateurs n’ont subi aucune perte. Mais pourquoi ces ponts n’ont-ils pas intégré dès leur conception des mécanismes anti-rejeu ? La réponse est simple : OmniBridge et Polygon Bridge ont été conçus pour un usage très spécifique — uniquement entre deux chaînes prédéfinies — sans plan de déploiement multi-chaînes. De ce fait, l’absence de protection contre le rejeu n’avait pas d’impact immédiat sur leur sécurité.
En revanche, pour les utilisateurs sur ETHW, utiliser ces ponts non conçus pour un environnement multi-chaînes expose à un risque inverse : leurs messages signés sur ETHW peuvent être rejoués sur le réseau principal ETH, causant des pertes d’actifs.
Prenons UniswapV2 comme exemple. Son contrat de pool inclut une fonction permit, contenant la variable PERMIT_TYPEHASH et notamment DOMAIN_SEPARATOR.
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external { require(deadline >= block.timestamp, 'UniswapV2: EXPIRED'); bytes32 digest = keccak256( abi.encodePacked( '\x19\x01', DOMAIN_SEPARATOR, keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) ) ); address recoveredAddress = ecrecover(digest, v, r, s); require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE'); _approve(owner, spender, value); }
Cette variable, définie initialement dans l’EIP-712, inclut le chainId et prévoit dès sa conception la prévention du rejeu dans un contexte multi-chaînes. Toutefois, selon la logique du contrat UniswapV2 :
constructor() public { uint chainId; assembly { chainId := chainid } DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), keccak256(bytes(name)), keccak256(bytes('1')), chainId, address(this) ) ); }
Le DOMAIN_SEPARATOR est défini dans le constructeur. Même après un hard fork, si le chainId change, le contrat ne peut pas mettre à jour cette valeur. Ainsi, si un utilisateur accorde une autorisation permit sur ETHW, celle-ci pourrait être rejouée sur le réseau principal ETH. Outre Uniswap, de nombreux autres protocoles (comme certaines versions du contrat Yearn Vault) utilisent aussi un DOMAIN_SEPARATOR figé. Les utilisateurs doivent donc rester vigilants face à ces risques de rejeu lorsqu’ils interagissent sur ETHW.
Mesures préventives dès la conception
Pour les développeurs, lors de la conception d’un mécanisme de signature de messages, il est crucial d’anticiper un éventuel déploiement multi-chaînes. Si un tel scénario est envisagé, le chainId doit être inclus dans le message signé. En outre, lors de la vérification, comme un hard fork conserve tous les états antérieurs, le chainId utilisé pour valider la signature ne doit pas être stocké en tant que variable de contrat, mais récupéré dynamiquement à chaque vérification afin d’assurer la sécurité.
Conséquences
Impact sur les utilisateurs
Les utilisateurs ordinaires devraient éviter toute interaction sur une chaîne issue de fork lorsque le protocole ne la prend pas en charge, afin de prévenir le rejeu de leurs messages signés sur le réseau principal et éviter ainsi toute perte d’actifs.
Impact sur les exchanges et institutions de custody
De nombreux exchanges prennent en charge le jeton ETHW. Les fonds extraits suite à ces attaques pourraient donc être déposés et vendus sur ces plateformes. Toutefois, ces attaques ne résultent pas d’une altération du consensus ni d’une création frauduleuse de jetons. Par conséquent, les exchanges n’ont pas besoin de mesures spécifiques supplémentaires.
Conclusion
Avec l’évolution vers des architectures multi-chaînes, les attaques par rejeu passent progressivement du domaine théorique à une menace courante. Les développeurs doivent soigneusement concevoir leurs protocoles, intégrer systématiquement des éléments comme le chainId dans les signatures de messages, et suivre les meilleures pratiques pour éviter toute perte d’actifs utilisateurs.
Cobo est l'institution de custody de cryptomonnaies la plus importante en Asie-Pacifique. Depuis sa création, elle a fourni des services exceptionnels à plus de 500 institutions leaders du secteur et à des particuliers fortunés. Garantissant la sécurité du stockage des actifs numériques tout en générant des rendements stables, Cobo jouit d'une confiance mondiale. Spécialisée dans la construction d'infrastructures évolutives, Cobo propose des solutions complètes aux institutions : custody sécurisé, valorisation des actifs, interactions blockchain et transferts inter-chaînes et inter-couches, offrant ainsi un soutien technique solide pour leur transition vers le Web 3.0. Les activités de Cobo comprennent Cobo Custody, Cobo DaaS, Cobo MaaS, Cobo StaaS, Cobo Ventures et Cobo DeFi Yield Fund, répondant à une large gamme de besoins.
Bienvenue dans la communauté officielle TechFlow
Groupe Telegram :https://t.me/TechFlowDaily
Compte Twitter officiel :https://x.com/TechFlowPost
Compte Twitter anglais :https://x.com/BlockFlow_News














