O ataque de reentrância (reentrancy attack) tem uma longa história na Ethereum e é a classe de vulnerabilidade responsável pelo “The DAO Hack”, um dos maiores ataques na rede Ethereum em 2016. Desde então, muitos padrões foram introduzidos para mitigar essa classe de vulnerabilidade, como limitar o gás encaminhado por uma guarda externa (external guard), guardas de reentrância (reentrancy guards) e seguir os padrões de CEI (Checks-Effects-Interactions, ou Verificações-Efeitos-Interações).
Muitos agora até questionam a relevância da reentrância como uma vulnerabilidade importante, dado o conhecimento generalizado do vetor de ataque e seus padrões. No entanto, se dermos uma olhada nos ataques e eventos recentes, veremos uma história diferente.
Passou algum tempo desde que Paweł Kuryłowicz escreveu seu artigo sobre se os ataques de reentrância ainda são um problema em Solidity. Em 2021, Pawel perguntou:
“Certo, mas esse ataque de reentrância é um problema significativo?”
E a resposta foi:
“Sim, é um problema significativo.”
Todo esse tempo depois, os ataques de reentrância ainda são um problema significativo?
A resposta é sim e provavelmente continuará sendo sim no futuro previsível.
Como a reentrância afetou o ecossistema?
Desde que Pawel postou seu artigo, centenas de milhões de dólares foram perdidos para ataques de reentrância, mais notavelmente no incidente com os Pools Rari Fuse do Fei Protocol, que teve uma perda reportada de mais de 80 milhões de dólares. Embora a reentrância seja o bug de contrato inteligente mais comumente referenciado e seja a primeira introdução de muitos pesquisadores de segurança à segurança de contrato inteligente, muitos projetos ainda são vítimas de ataques que, em sua essência, são resultado da reentrância. Você pode dar uma olhada nos projetos afetados no repositório de pcaversaccio, “A Historical Collection of Reentrancy Attacks”.
O que é reentrância?
A reentrância é um problema de sincronização de estado. Quando uma chamada externa é feita para outro contrato inteligente, o controle do fluxo de execução é transferido. O contrato de chamada tem que garantir que todo o estado compartilhado globalmente esteja totalmente sincronizado antes de transferir o controle. Como a EVM é uma máquina de encadeamento de execução (thread) único, se uma função não sincroniza totalmente o estado antes de transferir o controle de execução, essa função pode ser chamada novamente com o mesmo estado como se estivesse sendo chamada pela primeira vez. Isso pode fazer com que a função execute repetidamente ações que deveriam ser executadas apenas uma vez.
Fig 1: Exemplo de fluxo de execução de alto nível durante um ataque de reentrância
Se fizermos uma simples modificação no contrato WETH, que encapsula o ativo nativo ETH (Ether) em um token compatível com ERC20, podemos ter uma melhor compreensão de um antipadrão que pode levar à reentrância. A função de depósito recebe ETH e aumenta o saldo dos usuários armazenado no mapeamento balanceOf
.
Quando um usuário deseja converter seu WETH de volta para ETH, ele chama a função de saque, withdraw
. Quando a função withdraw
usa uma chamada de baixo nível para transferir o ETH para o usuário, o fluxo de execução é transferido para o destinatário. Neste exemplo, a chamada externa está sendo feita antes que o saldo seja atualizado. Se o chamador for uma EOA (Externally Owned Account, ou Conta de Propriedade Externa), a transferência será concluída com sucesso e a execução continuará dentro da função withdraw
. No entanto, se o chamador fosse um contrato inteligente, a função payable
padrão seria invocada, que pode ser controlada para fazer qualquer coisa que quisermos.
Durante nossa execução da função payable padrão, o contrato WETH ainda não sabe que já enviou o ETH, pois o mapeamento balanceOf
ainda não foi alterado! Se invocarmos novamente a função withdraw
, a instrução require
, que verifica se temos saldo suficiente de WETH para retirar ETH, será aprovada. Acabamos de invadir o contrato WETH e de obter uma quantidade infinita de ETH.
Uma vez que todas as chamadas reentrantes se resolvam, o mapeamento de
balanceOf
ainda é diminuído de acordo com o número de vezes que a função foi chamada. Nas versões do Solidity >= 0.8.0, isso causará a reversão total da função por causa das verificações de underflow/overflow que ocorrem por padrão. No entanto, qualquer versão do Solidity abaixo disso resultará em underflow do saldo e o invasor, no final, acabará com um saldo bem grande.
O que você pode fazer para prevenir a reentrância?
Os primeiros casos de reentrância ocorreram em transferências de ETH, uma vez que a execução do código é transferida para a função fallback
do destinatário durante uma transferência do ativo nativo. As funções send
e transfer
foram introduzidas para tipos de endereço para transferir ETH, mas limitam a quantidade de gás encaminhada para o destinatário para restringir a lógica que pode ser executada. Isso mitigou potenciais riscos de perda de gás, bem como impediu a reentrância, uma vez que a chamada interna ficaria sem gás antes de poder realizar a lógica necessária. Existem desvantagens para essa solução, no entanto. O uso de transfer
ou send
quebrará a composabilidade com contratos inteligentes que podem ter alguma lógica necessária ocorrendo na função fallback
, como proxies, que delegam sua lógica a um contrato de implementação.
O uso de send
e transfer
foi desaconselhado, devido a possíveis mudanças nos custos de gás dos opcodes que podem quebrar contratos existentes que dependem da quantidade limitada de gás passada durante essas chamadas. A ConsenSys detalha mais sobre esse problema em seu artigo "Stop Using Solidity’s transfer() Now", mas, como os custos de gás estão sujeitos a mudanças e existem maneiras mais eficazes de mitigar os riscos de reentrância, send
e transfer
não devem ser usados se as práticas recomendadas forem seguidas.
A maneira mais recomendada e simples de prevenir a reentrância é implementar um padrão CEI. Aquelas funções que executam chamadas externas devem garantir que todas as interações externas ocorram após quaisquer verificações ou mudanças de estado. Isso também é comumente conhecido como o padrão de chamada final na programação concorrente tradicional. Se fôssemos corrigir o exemplo anterior na função withdraw
do WETH, primeiro teríamos a instrução require
, que verifica se o usuário tem saldo suficiente de WETH (verificação), para depois fazer nossas alterações no armazenamento que atualiza o saldo dos usuários (efeito), e finalmente fazer a chamada externa para o usuário para transferir os fundos (interações).
Finalmente, se houver riscos desconhecidos que possam ser introduzidos por meio da operação não permissionada de um protocolo, uma reentrancyGuard
, ou guarda de reentrância, pode ser usada como uma maneira de garantir que não haja maneira de chamar a função mais de uma vez no mesmo quadro de chamada. O OpenZeppelin fornece uma biblioteca para implementar ReentrancyGuards
. No entanto, o custo extra de gás para realizar um SLOAD
e SSTORE
para verificar se a função já foi chamada aumentará os custos de gás e pode não ser necessário se seguir os padrões recomendados. Além disso, este tipo de guarda de reentrância não protegerá contra a reentrância entre contratos.
A EIP-1153 visa reduzir este custo introduzindo novos opcodes para dados que são descartados após cada transação.
O que pode desencadear a reentrância?
Qualquer chamada externa pode levar à reentrância se os padrões CEI adequados não estiverem sendo seguidos. O Slither é um framework de análise estática de código aberto que pode ajudar auditores e caçadores de bugs a encontrar possíveis pontos de entrada para vulnerabilidades de reentrância. No entanto, os seguintes padrões são alguns exemplos de maneiras como o fluxo de execução pode ser transferido para um contrato arbitrário:
- Chamadas de baixo nível (
.call()
); -
transfer
de tokens ERC223; -
transferAndCall
de tokens ERC667; -
transfer
de tokens ERC777; - Funções de transferência segura (
safe
) de tokens ERC1155; - Funções
AndCall
de tokens ERC1363; - Funções seguras (
safe
), comosafeTransfer
ousafeMint
, de tokens ERC721; -
transfer
de certos tokens ERC20 que podem ter implementado uma função de retorno de chamada (callback) personalizada para destinatários.
Quais são os diferentes tipos de reentrância?
Reentrância de Função Única
Este é o tipo mais simples de reentrância que levou ao “The DAO Hack”, um ataque de 60 milhões de dólares, e ao hard fork (bifurcação drástica) da rede Ethereum, resultando na criação de blockchains separadas, a “Ethereum Classic” inalterada, e a rede Ethereum com histórico alterado que conhecemos hoje.
A reentrância de função única ocorre quando um contrato faz uma chamada externa antes de finalizar as alterações de estado, e a mesma função é chamada novamente dentro da chamada externa.
Reentrância Entre Funções
Um invasor também pode ser capaz de realizar um ataque semelhante usando duas funções diferentes que compartilham o mesmo estado. Se a primeira função faz uma chamada externa antes que os dados compartilhados sejam atualizados, um invasor pode entrar na segunda função com o estado inalterado.
A guarda de reentrância do OpenZeppelin pode prevenir esse problema se ambas as funções tiverem uma guarda não reentrante (nonReentrant
), já que compartilham o mesmo valor de armazenamento como o valor que é verificado para indicar se a função já foi chamada. Isso também impede que funções com o modificador nonReentrant
sejam chamadas dentro do mesmo quadro de chamada.
Reentrância entre Contratos
A reentrância não se limita a chamadas para funções dentro do mesmo contrato. Vários contratos que compartilham o mesmo estado também podem ser suscetíveis à reentrância. Novamente, o padrão CEI teria prevenido quaisquer riscos de reentrância. No entanto, se o estado compartilhado não for atualizado antes da chamada externa, a reentrância pode causar uma vulnerabilidade crítica. Você pode ler mais sobre reentrância entre contratos neste exemplo de Phuwanai Thummavet.
Reentrância Somente Leitura
Geralmente, auditores e caçadores de bugs estão preocupados apenas com pontos de entrada que modificam o estado ao procurar por reentrância. No entanto, a reentrância somente leitura pode ocorrer quando um protocolo depende da leitura do estado de outro. O get_virtual_price
do Curve, notadamente, foi suscetível a esse tipo de ataque ao reentrar na função de visualização get_virtual_price
no meio da remoção de liquidez. Em muitos casos, isso afetará protocolos que dependem de um mecanismo de precificação de outro, então os projetos devem ter muito cuidado ao integrar oráculos de preços de exchanges ou outros protocolos de gerenciamento de liquidez. Leia mais sobre reentrância somente leitura na prática no Curve LP Oracle Manipulation: Post Mortem da Chain Security. Além disso, você pode encontrar exemplos de reentrância somente leitura no repositório de vulnerabilidades comuns de contratos inteligentes do DeFiVulnLabs, por SunWeb3Sec.
Reentrância Entre Cadeias
A reentrância entre cadeias é o mais novo tipo de ataque de reentrância que só recentemente começou a se tornar uma preocupação com o surgimento de protocolos de mensagens entre cadeias. Na prática, não há precedente para ataques de reentrância entre cadeias. No entanto, com o aumento da interoperabilidade entre cadeias e a visão unificada de um futuro multicadeia, esse paradigma deve ser entendido e revisado por quaisquer protocolos que façam a ponte entre ativos entre cadeias ou utilizem mensagens entre cadeias. Um exemplo criado especificamente para demonstrar reentrância entre cadeias pode ser visto aqui.
Futuro da reentrância?
A introdução de novos opcodes de armazenamento temporário TSTORE
e TLOAD
na EIP-1153 apresenta uma oportunidade para melhorar as proteções de reentrância em contratos inteligentes. Esses opcodes permitem o armazenamento de dados em um local temporário que é redefinido após a conclusão de uma função de contrato, tornando impossível para um invasor reentrar em uma função. Normalmente, as guardas de reentrância eram obtidas usando armazenamento. Dito isto, os opcodes SSTORE
e SLOAD
têm custos significativos de gás. É provável que as guardas de reentrância do OpenZeppelin mudem para usar opcodes de armazenamento temporário mais eficientes em termos de gás.
Com a adição desses novos opcodes, também existem iniciativas para desativar a reentrância por padrão no nível do compilador. Isso forneceria uma camada adicional de proteção contra ataques de reentrância e ajudaria a garantir que os desenvolvedores estejam cientes dos riscos associados ao código reentrante. As linguagens de programação Vyper e Solidity estão ambas considerando implementar esse recurso, o que facilitaria para os desenvolvedores escrever contratos seguros e poderia levar a uma mudança de paradigma para os desenvolvedores ao considerar chamadas externas dentro de seus contratos inteligentes.
Até então, os ataques de reentrância ainda são uma preocupação séria no mundo dos contratos inteligentes.
Portanto, é essencial que os desenvolvedores permaneçam vigilantes em suas práticas de codificação e adotem as melhores práticas de segurança para minimizar o risco de ataques de reentrância. Além disso, auditores e pesquisadores de segurança desempenham um papel crucial na identificação de vulnerabilidades e no fornecimento de feedback aos desenvolvedores. Trabalhando juntos, a comunidade blockchain pode continuar a melhorar a segurança dos contratos inteligentes e prevenir que ataques de reentrância causem mais danos por meio de recompensas de bugs e auditorias.
Artigo original publicado por Immunefi. Traduzido por Paulinho Giovannini.
Latest comments (0)