Back to Blog

Pós-Mortem do Exploit zkLend: Desvendando os Detalhes e Esclarecendo Mal-entendidos do Ataque de Flash Loan de $10M

February 20, 2025
9 min read

Em 12 de fevereiro de 2025, o zkLend [1], um protocolo de empréstimo na StarkNet, foi explorado em aproximadamente $10M por meio de uma sofisticada manipulação de seu mecanismo de acumulador. O atacante aproveitou flash loans e vulnerabilidades de arredondamento para inflar artificialmente os valores das garantias, tomando emprestado outros ativos do protocolo para obter lucro.

No entanto, ainda há falta de uma análise técnica detalhada e precisa do ponto de vista de segurança. Apesar das análises existentes de outros pesquisadores de segurança, que forneceram insights valiosos, alguns equívocos persistem — particularmente em relação à análise do ataque. A publicação posterior do post-mortem oficial do zkLend [2] oferece uma descrição simplificada, mas carece de uma análise técnica detalhada. Neste blog, nosso objetivo é fornecer um exame abrangente para esclarecer o incidente.

Principais Conclusões (TL;DR)

  • A causa raiz deste incidente decorre da combinação dos três problemas a seguir:

    • A inicialização do mercado vazio permite depósitos arbitrários de ativos.
    • O mecanismo específico de doação no flash loan do zkLend permite a manipulação do acumulador, uma variável global como fator de escala para ajustar dinamicamente os saldos de garantia dos usuários.
    • A perda de precisão ocorre devido ao truncamento. Ao contrário da perda de precisão clássica na divisão, o denominador começa em 1, mas foi inflado para um valor muito grande, causando subestimação durante a queima do token de participação.
  • O atacante não lucrou com o wstETH depositado por outros usuários. Em vez disso, o atacante aproveitou as vulnerabilidades para manipular o saldo de garantia, usando uma pequena quantidade de wstETH como capital inicial para aumentar o saldo de garantia para mais de 7.000 wstETH, permitindo assim o empréstimo de outros ativos do mercado.

Nas seções seguintes, primeiro forneceremos algumas informações cruciais de contexto sobre o zkLend. Em seguida, realizaremos uma análise aprofundada dos problemas e do ataque associado.

0x1 Contexto: Entendendo o Protocolo Principal do zkLend

O zkLend é um projeto de empréstimo na StarkNet que suporta protocolos de empréstimo comuns, como empréstimos com garantia e flash loans. Vamos mergulhar nos detalhes de implementação desses dois protocolos.

0x1.1 Empréstimos com Garantia

Um empréstimo com garantia refere-se ao processo em que os usuários depositam ativos específicos no protocolo como garantia em troca de empréstimos de outros ativos. O valor da garantia é usado para determinar a capacidade de empréstimo. É importante notar que os protocolos de empréstimo normalmente não armazenam o valor do ativo da garantia diretamente; em vez disso, calculam usando a fórmula:

collateral_balance = lending_accumulator * raw_balance

Especificamente, o lending_accumulator é um fator de escala que ajusta dinamicamente o valor da garantia de cada usuário, enquanto o raw_balance representa a participação real que o usuário detém no mercado. O raw_balance é derivado do collateral_balance usando o lending_accumulator.

Qual é o propósito deste design? Ele permite que o protocolo gerencie eficientemente o valor das garantias enquanto incentiva os usuários a depositar ativos. Ao alocar uma parte dos ganhos do protocolo aos provedores de garantia, o lending_accumulator aumenta, amplificando assim o valor das garantias de todos os usuários proporcionalmente e simultaneamente.

0x1.2 Flash Loans no zkLend

Um flash loan é um tipo de empréstimo sem garantia em que os usuários podem tomar emprestado ativos do protocolo por um período muito curto, normalmente dentro de uma única transação. Se o tomador não conseguir reembolsar o empréstimo ou cumprir as condições especificadas, toda a transação é revertida e o empréstimo não é executado.

Na implementação de flash loan do zkLend, há um mecanismo único de doação. Especificamente, quando os usuários reembolsam ativos, eles não apenas devolvem o valor mínimo necessário, mas também podem contribuir com fundos extras como doação. O protocolo rastreia esses fundos doados e atualiza o lending_accumulator de acordo. Este processo é implementado na função thesettle_extra_reserve_balance(). A fórmula para atualizar o lending_accumulator é a seguinte:

new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply

  • reserve_balance: O valor total do token subjacente (ex.: wstETH) mantido no contrato, que inclui a quantidade de tokens doados pelos usuários.
  • totaldebt: A dívida total de todos os usuários tomadores de empréstimo.
  • amount_to_treasury: O valor da receita do protocolo.
  • ztoken_supply: O fornecimento total do token de participação (ex.: zwstETH). Quando os usuários depositam wstETH, o contrato ztoken do zkLend cunha uma quantidade equivalente de zwstETH.

Tendo compreendido o protocolo principal do zkLend, agora explicaremos formalmente como o atacante manipulou seus ativos de garantia ao manipular as variáveis lending_accumulator e raw_balance.

0x2 Análise do Ataque

O atacante explorou os seguintes mecanismos e vulnerabilidades no contrato zkLend para manipular o valor da garantia:

  • Manipulação do lending_accumulator
    • Mercado vazio: Antes do ataque, o mercado zkLend para tokens wstETH estava vazio, fornecendo a condição perfeita para manipulação. Além disso, o contrato do Mercado zkLend permite que qualquer pessoa deposite qualquer quantidade de ativos em um mercado vazio. O atacante depositou uma pequena quantidade de ativos para inflar significativamente o valor do lending_accumulator.
    • Mecanismo de doação: A função flash_loan() do contrato do Mercado zkLend possui um mecanismo único de doação. Especificamente, quando um usuário reembolsa um flash loan, o contrato do Mercado calcula os fundos excedentes devolvidos e aumenta a variável global lending_accumulator, amplificando assim os valores de garantia para todos os usuários no contrato.
  • Manipulação do raw_balance
    • Comportamento de arredondamento: A operação de divisão durante o processo de queima do token de participação usa truncamento, o que leva a uma subestimação da mudança no raw_balance do usuário durante os saques.

Ao manipular ambas as variáveis, o atacante conseguiu aumentar o saldo de garantia para mais de 7.000 wstETH e tomar emprestado outros ativos do mercado para obter lucro.

0x2.1 Manipulando a Variável lending_accumulator

0x2.1.1 Inicialização do Mercado Vazio

Ao examinar o registro de transação do contrato do Mercado antes do ataque, podemos observar que o atacante inicialmente deposita 1 wei de wstETH no contrato do Mercado wstETH. Ao revisar as chamadas internas desta transação, fica evidente que o contrato do Mercado wstETH detinha 0 wstETH, e o fornecimento total de zwstETH também era 0.

Portanto, podemos confirmar que não havia depósitos ou empréstimos anteriores no mercado wstETH do zkLend. Tanto o reserve_balance quanto o ztoken_supply estavam em seus valores iniciais de 0, e o valor inicial do lending_accumulator era 1. Este cenário de mercado vazio criou as condições para o ataque subsequente, permitindo que o atacante ampliasse significativamente o lending_accumulator com uma quantidade mínima de wstETH.

0x2.1.2 Manipulando o lending_accumulator via Flash Loan

Em seguida, nesta transação, o atacante chama a função flash_loan(), tomando emprestado 1 wei de wstETH e reembolsando 1000 wei de wstETH. O excesso de 999 wei é tratado como uma doação e registrado no reserve_balance do contrato.

De acordo com a fórmula para calcular o lending_accumulator, esta transação faz com que o lending_accumulator aumente de 1 para 851,0.

0x2.1.3 Execução Repetida de flash_loan()

O atacante executa um total de 10 chamadas flash_loan(), cada vez tomando emprestado apenas 1 wei de wstETH, mas reembolsando uma quantia maior. Como resultado, o lending_accumulator escala para um valor astronômico de 4.069.297.906.051.644.020 (4,069 × 10^18), o que coincidentemente se alinha com a precisão decimal do wstETH.

0x2.2 Manipulando a Variável raw_balance

Após manipular o lending_accumulator para aproximadamente 4,069 × 10^18, o atacante chamou a função deposit() do contrato do Mercado com 4,069297906051644020 wstETH. Com base no valor mais recente do lending_accumulator, o raw_balance do contrato de ataque tornou-se 2.

0x2.2.1 A Primeira Transação Manipulando o raw_balance

Nesta transação, o atacante chamou a função callflashloandraaan() do contrato de ataque. Embora este contrato não seja de código aberto, com base no rastreamento de chamadas internas, pode-se especular que a lógica desta função inclui um loop que realiza as seguintes ações:

  • Depositar: O atacante deposita uma certa quantidade de wstETH no contrato do mercado.
  • Sacar: O atacante saca a quantidade específica de wstETH.

Análise do Registro de Transferência de Tokens

Pode-se observar que a quantidade de wstETH que o atacante deposita é sempre um múltiplo inteiro do lending_accumulator, por exemplo, 2 vezes o valor (ex.: 8,13859) do lending_accumulator.

No entanto, a quantidade de wstETH sacada é 1,5 vezes o valor (ex.: 6,10394) do lending_accumulator.

Por meio de cálculos, podemos determinar que a quantidade de wstETH sacada excede a quantidade depositada. Por que isso acontece?

Comportamento de Arredondamento

Ao revisar a implementação dos métodos deposit() e withdraw(), podemos ver que esses dois métodos envolvem a cunhagem e a queima de zwstETH, respectivamente. Veja como isso funciona:

Função `mint()` no contrato do Mercado

Função `burn()` no contrato do Mercado

Os processos mint() e burn() incluem uma lógica de redução de escala. A lógica de redução de escala envolve divisão inteira com arredondamento para baixo (truncamento para o inteiro mais próximo), o que desempenha um papel fundamental no exploit.

Quando o atacante queima uma certa quantidade de zwstETH, a lógica de redução de escala é aplicada. Devido ao valor manipulado do lending_accumulator ser excepcionalmente alto (em torno de 4.069.297.906.051.644.020), esta divisão faz com que o raw_balance do atacante diminua em apenas 1 unidade, apesar de queimar mais de 6 zwstETH.

As mudanças no raw_balance do atacante são resumidas na seguinte tabela:

Podemos observar que, nesta transação, o atacante executa repetidamente a lógica Depositar - Sacar, explorando a perda de precisão durante a função withdraw(), o que resulta em uma subestimação da diferença de raw_balance. Em última análise, o raw_balance do usuário aumentou de 2 para 3, ganhando uma unidade adicional.

0x2.2.2 Processo de Ataque Subsequente

As transações de ataque subsequentes seguiram o mesmo padrão do primeiro ataque: o atacante cicla repetidamente por transações de Depositar - Sacar para adquirir wstETH.

O wstETH adquirido é re-depositado de volta no mercado, aumentando ainda mais o raw_balance, fazendo com que o valor da garantia do atacante continue subindo.

Explicação com exemplo

Usamos a seguinte transação como ilustração.

  • Um total de 30 depósitos foram feitos, com 4,069 wstETH depositado a cada vez.
  • Um total de 30 saques foram feitos, com 6,104 wstETH sacado a cada vez.
  • Após este ciclo, o atacante extraiu com sucesso 61,39 wstETH, de acordo com os cálculos.

Além disso, vale notar que entre essas transações de ataque, vários métodos increase() foram chamados. Esses métodos foram usados para transferir uma quantidade específica de wstETH da conta do atacante para o contrato de ataque, que então forneceu os fundos para depósitos subsequentes no contrato do Mercado.

Essas ações aumentam o valor do raw_balance, permitindo que o atacante continue aumentando o valor da garantia. Eventualmente, o raw_balance do atacante atingiu 1.724, com um valor de 7.015,4 wstETH, o que foi suficiente para tomar emprestado outros ativos do mercado.

0x3 Análise de Lucro

0x3.1 Tomar Emprestado Outros Tipos de Fundos

Após manipular o valor das garantias, o atacante tomou emprestado outros tipos de fundos do mercado e procedeu com as seguintes transações (trecho):

0x3.2 Transferir os Fundos Emprestados para a Camada 1

Ao inspecionar as transações de bridge do contrato do atacante, pode-se observar que o atacante transferiu parte dos fundos emprestados para a Camada 1.

0x4 Conclusão

Em resumo, este ataque ao protocolo zkLend destaca várias implicações importantes para o design e a segurança dos protocolos de empréstimo descentralizados:

  • Inicialização do Mercado e Condições de Depósito de Ativos: O mercado vazio no início permitiu que o atacante depositasse uma pequena quantidade de wstETH e manipulasse o lending_accumulator, ganhando alavancagem para o exploit. Garantir uma base de liquidez suficiente ou limitar as doações de ativos nos estágios iniciais do mercado poderia ajudar a prevenir ataques semelhantes.
  • Importância de Mecanismos de Acumulador Adequados: O atacante explorou o mecanismo de doação na função flash_loan() para manipular o lending_accumulator, inflando os valores de garantia para todos os usuários. Protocolos com mecanismos baseados em acumulador devem se proteger contra fácil manipulação de fatores de escala.
  • Comportamento de Arredondamento e Perda de Precisão: Um problema de arredondamento durante as queimas do token zwstETH levou à perda de precisão e subestimação do raw_balance, permitindo que o atacante manipulasse o raw_balance. Os protocolos devem usar maior precisão ou verificações de validação para prevenir tais exploits.

Mais uma vez, este incidente sublinha a importância de notificações oportunas sobre o status de inicialização e operação, bem como a prevenção proativa de ameaças para mitigar possíveis perdas.

Referências

[1] https://zklend.com/

[2] Post-mortem do incidente de segurança do zkLend: https://drive.google.com/file/d/10i1dh_J89tPPw7KRcmFIVM6iNrJZAyfi/view