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)
}
}
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;
}
}
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
}
}
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);
}
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
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);
});
});
Vamos então analisar todos os passos detalhadamente.
const { expect } = require("chai");
const { ethers } = require("hardhat");
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();
});
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") });
// ...
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);
});
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:
- Garantem a funcionalidade,
- Previnem ataques,
- Aprimoram a qualidade do código
- 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.
Top comments (0)