Skip to main content
Sigvex

Read-Only Reentrancy

Detects view functions that read shared state mid-execution of a mutating call, allowing attackers to observe and exploit inconsistent intermediate state without modifying it.

Read-Only Reentrancy

Overview

Remediation Guide: How to Fix Read-Only Reentrancy

The read-only reentrancy detector identifies external contracts that invoke STATICCALL to read state from a contract mid-execution of a non-atomic, multi-step operation. Unlike classic reentrancy — where the attacker re-enters and modifies state — read-only reentrancy exploits the fact that a contract’s state is temporarily inconsistent during the execution of a multi-step write operation. An attacker calls a secondary contract (such as a price oracle or collateral calculator) that reads the primary contract’s stale view via a read-only call, and then makes financial decisions based on that stale data.

Sigvex detects this pattern by analyzing the execution ordering of state-writing operations (SSTORE) relative to outgoing external calls (CALL, DELEGATECALL). When a function updates state in multiple steps and makes an outgoing call between those steps — creating a window where external view functions can observe an inconsistent intermediate state — the detector flags the pattern as a read-only reentrancy risk.

This vulnerability became prominent with a major stable-swap protocol’s multi-token pools, where LP token prices read from external view functions could be manipulated by an attacker who exploited the gap between balance updates and price recalculations during a single transaction.

Why This Is an Issue

Read-only reentrancy is particularly insidious because the reentering contract does not need write access to exploit it. Protocols that use a DeFi pool’s get_virtual_price() or similar view functions as collateral oracles are vulnerable when those views read state that can be temporarily inconsistent during pool operations.

The stable-swap read-only reentrancy exploit class demonstrated losses across multiple protocols that used a stable-swap pool’s get_virtual_price() as a pricing source. Attackers could manipulate the reported price by re-entering during a pool’s remove_liquidity call while balances had not yet been fully updated, then exploit the artificially inflated price in a third-party lending protocol.

Real-world incidents in 2023 affected multiple lending protocols for tens of millions of dollars, exploiting shared read-only price feeds from stable-swap pools.

How to Resolve

// Before: Vulnerable — price read from external contract while state is partially updated
contract VulnerableLending {
    IStableSwapPool public pool;

    function liquidate(address borrower) external {
        // Reads the pool's get_virtual_price() — can return stale value mid-operation
        uint256 price = pool.get_virtual_price();
        uint256 collateralValue = calculateCollateral(price);
        // Uses potentially manipulated price for critical decision
        if (collateralValue < getDebt(borrower)) {
            _liquidate(borrower);
        }
    }
}

// After: Add reentrancy guard to price-reading functions
import "@audited/utils/ReentrancyGuard.sol";

contract SecureLending is ReentrancyGuard {
    IStableSwapPool public pool;

    function liquidate(address borrower) external nonReentrant {
        uint256 price = pool.get_virtual_price();
        uint256 collateralValue = calculateCollateral(price);
        if (collateralValue < getDebt(borrower)) {
            _liquidate(borrower);
        }
    }
}

Alternatively, use a time-weighted price or add an explicit reentrancy lock on the price-reading path:

// Also consider checking the stable-swap AMM's lock status before reading price
contract SecureLending {
    IStableSwapPool public pool;

    function getSecurePrice() internal view returns (uint256) {
        // Some stable-swap pools expose a reentrancy guard check
        // Check the pool's lock status if available, then read price
        return pool.get_virtual_price();
    }
}

Examples

Vulnerable Code

// Protocol A: DeFi pool that updates state in multiple steps with external call in between
contract VulnerablePool {
    uint256 public totalShares;
    mapping(address => uint256) public balances;

    function removeLiquidity(uint256 shares) external {
        uint256 amount = (shares * address(this).balance) / totalShares;
        totalShares -= shares;  // Step 1: shares updated

        // VULNERABLE: external call here while balances[msg.sender] not yet zeroed
        // An attacker can re-enter a Protocol B that reads balances[msg.sender]
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok);

        balances[msg.sender] -= shares;  // Step 2: not yet updated when call happens
    }

    // This view can return stale data during removeLiquidity execution
    function getVirtualPrice() external view returns (uint256) {
        return (address(this).balance * 1e18) / totalShares;
    }
}

// Protocol B: Lending protocol using Protocol A's view as oracle — VULNERABLE
contract VulnerableLender {
    IPool public pool;

    function borrow(uint256 amount) external {
        uint256 price = pool.getVirtualPrice();  // Reads mid-removeLiquidity — stale!
        // price is artificially high because balances are inconsistent
        require(getCollateral(msg.sender) >= amount * price / 1e18);
        _sendTokens(msg.sender, amount);
    }
}

Fixed Code

// Protocol B fixed: use reentrancy guard to prevent mid-state reads
contract SecureLender is ReentrancyGuard {
    IPool public pool;

    function borrow(uint256 amount) external nonReentrant {
        uint256 price = pool.getVirtualPrice();
        require(getCollateral(msg.sender) >= amount * price / 1e18);
        _sendTokens(msg.sender, amount);
    }
}

// Protocol A fixed: complete all state updates before external call
contract SecurePool {
    uint256 public totalShares;
    mapping(address => uint256) public balances;

    function removeLiquidity(uint256 shares) external {
        uint256 amount = (shares * address(this).balance) / totalShares;
        totalShares -= shares;
        balances[msg.sender] -= shares;  // Complete ALL state updates first

        // External call after all state is consistent
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok);
    }
}

Sample Sigvex Output

{
  "detector_id": "read-only-reentrancy",
  "severity": "critical",
  "confidence": 0.70,
  "description": "Function removeLiquidity() makes an external CALL at offset 0x8c while storage state (totalShares) has been updated but balances[msg.sender] has not yet been decremented. A STATICCALL to getVirtualPrice() during this window returns an inconsistent price.",
  "location": { "function": "removeLiquidity(uint256)", "offset": 140 }
}

Detection Methodology

Sigvex detects read-only reentrancy using a multi-pass CFG analysis:

  1. State-update sequencing: Identifies functions with multiple SSTORE operations that affect related storage slots (e.g., total supply and individual balance). Detects cases where these writes are split across an external call boundary.
  2. External call identification: Marks CALL, DELEGATECALL, and CALLCODE opcodes that transfer control to user-controlled addresses.
  3. View function cross-reference: Identifies STATICCALL targets that access storage slots involved in the split write. When a view function can read those slots during the inconsistent window, the pattern is flagged.
  4. Confidence calibration: Higher confidence when the split writes clearly affect a shared price or accounting variable; lower confidence when the relationship between the write slots is unclear.

Limitations

False positives:

  • Contracts that use a reentrancy guard on both the view function and the write function are correctly protected but may still be flagged if the guard is implemented via a custom mechanism not recognized at the bytecode level.
  • External calls to trusted, known-safe addresses (e.g., ERC-20 token contracts that do not invoke back) may be flagged.

False negatives:

  • Cross-contract read-only reentrancy where the vulnerable view function is in a separate contract (not the one being analyzed) is detected with lower confidence.
  • Novel patterns where the “view function” is implemented via CALL with no state changes (rather than STATICCALL) may be missed.
  • Reentrancy — detects classic single-function reentrancy with state modification
  • Cross-Function Reentrancy — detects reentrancy that pivots through a second function
  • Flash Loan — flash loans are frequently used to capitalize on read-only reentrancy windows

References