Reentrancy: Bug, który prawie zabił Ethereum
Najbardziej niesławna podatność w historii smart contractów. Jak wywoływanie zewnętrznych kontraktów przed aktualizacją stanu drenuję miliony.

Pogłęb temat z AI
Kliknij → prompt skopiowany → wklej w czacie AI
17 czerwca 2016.
Ktoś drenuję $60 milionów z The DAO. W jednej transakcji.
Atakujący nie zgadł hasła. Nie złamał szyfrowania. Nikogo nie przekupił.
Po prostu wywołał funkcję. Raz za razem. Zanim kontrakt zdążył zaktualizować swoje dane.
To jest reentrancy. Najbardziej niesławny bug w historii blockchaina.
Czym jest reentrancy?
Wyobraź sobie bankomat, który działa tak:
- Sprawdź saldo: 1000 zł
- Wydaj gotówkę: daje ci 1000 zł
- Zaktualizuj saldo: teraz 0 zł
Brzmi sensownie. Ale co jeśli możesz przerwać krok 3?
- Sprawdź saldo: 1000 zł
- Wydaj gotówkę: daje ci 1000 zł
- Zanim saldo się zaktualizuje, wyzwalasz kolejną wypłatę
- Sprawdź saldo: wciąż 1000 zł (jeszcze nie zaktualizowane!)
- Wydaj gotówkę: daje ci kolejne 1000 zł
- Powtarzaj aż bank będzie pusty
- Dopiero teraz saldo się aktualizuje: 0 zł
To jest reentrancy. Ponowne wejście do kontraktu zanim skończy wykonywanie.
Jak to działa w Solidity
Oto podatny smart contract:
contract PodatnyBank {
mapping(address => uint) public balances;
function withdraw() public {
uint amount = balances[msg.sender];
// Krok 1: Wyślij ETH
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
// Krok 2: Zaktualizuj saldo
balances[msg.sender] = 0;
}
}
Problem? Wysyła ETH przed aktualizacją salda.
Gdy wysyłasz ETH do kontraktu, uruchamia się funkcja receive() lub fallback() tego kontraktu. Atakujący używa tego żeby wywołać withdraw() ponownie:
contract Atakujacy {
PodatnyBank bank;
function attack() public {
bank.withdraw();
}
receive() external payable {
// To się uruchamia gdy bank wysyła ETH
// Saldo jeszcze nie zaktualizowane - wypłać znowu!
if (address(bank).balance > 0) {
bank.withdraw();
}
}
}
Każda wypłata wyzwala kolejną wypłatę. Pętla trwa aż bank jest pusty.
Wzorzec
Każdy atak reentrancy podąża za tym wzorcem:
1. Atakujący wywołuje funkcję
2. Kontrakt sprawdza warunki ✓
3. Kontrakt wykonuje zewnętrzne wywołanie (wysyła ETH, wywołuje inny kontrakt)
4. Zewnętrzne wywołanie uruchamia kod atakującego
5. Kod atakującego wywołuje z powrotem oryginalną funkcję
6. Kontrakt sprawdza warunki ponownie... wciąż ✓ (stan nie zaktualizowany)
7. Powtarzaj aż wyczerpane
8. Stan w końcu się aktualizuje (za późno)
Podatność istnieje gdy:
- Zewnętrzne wywołanie dzieje się przed aktualizacją stanu
- Wywoływany adres jest kontrolowany przez atakującego
- Funkcja może być wywoływana rekurencyjnie
Naprawa: Checks-Effects-Interactions
Rozwiązanie jest proste. Zmień kolejność:
contract BezpiecznyBank {
mapping(address => uint) public balances;
function withdraw() public {
uint amount = balances[msg.sender];
// NAJPIERW: Zaktualizuj stan (effects)
balances[msg.sender] = 0;
// POTEM: Wykonaj zewnętrzne wywołanie (interactions)
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Teraz nawet jeśli atakujący wywoła ponownie, jego saldo jest już 0. Nie ma co wypłacać.
Ten wzorzec nazywa się Checks-Effects-Interactions:
- Checks - zweryfikuj warunki (require)
- Effects - zaktualizuj stan (zmiany w storage)
- Interactions - wywołaj zewnętrzne kontrakty
Zawsze w tej kolejności. Nigdy nie odstępuj.
Guardy reentrancy
Dla dodatkowego bezpieczeństwa użyj mutex (blokada wzajemnego wykluczenia):
contract StrzezonyBank {
bool private locked;
mapping(address => uint) public balances;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function withdraw() public noReentrant {
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Jeśli ktoś próbuje wejść ponownie, flaga locked go blokuje.
OpenZeppelin dostarcza ReentrancyGuard - używaj go.
Rodzaje reentrancy
Reentrancy jednej funkcji
Klasyka. Ponowne wejście do tej samej funkcji (jak w naszym przykładzie).
Reentrancy między funkcjami
Ponowne wejście do INNEJ funkcji która współdzieli stan:
function withdraw() public {
uint amount = balances[msg.sender];
msg.sender.call{value: amount}("");
balances[msg.sender] = 0;
}
function transfer(address to, uint amount) public {
// To może być wywołane podczas withdraw!
// Saldo jeszcze nie zaktualizowane...
balances[msg.sender] -= amount;
balances[to] += amount;
}
Reentrancy między kontraktami
Ponowne wejście do innego kontraktu który ufa podatnemu.
Protokół A wywołuje Protokół B. Protokół B wywołuje z powrotem Protokół A przez trzecią ścieżkę.
Dlatego komponowalność w DeFi jest jednocześnie potężna i niebezpieczna.
Prawdziwe ofiary
The DAO (2016) - $60M Atak który to wszystko rozpoczął. Doprowadził do hard forka Ethereum i powstania Ethereum Classic.
Uniswap/Lendf.Me (2020) - $25M Hooki tokenów ERC-777 umożliwiły reentrancy.
Cream Finance (2021) - $18.8M Reentrancy między kontraktami przez callbacki flash loanów.
Curve (2023) - $70M+ Bug kompilatora Vyper w blokadach reentrancy w wielu pulach.
Lista jest długa. Reentrancy pozostaje w top 3 kategorii podatności DeFi.
Kiedy jesteś zagrożony
Twój kontrakt może być podatny jeśli:
- Wykonujesz zewnętrzne wywołania (
.call(),.transfer(),.send()) - Interagujesz z nieznanymi tokenami (niektóre mają callbacki)
- Używasz flash loanów (callbacki są częścią designu)
- Integrujesz się z zewnętrznymi protokołami
Czyli: prawie każdy nietrywiany kontrakt.
Checklista audytu
-
Znajdź wszystkie zewnętrzne wywołania. Szukaj
.call,.transfer,.send, wywołań interfejsów. -
Sprawdź czy aktualizacje stanu dzieją się przed wywołaniami. Jeśli nie, potencjalna podatność.
-
Szukaj problemów między funkcjami. Czy inna funkcja może dostać się do tego samego stanu w trakcie wykonywania?
-
Sprawdź dziedziczone kontrakty. Kontrakty rodzicielskie mogą mieć problemy.
-
Rozważ callbacki flash loanów. Jeśli je wspierasz, wspierasz wektory reentrancy.
Niewygodna prawda
Reentrancy to znany bug. Jest znany od 2016. A wciąż się zdarza.
Dlaczego?
- Złożoność kodu rośnie
- Komponowalność tworzy nieoczekiwane ścieżki
- Deweloperzy zakładają że "ktoś inny" to złapie
- Presja na szybkie wypuszczanie
Bug jest prosty. Zapobieganie mu wymaga dyscypliny.
Każdy hack DeFi z reentrancy to porażka procesu, nie wiedzy.
Szczegółowy rozkład ataku na The DAO znajdziesz w naszej analizie ataku który podzielił Ethereum na dwie części.
Dalsze czytanie:
- SWC-107: Reentrancy
- DeFiHackLabs przykłady reentrancy
- Implementacja
ReentrancyGuardod OpenZeppelin