Autor: Johan & Lisa

Editor: 77

Em 6 de setembro, às 13:52:10 UTC, duas transações com a mesma estrutura apareceram no bloco de número 4.050.335 do Liquid. Um minuto depois, o bloco 4.050.336 confirmou a terceira transação: cerca de 3.998,5 L-BTC entraram no conjunto UTXO, sem que nenhuma operação peg-in estivesse por trás. Alguns minutos depois, esses ativos que não existiam começaram a ser agrupados e consumidos; em seguida, foram trocados por BTC verdadeiro na rede principal por meio do canal de peg-out federado.

No dia seguinte, 3.400 BTC foram devolvidos à carteira de peg federada. Os 598,5 BTC restantes ficaram com o atacante e, até o momento da publicação, continuam sendo transferidos repetidamente de forma “auto” (self-transfer), com cada transação acompanhada por um trecho de texto em claro OP_RETURN, exigindo da federação uma “recompensa” de 10%. As negociações ainda estão sendo conduzidas publicamente na cadeia.

Fizemos uma checagem ponto a ponto dos blocos antes e depois de 4.050.335; do lado Bitcoin, rastreamos o fluxo de fundos em cada salto usando o MistTrack. Este artigo fala de duas coisas: o que o atacante fez tecnicamente e uma otimização de desempenho que já entrou no codebase em 2016 — por que, dez anos depois, foi possível resgatar quatro mil bitcoins.

Visão geral do ataque

Como o dinheiro do Liquid é administrado

A emissão de L-BTC tem apenas um caminho correto. Você entrega BTC à carteira multisig da federação; no lado Liquid, é cunhado L-BTC na mesma quantidade; depois, no sentido inverso, o peg-out devolve BTC. Essa ancoragem 1:1 é guardada pelo mecanismo de transações confidenciais. O valor de cada saída não é escrito diretamente na blockchain; ele é escondido dentro de uma promessa (Pedersen commitment). Cada compromisso precisa acompanhar uma rangeproof, que prova que o valor é não-negativo e que não ultrapassa limites. Ao validar a transação, o nó soma separadamente os pontos de compromisso das entradas e das saídas; só quando os dois lados batem, a conta fecha.

Contanto que uma saída consiga entrar com uma rangeproof falsa, é possível esconder um número negativo no compromisso, ou um número astronômico, e criar moeda do nada matematicamente — então a rangeproof deve ser verificada de verdade toda vez.

O problema é que isso é caro. Uma rangeproof normalmente tem mais de quatro mil bytes; a verificação envolve operações reais de curva elíptica, e cada saída precisa passar por isso. Em 12 de julho de 2016, uma submissão intitulada Rangeproof caching (66ab07bd1b) entrou no repositório do Elements, seguindo a ideia de cache de assinaturas do Bitcoin. Provas já verificadas não são mais re-verificadas; o resultado “verificação bem-sucedida” é armazenado em uma tabela de cache, e na próxima vez com as mesmas entradas a transação é liberada diretamente.

Esse tipo de otimização é muito comum em código criptográfico. Desta vez, todo o problema também está nisso.

O que está faltando na chave de cache

Primeiro, vamos olhar esse trecho de código na versão 23.3.3 publicada.

A chave de cache é o proof e o commitment concatenados em bruto na mesma corrente de SHA-256. A proof é um campo de tamanho variável, e a versão longa tem mais de quatro mil bytes. Essa chave não contém nenhum dado de ativo, nem script, nem qualquer informação de fronteira de campos.

O envio de correção de 3 de agosto deste ano (c26d719) preencheu o asset commitment e o script de bloqueio, tornando-os quatro campos. A direção está correta: realmente era possível reutilizar a mesma prova, mas combinando-a com ativos e scripts diferentes repetidamente. O patch ainda concatena em bruto os quatro campos na mesma corrente. A proof e o scriptPubKey são campos de tamanho variável; as fronteiras no meio podem ser movidas de um lugar para outro.

No reforço enviado em 8 de setembro (94000967f6), a serialização com hash passou a usar prefixos de comprimento. A mensagem de envio dizia algo bem direto.

Isto serializa cada campo com um prefixo de comprimento, então tuplas de argumentos distintas com concatenações brutas idênticas em bytes já não colidem para a mesma chave de cache. Uma entrada de cache é um resultado positivo de verificação; uma colisão permitiria ao atacante burlar a verificação.

No cache, o que fica armazenado é “verificação aprovada”. Se a chave colidir, então equivale a permitir que algo nunca verificado passe no lugar de outra coisa. Na mesma submissão também foi encontrado que a chave de cache do surjection proof nem sequer amarrava a etiqueta do ativo de entrada — e isso também foi corrigido.

Ao acertar, dispensa validação

A liberação ocorre no início de CachingRangeProofChecker::VerifyRangeProof.

Ao acertar, retorna diretamente true. As verificações posteriores, como secp256k1_rangeproof_verify e a checagem de min_value, nem chegam a ser executadas.

A escrita e a limpeza do cache têm regras próprias; isso é a chave do timing do ataque. Quando uma transação entra no mempool, ela é validada em modo que permite escrita: o resultado verificado é armazenado no cache para ser reutilizado na próxima vez que entrar em um bloco. Na conexão de blocos, é o contrário: consulta-se, mas não se escreve; quando encontra uma vez, marca como “recuperável”. O comentário na linha 2340 do validation.cpp deixa bem claro a segunda regra; o texto original é: Don't cache results if we're actually connecting blocks (still consult the cache, though).

Entradas de cache são consumíveis. O atacante precisa garantir apenas uma coisa: aproveitar a janela em que o nó-alvo ainda mantém o registro “já verificado” e encaminhar a transação carregando o payload com colisão.

Cada nó também carrega uma parte de sal aleatório gerado durante a inicialização na chave de cache. O sal não resolve isso. O sal é colocado no início de todos os campos; as duas correntes em si são idênticas; com ou sem sal, elas continuam idênticas. Portanto, a colisão se estabelece independentemente em cada nó.

Oito minutos na cadeia

Do lado da rede Bitcoin, o ataque começou em 13:52:10, no bloco 4.050.335; duas transações de setup com a mesma estrutura foram inseridas no mesmo bloco. Cada uma traz uma saída OP_RETURN; essa saída em si é completamente limpa. O ativo é L-BTC em texto claro (6f0279e9…); o compromisso de valor C0 (09d6c615…) vem com uma rangeproof honesta e válida P0, e o nó a verificou pelo fluxo normal. O payload está escondido no script de bloqueio dela.


6a43 ‖ C1 ‖ X ‖ 6a

0x43 é 67; isso faz com que os 67 bytes seguintes sejam empurrados como dados. C1 (086f5d67…) é o compromisso falso do valor da saída de ataque. X (0a0a488de489…) é uma forma ofuscada de compromisso de ativos; lendo apenas os bytes, não dá para descobrir qual ativo corresponde. Calculamos no repositório, usando secp256k1, as geradoras de texto claro para L-BTC; elas começam com 0ab963d4 e não são o mesmo ponto que X. Esses dois valores ficam como dados literais dentro do script; na verificação, eles são tratados apenas como dados comuns. Depois de verificado, cada nó adiciona um novo registro ao cache: a chave é a corrente de concatenação em bruto desses quatro campos.

No meio, há ainda uma parte de trabalho de primer. A conexão de blocos consome as entradas do cache; então o atacante reutiliza transações com o mesmo payload e as semeia repetidamente no mempool para recarregar os caches em cada nó. Não dá para deduzir quais são exatamente as transações de primer a partir dos dados dos blocos; elas talvez nem tenham entrado no bloco. As duas transações de setup ficaram apertadas no mesmo bloco, provavelmente também para, depois que as entradas fossem consumidas, conseguir preencher imediatamente mais uma rodada.

13:53:10, bloco 4.050.336, aparece a transação de cunhagem. Ela tem uma saída principal com quatro campos preenchidos assim. A proof vai como P0 e depois segue um rabo de 68 bytes; o conteúdo do rabo é C0, A, 6a43, sendo A o compromisso de ativos da própria saída de setup. O commitment vai como C1. O asset_commitment vai como X. O scriptPubKey fica com apenas um byte, 6a.

Os quatro campos são diferentes entre si; eles são concatenados em bruto na ordem em SHA-256 byte a byte. As duas correntes são idênticas em cada byte.

Quando o nó verifica essa saída, calcula a chave, acerta, e retorna true. A secp256k1_rangeproof_verify nunca chegou a tocar essa prova. O tamanho do valor por trás de C1 pode ser o que for; depois, é compensado pela soma Pedersen das saídas de compromisso de valor negativo. Assim, cerca de 3.998,5 L-BTC entraram direto no conjunto UTXO.

Em 13:54:10 e 14:00:10, nos blocos 4.050.337 e 4.050.343, duas transações consolidam e gastam os UTXOs relacionados. A solicitação de peg-out foi autorizada pela federação como de costume; os BTC equivalentes verdadeiros foram pagos pela mainnet do Bitcoin, com o endereço de recebimento bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. Do primeiro setup ser incluído no bloco até o dinheiro começar a ser consolidado, foi um total de oito minutos.

Devolução e negociações on-chain

No dia seguinte, 3.400 BTC foram transferidos de volta para a carteira peg da federação bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. Os 598,5 BTC restantes ficaram no lugar; entre 8 e 10 de setembro, fizeram repetidos gastos próprios entre os blocos 966.052 e 966.199, com uma OP_RETURN em cada transação. Em uma das frases, está escrito “You SHALL pay 10% using your own money as bug bounty”; e como assinatura, deixou a conta do Telegram @MRBOND_1. A mensagem se apresenta como “white hat”, exigindo que a federação pague do próprio bolso um prêmio oficial de 10%. As negociações ainda acontecem de forma pública on-chain até hoje.

Rastreamento de fundos do MistTrack

Depois de esclarecer o fluxo básico dos eventos, rastreamos ainda mais a movimentação de fundos do atacante nas redes Liquid e Bitcoin. Usamos o MistTrack para analisar endereços-chave no lado Bitcoin e cruzamos essa análise com os dados on-chain.

No lado Liquid, o atacante usa o endereço ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w para consolidar e consumir os ativos relacionados. Esse endereço envolve 91 transações e 247 saídas; até o momento, todas já foram gastas. Como o MistTrack ainda não cobre a blockchain Liquid, no lado Liquid o fluxo de fundos vai principalmente da cunhagem de L-BTC, consumo de UTXO até o peg-out subsequente, com validação ponto a ponto usando dados de explorador de blocos.

https://blockstream.info/liquid/address/ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w

No lado da mainnet Bitcoin, os fundos do peg-out de aproximadamente 3.998,5 BTC acabam entrando no endereço bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. A pontuação de risco do MistTrack para esse endereço é 100/100, com classificação Severe; as tags incluem Malicious Address / Involved Theft Activity. E isso se conecta em um salto direto às entidades de atividades ilegais do tipo Theft, com grau de relação de 100%.

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Quanto à origem dos fundos, os BTC recebidos por esse endereço não têm uma origem correspondente de capital normal; sua origem pode ser rastreada até a cunhagem do L-BTC citada anteriormente e o peg-out subsequente. Na prática, o atacante assume principalmente as taxas de transação relacionadas.

Acertou, dispensa validação

https://light.misttrack.io/address/BTC/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Os 598,5 BTC restantes continuam sob controle do atacante. Entre 8 de setembro e 10 de setembro, essa parcela de fundos ficou fazendo transferências de auto-gasto contínuas entre os blocos do Bitcoin 966.052 e 966.199 e, por meio de mensagens OP_RETURN, conduziu negociações públicas com a federação. Uma das mensagens dizia: “You SHALL pay 10% using your own money as bug bounty” e incluía a conta do Telegram @MRBOND_1. Até agora, as negociações ainda podem ser observadas on-chain.

https://www.oklink.com/bitcoin/address/bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte

Com base nos dados on-chain atuais, o atacante devolveu 3.400 BTC dos aproximadamente 3.998,5 BTC de recursos do peg-out; cerca de 598,5 BTC restantes ainda estão sob o controle dele, ou seja, aproximadamente 15% do valor do peg-out. Em seguida, continuaremos monitorando os endereços e as mudanças de fundos relacionados.

Conclusão

Esse trecho de código ficou no repositório por dez anos, rodado por incontáveis nós, incontáveis vezes. Foi corrigido uma vez em 3 de agosto, mas em 6 de setembro voltou a ser explorado. No primeiro patch, os campos faltantes foram adicionados ao hash, mas a fronteira dos campos foi omitida; cinco semanas depois, a fronteira foi explorada com precisão. O Elements 23.3.4 publicado em 8 de setembro, além de adicionar prefixos de tamanho às chaves, ainda incluiu um interruptor de emergência -norangeproofcache; a equipe de operação pode desligar a tabela inteira sem liberar qualquer resultado de cache.

O ensinamento desta vez pode ser resumido bem rápido. Cache do resultado positivo no caminho de verificação criptográfica: ao haver acerto, fica dispensada a validação; sua chave deve ser tratada conforme o padrão dos primitivos criptográficos. Cada campo que a função de validação lê e cada fronteira de cada campo precisam ser hashadas de modo inequívoco; o prefixo de tamanho é justamente para isso. Otimizações de desempenho como cache, memoização e paralelização: enquanto estiverem no caminho de validação, na auditoria devem receber o mesmo tratamento que os primitivos criptográficos que foram otimizados.

A equipe de segurança do Mist recomenda que as partes do projeto façam uma auditoria de segurança externa completa antes da implantação. Na lista de auditoria da camada de consenso, a integridade das chaves do cache de validação deve ser listada como item obrigatório.