Imagine que você é dono de um caixa eletrônico, aqueles ATMs de banco, sabe? Você deve supor que se deixá-lo em um lugar público, com câmeras e muita gente passando, ou em um lugar privado, mas com guardas e seguranças ao redor, será melhor do que deixá-lo sozinho em uma viela qualquer da cidade, certo?
Porque se você optar pela segunda opção, será muito mais difícil mantê-lo seguro, certo? Frequentemente vai ter alguém tentando arrombá-lo, já que ninguém está vendo e tem muito dinheiro dentro dele.
O mesmo acontece com os protocolos DeFi. Sempre que você faz deploy do smart contract de um novo protocolo DeFi na blockchain e ele passa a receber fundos dos usuários, ele se torna uma presa tão fácil quanto um caixa eletrônico carregado “dando sopa” em um beco escuro de uma grande cidade à noite. Vão tentar te atacar, isso é certo.
E dentre os ataques possíveis que os protocolos DeFi vem a ser alvo, tem um que é muito comum. Muito comum de ser feito, muito comum de deixar brecha e, felizmente, muito comum de evitar também. Infelizmente, eventualmente alguém não evita e casos como esses acontecem:
- The DAO Attack 2016 ($60M)
- BurgerSwap Attack 2021 ($7.2M)
- Lendf.me Attack 2020 ($25M)
- XSurge Attack 2021 ($4M)
- Cream Finance Attack 2021 ($18.8M)
- Siren Protocol Attack 2021 ($3.5M)
Entre tantos outros.
O nome desse ataque comum é Reentrancy Attack. E antes que eu possa ensiná-lo como se proteger do mesmo, eu preciso lhe ensinar como esse ataque é feito, para fins didáticos. Peço que não use conhecimento para o mal.
Se preferir, você pode assistir ao vídeo abaixo ao invés de ler o artigo.
#1 – Reentrancy Attack Explicado
Imagine que você tem um contrato de protocolo DeFi como abaixo, que permite depósitos e saques de moedas. Olhe e pense se ele parece ou não ok, em especial a função de saque, que é o alvo dos hackers.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract Carteira { mapping(address => uint) public balances; //user => balance constructor() {} function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw(uint amount) external { require(balances[msg.sender] >= amount, "Insufficient funds"); payable(msg.sender).transfer(amount); balances[msg.sender] -= amount; } } |
Antes de lhe dizer o que tem de errado com essa função, caso não tenha suposto por si só, eu preciso lhe explicar como o ataque é feito. Primeiro, é importante relembrar que na blockchain tanto as carteiras cripto quanto os smart contracts possuem endereços capazes de enviar e receber criptomoedas. Assim, eu posso interagir com o protocolo acima tanto usando uma carteira cripto quanto usando um outro smart contract qualquer, que é o que o hacker irá fazer.
Um hacker querendo fazer um Ataque de Reentrada (Reentrancy Attack), vai criar um smart contract semelhante ao abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; interface IReentrancy { function deposit() external payable; function withdraw(uint amount) external; } contract RA { IReentrancy private immutable target; constructor(address targetAddress) { target = IReentrancy(targetAddress); } function attack() external payable { target.deposit{value: msg.value}(); target.withdraw(msg.value); } receive() external payable { if (address(target).balance >= msg.value) target.withdraw(msg.value); } } |
Existem variações mais sofisticadas, mas como meu intuito não é entregar ferramenta de bandido para vocês, o exemplo acima já serve. O hacker vai fazer deploy desse contrato na blockchain, passando o endereço do protocolo que vai ser vítima do ataque. Claro que antes o hacker estuda o contrato do protocolo vítima para ver se ele é suscetível ao ataque, caso contrário só perderá dinheiro no ataque e até mesmo impossibilitará o mesmo, já que precisamos montar a interface IReentrancy de maneira que funcione com o contrato alvo (repare que a assinatura das funções é igual a do contrato Carteira.sol).
O que esse contrato faz após o deploy? O hacker chama a função attack, mandando junto dela um depósito inicial que será transferido para o protocolo. Esse investimento inicial muitas vezes os hackers obtém através de Flash Loans, o que caracteriza muitas vezes na mídia como um Flash Loan Attack. Até aqui nada demais, Flash Loans não são crime e nem mesmo fazer depósitos. O problema é o que vem a seguir.
O código chama na sequência a função de saque do protocolo, na mesma quantidade que acabou de depositar. Se você olhar a referida função no contrato Carteira.sol verá que ela valida o saldo, faz a transferência e depois atualiza o saldo, certo? No entanto, quando ela fizer a transferência ela estará transferindo para o smart contract do atacante e não para uma conta comum. Toda vez que um smart contract recebe um depósito ele dispara uma função nativa chamada receive, que por padrão não faz nada, apenas recebe o saldo mesmo.
No entanto, você pode implementar código na sua receive, para reagir a esses depósitos e aí que entra a “reentrada”. Dentro da receive o hacker coloca código para chamar um novo saque. Isso impede que o fluxo da transação chegue na etapa de atualizar saldo no contrato Carteira.sol, ele vai reentrar na função withdraw e fazer um novo teste e uma nova transferência. Como o saldo ainda não foi atualizado, esse segundo saque também será permitido…
Após a segunda transferência, o contrato receberá fundos, dispara o receive novamente e aí já viu, vai ficar em loop até secar a fonte e depois bastará o hacker transferir o montante final para outra conta e fugir.
Está feito o estrago.
#2 – Como se proteger de Reentrancy Attack
Existem duas maneiras principais de se proteger de Ataques de Reentrada:
- implementando lógica de maneira mais defensiva;
- bloqueando reentradas em funções críticas;
O primeiro ponto é o ideal e pode ser usado em conjunto do segundo, no entanto nem sempre é trivial.
Em nosso exemplo anterior, é simples de resolver a brecha pois é um protocolo muito simples, mas vale o exercício já que muitas vezes é esse o racional que você deve utilizar. Repare a diferença sutil, porém poderosa, na função de saque:
1 2 3 4 5 6 7 |
function withdraw2(uint amount) external { require(balances[msg.sender] >= amount, "Insufficient funds"); balances[msg.sender] -= amount; payable(msg.sender).transfer(amount); } |
Desta forma, após a primeira entrada na função, o atacante vai ter seu saldo deduzido ANTES de fazer a transferência. Assim, ao disparar o receive no contrato atacante e tentar fazer novo saque, o require que valida a existência de saldo vai acusar que não tem mais, e vai recusar a transação como um todo, inviabilizando ataques de reentrada.
A outra forma de resolver é bloqueando reentradas definitivamente. Isso tem um custo em gás mais alto mas ao mesmo tempo é uma segurança a mais que você pode colocar na sua função, especialmente se for um código mais complexo e difícil de identificar todas as brechas que podem ser exploradas em um Reentrancy Attack. Falarei sobre esta segundo linha de defesa no próximo tópico.
#3 – Bloqueando a Reentrada
Um bloqueio de reentrada é basicamente uma implementação que impede de, em uma mesma transação, a mesma função ser chamada duas vezes. Isso bloqueia completamente ataques de reentrada, mas também adiciona mais processamento, armazenamento e consequentemente custo de gás no seu contrato, então tem de valer a pena, ok?
O conceito principal é o uso de semáforos ou flags para controlar a entrada na função, como abaixo, em uma implementação simplista.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
bool private isProcessing = false; function withdraw3(uint amount) external { require(!isProcessing, "Reentry blocked"); isProcessing = true; require(balances[msg.sender] >= amount, "Insufficient funds"); balances[msg.sender] -= amount; payable(msg.sender).transfer(amount); isProcessing = false; } |
Na implementação acima, temos um semáforo indicando se a função já está sendo processada ou não, com o default sendo false. Quando a função de saque é chamada, ela somente executa se já não estiver em execução (nesta transação), disparando erro caso contrário. Se ela não estava processando, ela altera a flag/semáforo (o que impedirá novas reentradas), faz o seu processamento e depois libera a entrada à função novamente, encerrando a transação.
Caso você não se sinta confortável com essa reentrada ou esteja buscando ainda mais profissional e largamente usado no mercado, pode usar a implementação de bloqueio de reentrada da OpenZeppelin, chamada Reentrancy Guard, cujo uso é bem simples.
1 2 3 4 5 6 7 8 9 |
import "@openzeppelin/contracts/security/ReentrancyGuard.sol" function withdraw4(uint amount) external nonReentrant { require(balances[msg.sender] >= amount, "Insufficient funds"); balances[msg.sender] -= amount; payable(msg.sender).transfer(amount); } |
O import você deve adicionar no topo do contrato e ele lhe permitirá usar o function modifier nonReentrant nas funções que desejar ter o bloqueio de reentrada. Como dito antes, isso adiciona custos adicionais em gás, por isso não é recomendado sair adicionando em funções que não estão expostas a ataques de reentrada.
E com isso espero ter te ajudado a entender mais sobre este famoso ataque e de como se proteger dele. Outro ataque importante de ser estudado é o Gas Griefing, que explico neste artigo.
Até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.