Wszystkie artykuły
ŚredniBezpieczeństwo

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.

22 sierpnia 2025
5 min czytania
Reentrancy: Bug, który prawie zabił Ethereum meme

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:

  1. Sprawdź saldo: 1000 zł
  2. Wydaj gotówkę: daje ci 1000 zł
  3. Zaktualizuj saldo: teraz 0 zł

Brzmi sensownie. Ale co jeśli możesz przerwać krok 3?

  1. Sprawdź saldo: 1000 zł
  2. Wydaj gotówkę: daje ci 1000 zł
  3. Zanim saldo się zaktualizuje, wyzwalasz kolejną wypłatę
  4. Sprawdź saldo: wciąż 1000 zł (jeszcze nie zaktualizowane!)
  5. Wydaj gotówkę: daje ci kolejne 1000 zł
  6. Powtarzaj aż bank będzie pusty
  7. 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:

  1. Checks - zweryfikuj warunki (require)
  2. Effects - zaktualizuj stan (zmiany w storage)
  3. 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

  1. Znajdź wszystkie zewnętrzne wywołania. Szukaj .call, .transfer, .send, wywołań interfejsów.

  2. Sprawdź czy aktualizacje stanu dzieją się przed wywołaniami. Jeśli nie, potencjalna podatność.

  3. Szukaj problemów między funkcjami. Czy inna funkcja może dostać się do tego samego stanu w trakcie wykonywania?

  4. Sprawdź dziedziczone kontrakty. Kontrakty rodzicielskie mogą mieć problemy.

  5. 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:

Podobał Ci się artykuł? Obserwuj mnie!

@t0tty3
#reentrancy#bezpieczenstwo#smart-contracty#podatnosci#the-dao

Pogłęb temat z AI

Kliknij → prompt skopiowany → wklej w czacie AI