Auteur : Johan & Lisa

Éditeur : 77

Le 6 septembre à 13 h 52 min 10 s (UTC), deux transactions de structure identique apparaissent dans le bloc numéro 4 050 335 de Liquid. Une minute plus tard, le bloc numéro 4 050 336 confirme la troisième transaction : environ 3 998,5 L-BTC entrent dans le jeu d’UTXO, sans qu’aucun peg-in ne soit effectué derrière. Quelques minutes plus tard, ces actifs qui n’existent pas encore commencent à être regroupés, puis dépensés, avant d’être convertis en BTC véritables sur le réseau principal de Bitcoin via le canal de peg-out fédéral.

Le lendemain, 3 400 BTC ont été renvoyés vers le portefeuille de peg fédéral. Les 598,5 BTC restants restent entre les mains de l’attaquant ; au moment de la rédaction, ils font l’objet de virements répétés sur eux-mêmes. Chaque transaction comporte un texte en clair OP_RETURN, demandant au fédéral un dixième « de la récompense ». À ce jour, les négociations se poursuivent publiquement en chaîne.

Nous avons vérifié point par point les blocs autour de 4 050 335. Côté Bitcoin, les flux de fonds ont été suivis via MistTrack jusqu’à chaque étape. Cet article traite deux choses : d’une part, ce que l’attaquant a fait techniquement ; d’autre part, l’optimisation de performance introduite dans le code dès 2016, et pourquoi elle a permis, dix ans plus tard, de récupérer quatre mille bitcoins.

Vue d’ensemble de l’attaque

Comment l’argent Liquid est géré

La création de L-BTC n’a qu’une voie. On confie les BTC à un portefeuille multisig géré par la fédération ; côté Liquid, on frappe alors un L-BTC équivalent. En sens inverse, on repasse par le peg-out pour récupérer les BTC. Ce mécanisme d’ancrage 1 pour 1 est gardé par le système de transactions confidentielles. Le montant de chaque sortie n’est pas écrit directement sur la chaîne : il est dissimulé dans un engagement de Pedersen ; et chaque engagement doit être accompagné d’une rangeproof, prouvant que le montant est non négatif et qu’il n’y a pas de dépassement de plage. Lors de la vérification d’une transaction, les nœuds additionnent séparément les points d’engagement des entrées et ceux des sorties ; si les deux côtés correspondent, l’écriture comptable est considérée comme valide.

Tant qu’une seule sortie peut être insérée avec une fausse rangeproof, un nombre négatif peut être caché dans l’engagement ; ou un nombre astronomique. En mathématiques, cela revient à créer de la monnaie “du néant”. Donc la rangeproof doit être vérifiée à chaque fois, réellement.

Le problème, c’est que ça coûte cher. Une rangeproof fait souvent plus de quatre mille octets ; la vérification correspond à de véritables calculs d’ellipse, et il faut la refaire pour chaque sortie. Le 12 juillet 2016, une soumission intitulée “Rangeproof caching” (66ab07bd1b) est entrée dans le dépôt de code Elements et a repris l’idée du cache de signatures de Bitcoin. Les preuves déjà vérifiées ne sont plus revérifiées : le résultat “vérification réussie” est enregistré dans une table de cache ; la prochaine fois, si les entrées sont identiques, on autorise directement.

Ce type d’optimisation est très courant dans le code cryptographique. Cette fois encore, c’est précisément à cause d’elle que tous les problèmes se sont produits.

Ce qui manque dans la clé de cache

Regardons d’abord ce morceau de code dans la version publiée 23.3.3.

La clé de cache est la concaténation à nu de la proof et de la commitment dans un même flux SHA-256. proof est un champ de longueur variable ; la version longue fait plus de quatre mille octets. Dans cette clé, il n’y a ni actif, ni script, ni aucune information sur les frontières entre champs.

Le correctif soumis le 3 août (c26d719) — de cette année-là — a rajouté les engagements d’actifs et les scripts de verrouillage, en les transformant en quatre champs. L’orientation était correcte : l’ancien problème était bien que, pour une même preuve, on pouvait “réutiliser” à répétition des associations différentes d’actifs et de scripts. Le patch concatène encore à nu les quatre champs dans le même flux. proof et scriptPubKey sont tous deux des champs de longueur variable : les limites entre eux peuvent donc être déplacées d’un endroit à l’autre.

Le dépôt de renforcement du 8 septembre (94000967f6) remplace par un hachage sérialisé avec préfixe de longueur. Dans le message de soumission, une phrase est dite de façon très explicite.

Ceci sérialise chaque champ avec un préfixe de longueur ; ainsi, des tuples d’arguments distincts ayant des concaténations brutes identiques octet pour octet ne se retrouvent plus en collision sur la même clé de cache. Une entrée de cache correspond à un résultat de vérification positif ; une collision permettrait à un attaquant de contourner la vérification.

Dans le cache, on stocke « vérification réussie ». Si les clés entrent en collision, cela revient à garantir qu’un objet jamais vérifié bénéficie de la même approbation. On a aussi découvert qu’au sein de la soumission, la clé de cache de la surjection proof ne liait même pas l’étiquette d’actif d’entrée : ce point a aussi été corrigé.

Succès en cache = vérification évitée

Le passage en mode « autorisé » se produit au tout début de CachingRangeProofChecker::VerifyRangeProof.

Le test renvoie directement true. Les vérifications suivantes secp256k1_rangeproof_verify et le contrôle min_value ne s’exécutent pas une seule ligne.

L’écriture et la suppression du cache obéissent à des règles distinctes ; c’est la clé pour comprendre la temporalité de l’attaque. Quand une transaction entre dans le mempool, elle est contrôlée selon un mode “permet d’écrire” : le résultat vérifié est stocké dans le cache, pour être réutilisé la prochaine fois lors de la validation d’un bloc. Inversement, lors de la connexion des blocs, c’est l’inverse : on ne fait que consulter, on n’écrit pas ; une fois trouvée, une marque “récupérable” est apposée. Le commentaire à la ligne 2340 de validation.cpp énonce clairement la seconde règle : « Don’t cache results if we’re actually connecting blocks (still consult the cache, though) ».

Une entrée de cache est une “consommable”. Tout ce que l’attaquant doit assurer, c’est qu’à l’instant où le nœud cible conserve encore la trace « déjà vérifiée », il lui fasse passer une transaction portant la charge utile avec collision.

Chaque nœud a aussi une part de sel aléatoire généré au démarrage, dans la clé de cache. Le sel ne peut rien y faire. Le sel est placé tout au début de tous les champs. Les deux flux eux-mêmes sont identiques : qu’il y ait du sel ou non, c’est pareil, donc la collision se produit de manière indépendante sur chaque nœud.

Les huit minutes on-chain

L’attaque commence à 13:52:10, dans le bloc 4 050 335 : deux transactions de setup ayant la même structure sont ajoutées dans le même bloc. Chaque transaction comporte une sortie OP_RETURN : cette sortie est parfaitement “propre” en elle-même. Les actifs sont L-BTC en clair (6f0279e9…), et l’engagement de montant C0 (09d6c615…) est accompagné d’une rangeproof honnête et valide P0 ; les nœuds l’ont vérifiée selon le processus normal. La charge utile est dissimulée dans son script de verrouillage.


6a43 ‖ C1 ‖ X ‖ 6a

0x43 vaut 67 : cela envoie juste les 67 octets suivants comme données. C1 (086f5d67…) est un engagement de montant fictif préparé pour la sortie d’attaque. X (0a0a488de489…) est une forme “aveuglée” d’engagement d’actif ; en ne lisant que les octets, on ne peut pas déterminer quel type d’actif il représente. Nous avons calculé avec le vrai secp256k1 du dépôt les générateurs “en clair” pour L-BTC : le générateur commence par 0ab963d4, et n’est pas le même point que X. Ces deux valeurs, prises comme données littérales, sont stockées dans le script ; lors de la vérification, elles ne sont traitées que comme des données ordinaires. Une fois la vérification faite, chaque nœud ajoute une entrée dans son cache : la clé est le flux concaténé à nu de ces quatre champs.

Il y a aussi un petit passage de “primer”. Lors de la connexion des blocs, les entrées de cache sont consommées ; l’attaquant utilise alors des transactions portant la même charge utile pour “ensemencer” à répétition le mempool, de sorte que le cache des nœuds soit rechargé. On ne peut pas déduire précisément quelles transactions constituent le primer à partir des seules données de blocs : il est possible qu’elles ne soient même jamais entrées dans un bloc. Deux transactions de setup sont placées dans le même bloc, probablement pour pouvoir reconstituer les entrées immédiatement après leur consommation.

13:53:10, le bloc 4 050 336, la transaction de frappe apparaît. Elle a une sortie principale, et quatre champs sont remplis ainsi. proof est P0 puis une “queue” de 68 octets ; le contenu de la queue est C0, A, 6a43, où A est l’engagement d’actif lui-même de la sortie de setup. commitment est C1. asset_commitment est X. Le scriptPubKey ne tient plus qu’un octet : 6a.

Les quatre champs sont deux à deux différents. On les concatène à nu dans SHA-256 dans cet ordre, et les deux flux sont identiques octet par octet.

Le nœud vérifie cette sortie, calcule la clé, observe le hit, puis renvoie true. secp256k1_rangeproof_verify n’a jamais touché cette preuve. Peu importe la taille exacte du montant derrière C1 : le résultat du Pedersen est compensé par la somme des sorties avec engagement de valeur négative. Et ainsi, environ 3 998,5 L-BTC entrent dans l’ensemble des UTXO.

À 13:54:10 puis à 14:00:10, dans les blocs 4 050 337 et 4 050 343, deux transactions regroupent et dépensent les UTXO correspondants. La requête de peg-out est autorisée normalement par la fédération : un BTC équivalent “réel” est envoyé via le mainnet Bitcoin vers l’adresse de réception bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. Du premier setup jusqu’au moment où les fonds commencent à être regroupés, il s’écoule environ huit minutes.

Remboursement et négociation on-chain

Le lendemain, 3 400 BTC sont transférés en retour vers le portefeuille de peg de la fédération bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. Les 598,5 BTC restants restent sur place ; du 8 au 10 septembre, ils sont dépensés à répétition en auto-transferts entre les blocs 966 052 et 966 199, chaque transaction étant accompagnée d’un OP_RETURN en clair. L’un des messages dit : « You SHALL pay 10% using your own money as bug bounty », et laisse en signature le compte Telegram @MRBOND_1. Le message se présente comme un chasseur de bugs “white hat” et demande à la fédération de payer elle-même une récompense officielle de 10 %. À ce jour, les négociations sont toujours menées publiquement on-chain.

Suivi des fonds par MistTrack

Après avoir clarifié le déroulé de base de l’incident, nous traçons plus loin les flux de fonds de l’attaquant sur Liquid et sur le mainnet Bitcoin, puis nous analysons via MistTrack les adresses clés côté Bitcoin ; nous croisons ensuite ces éléments avec les données on-chain.

Côté Liquid, l’attaquant utilise l’adresse ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w pour regrouper et consommer les actifs concernés. Cette adresse implique 91 transactions et 247 sorties, et elles ont toutes déjà été dépensées. Comme MistTrack ne couvre pas encore la chaîne Liquid, le suivi côté Liquid — de la frappe L-BTC au moment de la consommation des UTXO, puis jusqu’aux flux de peg-out — repose principalement sur des vérifications poste par poste à partir des données d’un explorateur de blocs.

https://blockstream.info/liquid/address/ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w

Côté mainnet Bitcoin, les fonds de peg-out d’environ 3 998,5 BTC finissent par entrer dans l’adresse bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. Le score de risque de MistTrack pour cette adresse est de 100/100, avec une qualification Severe. Les étiquettes incluent Malicious Address / Involved Theft Activity, et l’adresse est connectée à un cran d’entités d’activités illégales de type Theft, avec un degré de relation de 100 %.

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Du point de vue de la source des fonds, l’adresse ne reçoit pas de BTC provenant d’un capital normal correspondant. Sa source remonte à la frappe L-BTC et à l’opération de peg-out décrites précédemment : en pratique, l’attaquant supporte principalement les frais de transaction associés.

Ensuite, ce montant est scindé en deux parties. Parmi celles-ci, 3 400 BTC sont transférés le lendemain vers le portefeuille federé de peg : bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. Lors de la vérification par enquête de transaction via MistTrack, cette adresse est également la plus élevée parmi les adresses de la cible de sortie, correspondant à un montant de 3 400 BTC, ce qui concorde avec les données on-chain.

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Les ~598,5 BTC restants restent contrôlés par l’attaquant. Du 8 septembre au 10, ces fonds ont continué à effectuer des transferts à eux-mêmes entre les blocs Bitcoin 966 052 et 966 199, et ont mené une négociation publique avec la fédération via des messages OP_RETURN. L’un des messages dit : « You SHALL pay 10% using your own money as bug bounty », et fournit un compte Telegram : @MRBOND_1. À l’heure actuelle, les négociations correspondantes restent observables on-chain.

https://www.oklink.com/bitcoin/address/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

D’après les données on-chain disponibles, l’attaquant a reversé 3 400 BTC sur environ 3 998,5 BTC de fonds issus du peg-out ; environ 598,5 BTC restent sous son contrôle, soit ~15 % du montant du peg-out. Par la suite, nous continuerons à surveiller les adresses et l’évolution des fonds concernés.

Conclusion

Ce code est resté dans le dépôt pendant dix ans, et a été exécuté par d’innombrables nœuds, d’innombrables fois. Il a été corrigé une fois le 3 août ; le 6 septembre, il s’est fait attaquer à nouveau. Le premier patch complétait dans le hachage les champs manquants, mais omettait les frontières de champs : cinq semaines plus tard, les frontières ont été exploitées de manière précise. Le 8 septembre, la version Elements 23.3.4, en plus de mettre les préfixes de longueur des clés, ajoute aussi le commutateur d’arrêt d’urgence -norangeproofcache : les opérations peuvent alors ne plus autoriser aucun résultat en cache, en éteignant tout simplement cette table.

Cette leçon peut être résumée très brièvement. Dans la voie de validation cryptographique, le résultat positif mis en cache évite une vérification : il faut traiter sa clé comme le ferait un précepte standard pour les primitives cryptographiques. Chaque champ lu par la fonction de vérification, ainsi que les frontières entre champs, doivent être hachés sans ambiguïté : le préfixe de longueur est précisément fait pour cela. Les optimisations de performance du type mise en cache, mémorisation et parallélisation — tant qu’elles se trouvent sur le chemin de validation — doivent être traitées, lors d’un audit, avec la même rigueur que la primitive cryptographique qu’elles optimisent.

L’équipe de sécurité Mist Team recommande aux parties concernées de réaliser un audit de sécurité externe complet avant le déploiement. Dans la checklist d’audit de la couche de consensus, l’intégrité des clés du cache de validation devrait être listée comme point à vérifier obligatoirement.