zkLink piraté via une faille de falsification de racine de lot

Un attaquant a exploité des faiblesses de conception dans le contrat Arbitrator de zkLink pour retirer 783 000 $

zkLink piraté via une faille de falsification de racine de lot

Contexte

Le protocole zkLink multi-chaînes zk-rollup a subi une perte d’environ 783 000 $ le 29 octobre 2026, suite à une attaque exploitant la vérification des messages cross-chain. L’examen forensique de SlowMist a révélé que l’incident provient de faiblesses de conception dans le mécanisme de revendication de messages du contrat Arbitrator, permettant à un attaquant de falsifier des racines de lots et d’effectuer des retraits non autorisés.

Architecture du protocole

Sur Ethereum, zkLink fonctionne via le contrat ZkLink à l’adresse 0x5fd9f73286b7e8683bab45019c94553b93e015cf, qui fait office de hub de messagerie L1 et de trésorerie ETH. Le L1ERC20Bridge à l’adresse 0xad16edcf7deb7e90096a259c81269d811544b6b6 détient les actifs ERC-20.

L’autorisation de retrait repose sur les racines de lots : chaque retrait L2 génère un log L2→L1, dont les logs issus d’un même lot forment un arbre de Merkle. Une fois l’exécution L2 terminée pour un lot, la racine des logs—stockée comme l2LogsRootHashes[batchNumber]—est synchronisée sur le contrat ZkLink L1. Les utilisateurs soumettent des messages de retrait accompagnés de preuves Merkle lors du retrait sur L1 ; ZkLink valide les preuves par rapport à la racine stockée avant de libérer les fonds. Une racine falsifiée rend cette vérification inopérante, car tout message fabriqué peut être accompagné d’une preuve correspondante. Le compteur totalBatchesExecuted indique le numéro de lot exécuté le plus élevé, la vérification exigeant que _batchNumber ≤ totalBatchesExecuted.

La messagerie cross-chain passe par des passerelles intermédiaires et non par une transmission directe entre chaînes. EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c) gère les opérations côté Ethereum, tandis que LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b) gère Linea. Le contrat Arbitrator à l’adresse 0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 coordonne ces passerelles de manière centralisée.

La fonction claimMessage de l’Arbitrator traite la revendication et la transmission des messages lorsqu’elle est appelée par des relayeurs de confiance. Elle utilise le service de messagerie de la chaîne source désigné par le relayeur pour réclamer les messages cross-chain, puis les transmet à la passerelle de destination. Pour garantir que le contenu transmis correspond au message revendiqué, l’Arbitrator écrit le hash du message dans un stockage transitoire via un rappel receiveMessage lors de la revendication, que claimMessage vérifie ensuite.

Le stockage transitoire, introduit dans Solidity 0.8.24 avec EIP-1153, persiste pendant toute une transaction plutôt qu’au niveau de chaque fonction : les valeurs écrites lors d’un appel restent accessibles pour les appels suivants dans la même transaction, et sont effacées uniquement à la fin de la transaction.

Le vecteur d’attaque provient du contrat SyncL2TxHashRelayer (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6). Sa fonction claimPrimaryChainSyncL2TxHashMessage ne possède aucune restriction d’accès et fait simplement passer les paramètres fournis par l’appelant directement à Arbitrator.claimMessage. Son invocation réussie de claimMessage confirme son enregistrement en tant que relayeur autorisé dans l’Arbitrator.

Mécanismes de la vulnérabilité

L’exploit repose sur deux faiblesses interdépendantes dans la fonction claimMessage du contrat Arbitrator, qui gère à la fois la revendication et la transmission des messages, mais souffrait d’hypothèses de confiance erronées dans ces deux opérations.

Première faiblesse : la fonction donne aux relayeurs un contrôle total sur les cibles d’exécution externes. Les paramètres _sourceChainCanonicalMessageService et _sourceChainClaimCallData sont fournis par le relayeur, et l’Arbitrator exécute l’appel sous sa propre autorité. En usage légitime, la cible est l’interface de service de messagerie de la chaîne source, mais un attaquant peut la rediriger vers n’importe quelle passerelle enregistrée dans l’Arbitrator, invoquant ainsi des fonctions de passerelle protégées par les modificateurs onlyArbitrator.

Deuxième faiblesse : la vérification de cohérence peut être contournée à cause du comportement du stockage transitoire. La vérification lit finalizeMessageHash dans le stockage transitoire, mais cette case n’est jamais effacée durant une transaction et persiste au-delà des appels de fonction individuels. Une revendication légitime survenant plus tôt dans la transaction écrit une valeur dans cette case, qu’une invocation suivante de claimMessage peut réutiliser pour satisfaire la vérification—même si le second appel ne revendique aucun message. L’attaquant doit seulement veiller à utiliser des paramètres (gateway, value, callData) identiques lors des deux appels. La vérification garantit donc uniquement qu’« une revendication correspondante a eu lieu quelque part dans cette transaction », et non que « cette invocation a revendiqué ce message ».

La falsification de racine de lot devient possible en redirigeant l’appel externe vers EthereumGateway, obligeant l’Arbitrator à invoquer sa fonction sendMessage. Cette fonction vérifie uniquement que l’appelant est le contrat Arbitrator avant de transmettre le calldata directement au contrat ZkLink. Puisque l’appel provient de l’Arbitrator, la vérification est valide. L’attaquant renseigne _callData avec syncBatchRoot(116409, forgedRoot, 0).

La fonction syncBatchRoot de ZkLink utilise un modificateur onlyGateway exigeant que l’appelant soit une passerelle configurée. Comme EthereumGateway transmet l’appel, cette exigence est satisfaite. La fonction accepte n’importe quel numéro de lot et valeur de racine sans vérifier les preuves de lot, écrasant directement l2LogsRootHashes[_batchNumber] avec la racine fournie. Si _batchNumber dépasse totalBatchesExecuted, le compteur avance jusqu’à _batchNumber. L’attaquant a choisi 116409—le prochain lot non exécuté à ce moment-là—avec une racine de log forgée, remplaçant l2LogsRootHashes[116409] par la valeur falsifiée tout en incrémentant totalBatchesExecuted de 116408 à 116409. Une fois la racine falsifiée, les preuves Merkle valident inévitablement, permettant la création de messages de retrait arbitraires et la sortie de fonds via des preuves fabriquées.

Séquence d’exécution

Transaction d’attaque : 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
EOA de l’attaquant : 0xaca0655a11200a80d33d0e48786317304cde5718
Contrat d’attaque : 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07

  • Le contrat d’attaque a interrogé totalBatchesExecuted() de ZkLink afin d’obtenir le dernier numéro de lot exécuté, puis l’a incrémenté de un pour cibler 116409 en vue de la falsification. Les preuves de retrait ultérieures exigent que les numéros de lot ne dépassent pas totalBatchesExecuted, imposant d’enregistrer le lot falsifié comme « exécuté ». La fonction syncBatchRoot accepte des numéros et racines arbitraires tout en avançant totalBatchesExecuted, ce qui signifie que le contrôle de l’attaquant sur l’invocation de la passerelle détermine quel lot est considéré comme exécuté.
  • L’attaquant a initié une revendication de message cross-chain légitime depuis Linea en appelant Arbitrator.claimMessage via SyncL2TxHashRelayer. La soumission comportait une preuve valide d’un véritable message Linea, spécifiant l’adresse du service de messagerie de la chaîne source comme Linea Rollup et fournissant la preuve claimMessageWithProof. Ce message Linea→Ethereum, émis par l’attaquant sur Linea, désignait LineaL1Gateway comme destinataire avec instruction de transmettre un appel syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) à la passerelle de destination. Après validation de la preuve par Linea Rollup, celle-ci a déclenché le rappel de LineaL1Gateway, qui a appelé la fonction receiveMessage de l’Arbitrator, inscrivant le hash du message dans le slot finalizeMessageHash du stockage transitoire. La fonction claimMessage a alors comparé la valeur. L’attaquant a ainsi obtenu la valeur de vérification finalizeMessageHash via une opération de revendication légitime, la laissant stockée pour toute la durée de la transaction. L’enregistrement de SyncL2TxHashRelayer comme relayeur de l’Arbitrator, combiné à l’absence de contrôle d’accès de sa fonction claimPrimaryChainSyncL2TxHashMessage, permet à n’importe quelle adresse de s’en servir pour appeler claimMessage. Par ailleurs, claimMessage permet aux relayeurs de spécifier eux-mêmes les adresses du service de messagerie de la chaîne source, et la valeur de vérification écrite lors de la revendication légitime persiste dans le stockage transitoire jusqu’à la fin de la transaction.
  • Le contrat d’attaque a immédiatement rappelé le même relayeur, modifiant seulement deux paramètres : remplaçant EthereumGateway (enregistrée dans l’Arbitrator) à la place de Linea Rollup comme service de messagerie de la chaîne source, et remplaçant _sourceChainClaimCallData par sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), ""). Tous les autres paramètres—passerelle de la chaîne source, montant reçu, calldata déclaré à recevoir et paramètres de transmission—étaient identiques à l’invocation précédente, octet par octet. Ces paramètres servent de base à la vérification finalizeMessageHash. Parce que finalizeMessageHash réside dans le stockage transitoire, il n’est jamais effacé au sein d’une transaction et reste indépendant du fait que l’invocation courante ait effectivement revendiqué un message. Ni l’adresse cible de l’appel externe ni son calldata ne participent à la vérification, permettant la réutilisation directe de la valeur laissée par la revendication précédente. De plus, Address.functionCall autorise l’Arbitrator à exécuter n’importe quel appel spécifié par l’attaquant sous sa propre identité—le cœur même de la vulnérabilité. Ce second appel ne revendiquait aucun message, mais a passé la vérification claimMessage tandis que l’appel externe de l’attaquant vers sendMessage d’EthereumGateway avait déjà été exécuté avant la vérification.
  • Lorsque l’Arbitrator a appelé la fonction sendMessage d’EthereumGateway, le calldata contenait exactement l’appel syncBatchRoot(116409, forgedRoot, 0) fourni par l’attaquant. ZkLink a donc enregistré la racine falsifiée comme racine de log du lot. Les preuves on-chain montrent que ZkLink a émis l’événement SyncBatchRoot(116409, 0x1934a1c3…), l2LogsRootHashes[116409] est passé de zéro à la racine construite par l’attaquant, et totalBatchesExecuted a été incrémenté de 116408 à 116409. La racine falsifiée était désormais consignée « légitimement » dans le registre du protocole. Les deux appels ont aussi transmis un appel syncL2TxHash, définissant l2TxHashMap[0x2575…] à 0xe9ec…—un effet secondaire inoffensif dû à la réutilisation de la valeur de vérification. Parce que EthereumGateway.sendMessage vérifie uniquement que l’appelant est l’Arbitrator avant de transmettre le calldata à ZkLink, et que ZkLink.syncBatchRoot exige seulement que l’appelant soit une passerelle sans vérifier les preuves de lot, les deux couches de « vérification de l’appelant sans validation du contenu » ont été franchies, autorisant l’inscription de la racine falsifiée en tant que légitime.
  • Après la falsification de la racine, tout message de retrait accompagné d’une preuve valide contre cette racine passait la vérification. Le contrat d’attaque a exécuté 12 retraits finaux consécutifs, tous visant le lot 116409 : un retrait d’ETH suivi de 11 retraits ERC-20. Chaque message contenait quatre nœuds Merkle construits par l’attaquant sur la base de la racine falsifiée, garantissant la validation des preuves. Le contrat ZkLink a traité directement le retrait d’ETH, tandis que L1ERC20Bridge a géré les 11 retraits ERC-20. Après validation des preuves, les tokens ont été transférés directement du contrat bridge vers l’EOA de l’attaquant. Les deux contrats ont enregistré les messages comme retirés, mais ce marquage n’empêche que les soumissions en double et ne peut vérifier si les messages sont effectivement apparus sur L2. Les contrats de retrait ne vérifient que « l’inclusion du message dans l’arbre correspondant à la racine de lot stockée », et non si la racine elle-même provient d’une exécution réelle sur L2. Une fois la racine falsifiée, n’importe quel message peut être accompagné d’une preuve valide.

Mesures correctives nécessaires

Cet incident résulte de la combinaison entre la capacité d’appel externe arbitraire dans le contrat Arbitrator et la réutilisation de valeurs de stockage transitoire non effacées pour la vérification. Arbitrator.claimMessage permet aux relayeurs de désigner des cibles et des calldata arbitraires, exécutant des appels externes sous sa propre autorité. Son unique vérification de finalité compare avec une valeur résiduelle du stockage transitoire sans l’effacer ni s’assurer que l’invocation courante a bien revendiqué un message. L’attaquant a utilisé une revendication légitime pour écrire la valeur de vérification, puis l’a réutilisée dans un second appel claimMessage, forçant l’Arbitrator à invoquer l’interface onlyArbitrator de la passerelle au nom de l’attaquant, écrivant finalement la racine de lot falsifiée dans ZkLink et rendant les actifs détenus par le bridge « prouvablement » retirables.

La remédiation doit intervenir à trois niveaux. Premièrement, claimMessage doit effacer finalizeMessageHash avant d’invoquer Address.functionCall, puis exiger qu’il soit non nul et corresponde à la déclaration de l’appel courant, ou utiliser un nonce incrémental par appel pour lier la valeur, afin de garantir que la valeur de vérification ne puisse être écrite par receiveMessage que durant l’invocation en cours. Deuxièmement, implémenter une liste blanche pour _sourceChainCanonicalMessageService afin d’empêcher que des passerelles enregistrées dans l’Arbitrator puissent servir de « services de messagerie de chaîne source ». Les contrats d’aide au développement ne doivent pas être enregistrés comme relayeurs, ou doivent mettre en place des contrôles d’accès empêchant des adresses arbitraires de pouvoir invoquer claimMessage indirectement. Troisièmement, ZkLink.syncBatchRoot doit refuser toute tentative d’écrasement d’une racine de lot existante, et une couche de preuve indépendante doit être ajoutée au niveau de la passerelle ou de la messagerie pour s’assurer que les mises à jour de racine ne puissent réussir uniquement car « l’appelant est une passerelle ».

Populaires

Envoyer une demande

Laisser une demande

Comment vous contacter ?

Indiquez votre identifiant Telegram ou votre e-mail, selon le mode de contact choisi.