WEB3DEV

Cover image for A Maneira Correta de Escrever Testes para seus Contratos Inteligentes usando o HardHat e o Ethers.js: Ataques de Reentrância
Paulo Gio
Paulo Gio

Posted on

A Maneira Correta de Escrever Testes para seus Contratos Inteligentes usando o HardHat e o Ethers.js: Ataques de Reentrância

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*tHQE_aYARgrbTYNEFE5etQ.png

Eu estava lendo sobre alguns tópicos interessantes outro dia e encontrei algo que me fascinou.

Ataques de Reentrância…

...um dos ataques mais simples e mortais já realizados, ou que serão realizados, em um aplicativo descentralizado.

Pode ser mais fácil entender como um ataque de reentrância funciona se eu primeiro explicar o que são contratos inteligentes (por causa dos leitores que são novos em Solidity, ou que estão no processo de aprendizado, mas ainda não chegaram a esse ponto).

Contratos inteligentes são basicamente trechos de código (instruções programáticas) que são armazenados em uma blockchain e só são executados quando condições previamente especificadas são atendidas.

Pode ser mais fácil visualizar uma pessoa: eu, por exemplo, a caminho da loja para comprar uma lata de ervilhas.

No entanto, em vez de assistentes e caixas para me ajudar a encontrar minhas ervilhas na prateleira quatro, para registrar minha compra e embalá-la em sacolas de compras... existem robôs.

Robôs realmente teimosos.

  • O robô que deveria me ajudar a encontrar minhas ervilhas é programado para só me ajudar se eu estiver vestindo uma camiseta laranja.

O robô que deve registrar minha compra é programado para apenas me ajudar se eu realmente estiver vestindo uma camiseta laranja, tiver escolhido algo nas prateleiras e mostrar evidências de que posso pagar por isso.

contract{
 if( person.shirt(orange) && person.purchase(true) && money.true ){
   help(person)
 }
}
Enter fullscreen mode Exit fullscreen mode

Você pode pensar nos programas que executam esses robôs como contratos inteligentes.

Ah, sim, e... O "contrato" acima, caso você não tenha percebido pela sintaxe um tanto inventada, é um contrato fictício.

Ok, mas qualquer outro programa de computador pode fazer essas coisas…

Sim, isso é verdade.

É fácil se perguntar o que torna os contratos inteligentes tão diferentes de outros tipos de programas.

Uma das principais diferenças é que as condições desses "contratos" estão prontamente disponíveis na blockchain, para ambas as partes de qualquer transação verem.

Com esses "contratos inteligentes" controlando os robôs, não preciso confiar que os proprietários da loja cumprirão sua palavra e me permitirão sair com minhas ervilhas quando eu pagar por elas. Porque eu li o "contrato", concordei em usar uma camisa laranja e concordei em comprar algo muito antes de sair do meu apartamento.

Os proprietários da loja também podem ficar tranquilos, sabendo que seus robôs nunca me deixarão sair com minha compra se eu recusar usar uma camisa laranja ou pagar pela minha compra.

Isso é o que as pessoas querem dizer quando dizem que os contratos inteligentes são usados para executar um sistema "sem necessidade mínima confiança" (trustless).

Geralmente, os contratos inteligentes são considerados inquebráveis e inflexíveis ao extremo. No entanto, assim como em outros tipos de sistemas e aplicativos de computador, sempre haverá brechas.

É aí que ocorrem os ataques de reentrância.

A lógica por trás desse tipo de ataque é bastante simples de entender. Um atacante que tenta realizar um ataque de reentrância em um sistema se aproveita da latência do sistema na atualização de seu estado.

Em outras palavras, eu, como atacante, poderia atacar um caixa eletrônico que executa um contrato inteligente com gerenciamento de estado inadequado e continuar solicitando saques. Repetidas vezes, antes que o sistema tenha a chance de deduzir o valor do(s) meu(s) saque(s) do saldo da minha conta.

Embora os ataques de reentrância sejam considerados ultrapassados hoje em dia, eles eram muito comuns até poucos anos atrás.

Alguns dos maiores ataques a aplicativos descentralizados foram realizados usando ataques de reentrância. Alguns bons exemplos incluem:

  • O ataque da Uniswap/Lendf.me;
  • O ataque do King of the Ether Throne (2016);
  • O ataque do BatchOverflow (2018);
  • O ataque do ProxyOverflow (2021).

E vários outros.

A questão é que os ataques de reentrância são explorações inteligentes em contratos inteligentes e são um tópico interessante no qual todos devem se informar.

Escrevendo Testes para Contratos Inteligentes

Agora que definimos todos os conceitos, provavelmente devemos tentar entender as ferramentas com as quais estaremos trabalhando.

O HardHat é um ambiente de desenvolvimento que oferece um conjunto de ferramentas para desenvolver, testar e implantar contratos inteligentes. O Ethers.js é uma biblioteca que fornece uma interface simples e consistente para interagir com redes Ethereum.

Para começar, precisamos instalar e configurar o HardHat e o Ethers.js.

Você pode seguir as instruções de instalação para o ethers.js aqui e para o HardHat.js aqui.

Depois de configurar seu projeto, você pode criar um arquivo de teste usando o framework de testes Mocha, que está incluído no HardHat por padrão.

Neste artigo, percorreremos o caminho correto para escrever testes para seus contratos inteligentes usando o HardHat e o Ethers.js. Usaremos um exemplo de contrato inteligente para um negócio que aceita um pagamento de 0,1 ether dos usuários e, assim que acumula 1 ether, envia esse dinheiro para o proprietário do negócio.

Primeiro, vamos começar com o código do contrato inteligente. Aqui está um exemplo de um contrato inteligente que aceita pagamentos dos usuários e os envia para o proprietário do contrato quando o valor atinge 1 ether:

pragma solidity ^0.8.0;

contract PaymentCollection {
   address payable owner;
   uint256 public amountCollected;

   constructor() {
       owner = payable(msg.sender);
   }

   function makePayment() public payable {
       require(msg.value == 0.1 ether, "O pagamento deve ser 0,1 ether");
       amountCollected += msg.value;
       if (amountCollected == 1 ether) {
           sendFundsToOwner();
       }
   }

   function sendFundsToOwner() private {
       owner.transfer(amountCollected);
       amountCollected = 0;
   }
}
Enter fullscreen mode Exit fullscreen mode

Como você pode ver, o contrato aceita pagamentos dos usuários e armazena o valor na variável amountCollected. Quando o amountCollected atinge 1 ether, o contrato chama a função sendFundsToOwner, que envia o valor coletado para o endereço do proprietário (owner) e redefine a variável amountCollected para 0.

Ataque de Reentrância em nosso Contrato Inteligente

Agora vamos discutir como um ataque de reentrância pode ser aplicado a este contrato.

À primeira vista, pode parecer que este contrato é seguro e não possui vulnerabilidades, mas vamos dar uma olhada mais de perto.

Um ataque de reentrância ocorre quando um contrato malicioso chama repetidamente de volta ao contrato vulnerável antes que a primeira chamada tenha sido concluída, resultando em comportamento inesperado e potencialmente fazendo com que o contrato se comporte de maneiras não intencionais.

No contrato PaymentCollection, a função sendFundsToOwner envia os fundos coletados para o endereço do proprietário. No entanto, ela não atualiza a variável amountCollected até que a transferência esteja concluída. Isso cria uma vulnerabilidade que pode ser explorada por um atacante para executar um ataque de reentrância.

Para entender como isso pode ser feito, considere o seguinte contrato malicioso:

pragma solidity ^0.8.0;

contract MaliciousContract {
   PaymentCollection paymentCollection;

   constructor(address payable paymentCollectionAddress) {
       paymentCollection = PaymentCollection(paymentCollectionAddress);
   }

   function attack() public payable {
       // Chame a função vulnerável
       paymentCollection.makePayment{value: 0.1 ether}();

       // Reentre na função vulnerável
       paymentCollection.makePayment{value: 0.1 ether}();
   }

   receive() external payable {
       // Faça nada
   }
}
Enter fullscreen mode Exit fullscreen mode

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*tHQE_aYARgrbTYNEFE5etQ.png

O contrato malicioso MaliciousContract aceita um endereço do contrato vulnerável como parâmetro e chama sua função makePayment duas vezes, com um pagamento de 0,1 ether cada vez. Como a função sendFundsToOwner não atualiza a variável amountCollected até que a transferência seja concluída, o atacante pode chamar repetidamente a função makePayment antes que a primeira transferência seja concluída. Isso faz com que a variável amountCollected seja definida como 0 antes que os fundos sejam enviados para o endereço do proprietário.

Como resultado, o atacante pode drenar todo o saldo do contrato PaymentCollection chamando repetidamente a função makePayment antes que a primeira transferência seja concluída. Este é um exemplo clássico de um ataque de reentrância.

Para se proteger contra ataques de reentrância, é essencial seguir o padrão "verificações-efeitos-interações". Esse padrão envolve realizar todas as verificações e atualizações no estado do contrato antes de interagir com contratos externos. No contrato PaymentCollection, a variável amountCollected deve ser atualizada antes de transferir os fundos para o endereço do proprietário, conforme mostrado abaixo:

function sendFundsToOwner() private {
   uint256 amount = amountCollected;
   amountCollected = 0;
   owner.transfer(amount);
}
Enter fullscreen mode Exit fullscreen mode

Ao atualizar a variável amountCollected antes de transferir os fundos para o endereço do proprietário, o contrato PaymentCollection agora está protegido contra ataques de reentrância, pois o estado do contrato é atualizado antes de interagir com contratos externos.

Vale ressaltar também que o uso da função transfer é geralmente considerado mais seguro do que o uso de send ou call, pois possui um limite embutido de 2.300 gás e reverte automaticamente em caso de falha. No entanto, ainda é importante seguir as melhores práticas para garantir que o contrato seja seguro.

O Papel do Hardhat e do Ethers

Testar contratos inteligentes é uma parte essencial do processo de desenvolvimento, e o Hardhat e o Ethers são duas ferramentas populares que podem ser usadas para testar contratos inteligentes de maneira eficiente. Nesta seção, escreveremos um teste para o contrato PaymentCollection usando o Hardhat e o Ethers.

Neste ponto, você deve ter o Hardhat e o Ethers instalados.

Em seguida, precisamos inicializar um novo projeto do Hardhat executando o seguinte comando no terminal:

npx hardhat init
Enter fullscreen mode Exit fullscreen mode

Isso criará um novo projeto do Hardhat com um contrato de exemplo e um arquivo de teste. Podemos excluir o contrato de exemplo e o arquivo de teste e criar um novo arquivo de teste para nosso contrato PaymentCollection.

No novo arquivo de teste, podemos escrever um teste que simula um ataque de reentrância. O teste implantará uma instância do contrato PaymentCollection, criará uma nova instância do contrato malicioso e, em seguida, chamará a função attack no contrato malicioso para drenar o saldo do contrato PaymentCollection.

Depois de instalar os pacotes necessários e ter um projeto básico do Hardhat configurado, você pode criar um novo arquivo de teste no diretório test/.

Vamos chamá-lo de paymentCollection.test.js. Neste arquivo, você pode usar o seguinte código para testar o contrato PaymentCollection:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("PaymentCollection", function () {
 let paymentCollection;
 let owner;

 beforeEach(async function () {
   // Implante o contrato PaymentCollection
   const PaymentCollection = await ethers.getContractFactory("PaymentCollection");
   paymentCollection = await PaymentCollection.deploy();
   await paymentCollection.deployed();

   // Obtenha o endereço do proprietário
   [owner] = await ethers.getSigners();
 });

 it("deve enviar fundos ao proprietário quando o valor coletado for 1 ether", async function () {
   // Faça pagamentos até que o valor coletado seja 1 ether
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });

   // Verifique se o valor coletado é de 1 ether
   expect(await paymentCollection.amountCollected()).to.equal(ethers.utils.parseEther("1"));

   // Envie fundos ao proprietário
   await paymentCollection.sendFundsToOwner();

   // Verifique se o proprietário recebeu os fundos
   const balance = await ethers.provider.getBalance(owner.address);
   expect(balance).to.equal(ethers.utils.parseEther("1"));

   // Verifique se o valor coletado é 0
   expect(await paymentCollection.amountCollected()).to.equal(0);
 });
});
Enter fullscreen mode Exit fullscreen mode

Vamos então analisar todos os passos detalhadamente.

const { expect } = require("chai");
const { ethers } = require("hardhat");
Enter fullscreen mode Exit fullscreen mode

Essas linhas importam as dependências necessárias: expect da biblioteca de asserção Chai e ethers do plugin Ethers do Hardhat.

describe("PaymentCollection", function () {
 let paymentCollection;
 let owner;

 beforeEach(async function () {
   // Implante o contrato PaymentCollection
   const PaymentCollection = await ethers.getContractFactory("PaymentCollection");
   paymentCollection = await PaymentCollection.deploy();
   await paymentCollection.deployed();

   // Obtenha o endereço do proprietário
   [owner] = await ethers.getSigners();
 });
Enter fullscreen mode Exit fullscreen mode

Isso cria um conjunto de testes usando o describe para agrupar os testes para o contrato PaymentCollection. A função beforeEach é executada antes de cada teste e realiza duas ações: ela implanta uma nova instância do contrato PaymentCollection e recupera o endereço do proprietário do contrato.

it("deve enviar fundos ao proprietário quando o valor coletado for 1 ether", async function () {
   // Faça pagamentos até que o valor coletado seja 1 ether
   await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
   // ...
Enter fullscreen mode Exit fullscreen mode

Este é o primeiro teste, que verifica se os fundos são enviados para o proprietário quando o valor coletado é de 1 ether. Ele usa a função makePayment para fazer oito pagamentos de 0,1 ether cada, totalizando 0,8 ether. Em seguida, ele verifica se a variável amountCollected é igual a 1 ether chamando await paymentCollection.amountCollected(). Se a condição for verdadeira, ele envia os fundos coletados para o proprietário chamando a função sendFundsToOwner.

// Verifique se o proprietário recebeu os fundos
   const balance = await ethers.provider.getBalance(owner.address);
   expect(balance).to.equal(ethers.utils.parseEther("1"));

   // Verifique se o valor coletado é 0
   expect(await paymentCollection.amountCollected()).to.equal(0);
 });
Enter fullscreen mode Exit fullscreen mode

Esta parte do teste verifica se o proprietário recebeu os fundos, recuperando o saldo do proprietário usando await ethers.provider.getBalance(owner.address) e comparando-o com 1 ether. Em seguida, verifica se a variável amountCollected foi redefinida para zero.

Você consegue ver como o Hardhat e o Ethers são úteis para testar contratos inteligentes?

Em essência, os testes são importantes quando se trata de desenvolvimento de contratos inteligentes, pois eles:

  1. Garantem a funcionalidade,
  2. Previnem ataques,
  3. Aprimoram a qualidade do código
  4. e Estimulam a colaboração.

No geral, escrever testes é uma parte essencial do desenvolvimento de contratos inteligentes que ajuda a garantir que o contrato inteligente funcione conforme o previsto, seja seguro e de alta qualidade.

Espero ter conseguido mostrar a você algumas dicas e truques sobre como usar o Hardhat e o Ethers, como escrever testes eficazes para seus contratos inteligentes e como os testes de contratos inteligentes são importantes para garantir segurança e escalabilidade.

Artigo original publicado por Bashorun Dolapo. Traduzido por Paulinho Giovannini.

Oldest comments (0)