Post-mortems · 6 min · démo interactive
Nomad : le bridge que tout le monde a pillé
Une valeur initialisée à zéro a fait de chaque message inconnu un message prouvé. Une fois la première transaction visible, il suffisait de la copier.
L’idée en une phrase
Après une mise à jour, le bridge Nomad a traité la valeur « zéro » comme une preuve valide, et comme un message inconnu vaut justement zéro, n'importe quel message faux passait.
Ce n'est pas un vol de clé, ni une attaque subtile. C'est une erreur d'initialisation qu'on peut résumer en une ligne, dans un contrat qui gardait des centaines de millions. Le plus frappant vient après : il n'y a pas eu un voleur, mais une foule.
La démo
Sans JavaScript, suis ce raisonnement. Le contrat lit messages[hash] pour trouver la racine du message. Pour un message jamais vu, cette lecture renvoie 0x00. Puis il regarde si confirmAt[0x00] est non nul. À cause de l'initialisation, il vaut 1. Conclusion du contrat : message prouvé. Le pool de 1 000 unités est fictif, et les empreintes sont calculées en SHA-256 là où le vrai contrat utilise Keccak-256.
Pourquoi ça compte
Nomad était un bridge entre chaînes. Du côté Ethereum, un contrat gardait les fonds verrouillés. Pour libérer ces fonds, un message devait être prouvé par rapport à une racine d'arbre de Merkle jugée de confiance, puis traité par un contrat appelé Replica.
Le 1er août 2022, une première transaction retire 100 WBTC, environ 2,3 millions de dollars, sans aucune mécanique sophistiquée. En quelques heures, d'autres font de même. Les analyses concordent sur environ 190 millions de dollars drainés, et Nomad a compté plus de 300 adresses participantes. Une partie de ces adresses a ensuite restitué des fonds, plusieurs dizaines de millions de dollars au total, en se présentant comme des hackers éthiques.
Le rapport de Nomad situe l'origine dans une mise à jour du 21 juin 2022, après un audit. Le bug est donc resté en production plusieurs semaines, sans que personne ne l'exploite.
Le code
Voici la logique du contrat réduite à deux tables. Passe RACINE_INITIALE à une autre valeur que '0x00', ou remplace '0xCOPIEUR', puis relance. Le même message rejoué à l'identique est refusé, et c'est pour cela qu'il fallait changer l'adresse.
Clique sur Lancer pour exécuter ce code dans ton navigateur.
Ce qui se passe vraiment
Clique sur Envoyer process(). Aucune preuve n'a été soumise, et pourtant le message passe. Le journal indique « racine 0x00 ». Clique une seconde fois sans rien changer : refus, « déjà traité ». Le contrat retient bien les messages exécutés. Le rejeu pur est bloqué.
Clique alors sur Copier avec une autre adresse. Une seule donnée change, le destinataire, donc le hash aussi. Pour le contrat, c'est un message jamais vu, et l'on retombe sur le zéro par défaut. C'est tout l'effet de foule : Mandiant et Coinbase décrivent des exploitants qui reprenaient des transactions existantes en remplaçant l'adresse de réception. Pas besoin de comprendre le contrat pour voler, seulement de savoir copier.
Lance la foule : le pool se vide. Décoche ensuite la mise à jour fautive. Tous les messages sans preuve sont refusés, car 0x00 n'a plus de confirmation. Le bouton Message légitime prouvé passe malgré tout : le correctif ne bloque rien d'honnête.
La racine du mal tient à la rencontre de deux conventions, chacune défendable seule. En Solidity, lire une clé absente d'une table renvoie zéro. Et dans le code d'initialisation, confirmAt[committedRoot] = 1 approuve la racine de départ. Or un arbre vide a pour racine une suite de zéros, et c'est cette valeur qui a été passée lors de la mise à jour. La valeur qui signifiait « aucune preuve » est devenue « racine de confiance ».
La démo simplifie : pas de délai d'optimisme, pas d'agents hors chaîne, et un seul domaine. Elle ne montre que la logique de ces deux tables, qui suffit à reproduire l'accident.
Dans la vraie vie
Le rapport de Nomad précise que la mise à jour avait été auditée par Quantstamp et qu'un programme de primes était ouvert. Le bug a pourtant atteint la production. Ce n'est pas une accusation contre l'un ou l'autre : c'est un rappel qu'un audit porte sur un périmètre et une version, et que les paramètres passés au déploiement comptent autant que le code.
Le correctif général consiste à ne jamais donner de sens à la valeur par défaut. Une racine zéro doit être rejetée explicitement, et un test doit couvrir ce cas. Une vérification de valeur à l'entrée de initialize aurait suffi.
Nomad a ensuite préparé une reprise avec un remboursement partiel, fondé sur les fonds récupérés, pour les utilisateurs identifiés. Une chaîne ne se réécrit pas : une transaction validée reste valide, même absurde. C'est pourquoi le seul vrai remède était d'éviter l'erreur en amont, ou de pouvoir suspendre le contrat à temps. Le cas Ronin montre un bridge tombé par un autre chemin, les clés et non le code. Pour le fonctionnement même d'un contrat, repars du smart contract de base.
Ce qui peut mal tourner
- Confondre « absent » et « nul ». En Solidity, les deux se lisent pareil. Si zéro a un sens métier, il faut un drapeau séparé ou un rejet explicite.
- Auditer le code sans le déploiement. Les paramètres d'initialisation et de mise à jour font partie du système, et une transaction de déploiement mérite la même revue.
- Mettre à jour en production sans test de l'état réel. Une batterie de tests sur un état neuf peut rater ce que change une initialisation sur le contrat déployé.
- Tout garder dans un seul pool. Un bridge concentre la valeur. Sans plafond de retrait par période, une faille vide tout d'un coup.
- Ne pas prévoir l'arrêt d'urgence. Une pause du contrat, sous contrôle, limite les dégâts. Elle crée en échange un pouvoir qu'il faut gouverner.
- Penser qu'un vol public est permis. Reproduire un exploit visible reste une appropriation de fonds d'autrui, avec des conséquences légales possibles, même si « tout le monde le fait ».
Teste-toi
Contenu éducatif. Pas un conseil en investissement. Les crypto-actifs comportent un risque de perte en capital. Pour la démarche éditoriale, voir à propos. Mise à jour : 31 octobre 2026.