Primeiramente vou te ajudar a entender de forma simples o que é um ataque de reentrada e como você pode evitá-lo, e depois, vou me aprofundar em exemplos de códigos para mostrar onde estão as vulnerabilidades, qual seria o código do invasor e o mais importante, mostrarei os métodos verificados mais recentes para proteger não apenas um, mas todos os contratos inteligentes do seu projeto.
Spoiler: Se você já ouviu falar sobre o modificador nonReentrant(), continue lendo porque você está prestes a descobrir algumas linhas abaixo do modificador globalNonReentrant() e do padrão verifica-efeitos-interações.
Na imagem acima temos ContratoA e ContratoB. Agora, como você sabe, um contrato inteligente pode interagir com outro contrato inteligente, como neste caso, o ContratoA pode chamar o ContratoB. Portanto, a ideia básica da reentrada é que o ContratoB seja capaz de retornar a chamada para o ContratoA enquanto o ContratoA ainda está em execução.
Então, como o invasor pode usar isso?
Acima temos o ContractA que possui 10 Ethers e vemos que o ContractB armazenou 1 Ether no ContractA. Neste caso, o ContratoB poderá utilizar a função de saque do ContratoA e enviar o Ether de volta para si mesmo ao passar na verificação onde seu saldo for maior que 0, para então ter seu saldo total modificado para 0.
Vamos agora ver como o ContractB pode usar a reentrada para explorar a função de retirada e roubar todos os Ethers do ContractA. Basicamente, o invasor precisará de duas funções: attack() e fallback().
No Solidity, uma função substituta é uma função externa sem nome, parâmetros ou valores de retorno. Qualquer pessoa pode chamar uma função substituta: Chamando uma função que não existe dentro do contrato; Chamar uma função sem passar os dados necessários; Envio de Ether sem nenhum dado no contrato.
A forma como a reentrada funciona (vamos seguir as setas passo a passo) é com o invasor chamando a função attack() que por dentro está chamando a função withdraw() do ContratoA. Dentro da função, ele irá verificar se o saldo do ContratoB é maior que 0 e se sim dará continuidade à execução.
Como o saldo do ContractB é maior que 0, ele envia 1 Ether de volta e aciona a função de fallback. Observe que neste momento o ContratoA possui 9 Ethers e o ContratoB já possui 1 Ether.
A seguir, quando a função de fallback é executada, ela aciona novamente a função de retirada do ContratoA, verificando novamente se o saldo do ContratoB é maior que 0. Se você verificar novamente a imagem acima, notará que seu saldo ainda é de 1 Ether.
Isso significa que a verificação é aprovada e envia outro Ether para ContractB, o que aciona a função de fallback. Observe que como a linha onde temos “balance=0” nunca é executada, isso continuará até que todo o Ether do ContratoA acabe.
___________
Vamos agora dar uma olhada em um contrato inteligente onde podemos identificar a reentrada com o código Solidity.
No contrato EtherStore, temos a função deposit() que armazena e atualiza os saldos do remetente e depois a função withdrawAll() que pegará todos os saldos armazenados de uma vez. Por favor, observe a implementação do withdrawAll() onde ele verifica primeiro com o require se o saldo é maior que 0 e logo após envia o Ether, novamente, deixando para o final a atualização do saldo do remetente para 0.
Aqui temos o contrato Attack que vai usar a reentrada para drenar o contrato EtherStore. Vamos analisar seu código:
Em seu construtor, o invasor passará o endereço EtherStore para criar uma instância e assim poder utilizar suas funções.
Lá vemos a função fallback() que será chamada quando o EtherStore enviar Ether para este contrato. Dentro dele estará chamando retirar da EtherStore desde que o saldo seja igual ou maior que 1.
E dentro da função attack() temos a lógica que explorará o EtherStore. Como podemos ver, primeiro iniciaremos o ataque certificando-nos de que temos éter suficiente, depois depositaremos 1 éter para ter um saldo maior que 0 no EtherStore e assim passaremos nas verificações antes de começar a sacar.
Expliquei acima no exemplo do ContratoA e do ContratoB passo a passo como o código será executado, então agora vamos fazer um resumo de como será. Primeiro de tudo, o invasor chamará attack(), que internamente chamará retireAll() do EtherStore, que então enviará Ether para a função de fallback do contrato de ataque. E aí vai iniciar a reentrada e esgotar o saldo da EtherStore.
Então, como podemos proteger nossos contratos contra ataques de reentrada?
Vou mostrar três técnicas de prevenção para protegê-los totalmente. Abordarei como evitar a reentrada em uma única função, reentrada entre funções e reentrada entre contratos.
A primeira técnica para proteger uma única função é usar um modificador chamado noReentrant.
Um modificador é um tipo especial de função que você usa para modificar o comportamento de outras funções. Os modificadores permitem adicionar condições ou funcionalidades extras a uma função sem ter que reescrever a função inteira.
O que fazemos aqui é bloquear o contrato enquanto a função é executada. Dessa forma, ele não poderá entrar novamente na função única, pois precisará passar pelo código da função e depois alterar a variável de estado bloqueada para falso para passar novamente na verificação feita no require.
___________
A segunda técnica é usar o padrão Verificações-Efeitos-Interações, que protegerá nossos contratos da reentrada entre funções. Você consegue identificar no contrato EtherStore atualizado acima o que mudou?
Para se aprofundar no padrão Check-Effects-Interaction, recomendo ler https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html
Acima vemos a comparação entre o código vulnerável da imagem à esquerda onde o saldo foi atualizado após o envio do Ether, que como visto acima poderia nunca ser alcançado, e à direita o que foi feito foi mover os saldos[ msg.sender] = 0 (ou efeito) logo após require(bal > 0) (verificar), mas antes de enviar ether (interação).
Desta forma estaremos garantindo que mesmo que outra função acesse withdrawAll(), este contrato estará protegido do invasor pois o saldo será sempre atualizado antes do envio do Ether.
Padrão criado por https://twitter.com/GMX_IO
A terceira técnica que vou mostrar é criar o contrato GlobalReentrancyGuard para proteger contra reentrada entre contratos. É importante compreender que isto se aplica a projetos com múltiplos contratos interagindo entre si.
A ideia aqui é a mesma do modificador noReentrant que expliquei na primeira técnica, ele entra no modificador, atualiza uma variável para travar o contrato e não desbloqueia até terminar o código. A grande diferença aqui é que estamos utilizando uma variável armazenada em um contrato separado que serve como local para verificar se a função foi inserida ou não.
Criei aqui um exemplo sem código real e apenas com nomes de funções como referência para entender a ideia, pois, pela minha experiência, pode ajudar a visualizar a situação mais do que apenas escrevê-la com palavras.
Aqui, o invasor estaria chamando a função no contrato ScheduledTransfer que após atender às condições enviaria o Ether especificado para o contrato AttackTransfer que, portanto, entraria na função de fallback e, portanto, “cancelaria” a transação do ponto de origem do contrato ScheduledTransfer. ver e ainda assim receber o Éter. E assim estaria iniciando uma olhada até drenar todos os Ethers do ScheduledTransfer.
Bem, usar o GlobalReentrancyGuard que mencionei acima evitará esse cenário de ataque.
__________________
Twitter @TheBlockChainer para encontrar mais atualizações diárias sobre contratos inteligentes, segurança Web3, solidez, auditoria de contratos inteligentes e muito mais.
__________________