DEV Community

ohmygod
ohmygod

Posted on

Read-Only Reentrancy: The Silent Price Oracle Killer Every DeFi Protocol Still Gets Wrong

Traditional reentrancy has a signature that every auditor can spot — a state change after an external call. But read-only reentrancy hides in plain sight: it targets view functions that return stale data during an ongoing callback, poisoning every protocol that reads from them. In Q1 2026 alone, at least $47M in losses trace back to price feeds that were technically "correct" — just queried at exactly the wrong moment.

This article dissects the mechanics, shows you how to detect it in your own codebase, and provides battle-tested defense patterns that work at scale.


The Anatomy of Read-Only Reentrancy

Why Traditional Guards Don't Catch It

A standard nonReentrant modifier protects state-modifying functions. But view functions — getVirtualPrice(), totalAssets(), getRate() — are left unguarded because they "don't change state." The assumption: reading data is always safe.

That assumption is wrong.

When Protocol A makes an external call (e.g., transferring ETH via a callback), execution briefly returns to the caller. During that callback window, Protocol A's internal balances are inconsistent — some state has been updated, some hasn't. If Protocol B calls Protocol A's view function during this window, it gets a snapshot of an incomplete state transition.

Timeline of a Read-Only Reentrancy Attack:

1. Attacker calls Protocol A's withdraw()
2. Protocol A updates internal accounting (partial)
3. Protocol A sends ETH to attacker → triggers fallback
4. In fallback, attacker calls Protocol B
5. Protocol B reads Protocol A's getVirtualPrice() → STALE VALUE
6. Protocol B executes trade/borrow based on inflated price
7. Protocol A's withdraw() completes, price normalizes
8. Attacker profits from the price discrepancy
Enter fullscreen mode Exit fullscreen mode

The Composability Trap

This isn't just a bug in one protocol — it's a systemic design flaw in DeFi composability. Every protocol that uses another protocol's view function as a price oracle is potentially exposed. The vulnerable protocol might be perfectly secure in isolation. The exploit only manifests in the interaction between protocols.

Real examples of dangerous price dependencies:

// Lending protocol using Curve's virtual price as collateral oracle
function getCollateralValue(uint256 lpTokens) public view returns (uint256) {
    // This call is safe in isolation
    // But deadly if called during a Curve pool's callback window
    uint256 virtualPrice = curvePool.get_virtual_price();
    return lpTokens * virtualPrice / 1e18;
}
Enter fullscreen mode Exit fullscreen mode

Case Study: The Curve LP Oracle Manipulation Pattern

The canonical read-only reentrancy vector involves Curve Finance's get_virtual_price() function.

How get_virtual_price() Works

Curve's virtual price represents the value of 1 LP token in the pool's base currency. It's calculated from the pool's internal balances and the invariant. During a remove_liquidity call:

  1. The pool calculates withdrawal amounts
  2. Tokens are transferred to the user (external call!)
  3. The pool's internal balances are updated

Between steps 2 and 3, get_virtual_price() reads balances that haven't been fully updated yet — inflating the apparent value of each LP token.

The Attack Chain

// VULNERABLE: Lending protocol that accepts Curve LP as collateral
contract VulnerableLender {
    ICurvePool public curvePool;

    function borrow(uint256 lpCollateral, uint256 borrowAmount) external {
        IERC20(curvePool.lp_token()).transferFrom(msg.sender, address(this), lpCollateral);

        // ⚠️ VULNERABLE: This reads stale data during reentrancy
        uint256 collateralValue = lpCollateral * curvePool.get_virtual_price() / 1e18;

        require(borrowAmount <= collateralValue * MAX_LTV / 10000, "Undercollateralized");
        IERC20(borrowToken).transfer(msg.sender, borrowAmount);
    }
}
Enter fullscreen mode Exit fullscreen mode

The attacker's contract:

contract ReadOnlyReentrancyExploit {
    ICurvePool public curvePool;
    VulnerableLender public lender;

    function attack() external {
        curvePool.add_liquidity([1000e18, 1000e18], 0);
        curvePool.remove_liquidity(curvePool.balanceOf(address(this)), [0, 0]);
    }

    receive() external payable {
        // During callback, virtual price is inflated
        uint256 lpBalance = IERC20(curvePool.lp_token()).balanceOf(address(this));
        lender.borrow(lpBalance, OVERBORROW_AMOUNT);
    }
}
Enter fullscreen mode Exit fullscreen mode

Five Protocols That Got Burned

Protocol Date Loss Root Cause
Sentiment Protocol Apr 2023 $1M Read-only reentrancy via Balancer getRate()
Sturdy Finance Jun 2023 $800K Stale Balancer pool price during callback
EraLend (zkSync) Jul 2023 $3.4M Read-only reentrancy via SyncSwap oracle
Balancer V2 Composable Nov 2025 $100M+ Precision + reentrancy interaction
Venus Protocol (THE) Mar 2026 $3.7M Supply cap bypass via inflated oracle read

Detection: Finding Read-Only Reentrancy in Your Codebase

Pattern 1: Cross-Protocol View Dependencies

Search for any external view call used in a value calculation:

# Slither custom detector approach
slither . --detect reentrancy-no-eth --print call-graph
Enter fullscreen mode Exit fullscreen mode

Manual checklist:

  • Does your protocol call getRate(), get_virtual_price(), totalAssets(), convertToAssets(), or getPrice() on external contracts?
  • Are those values used to determine collateral value, exchange rates, or reward amounts?
  • Can the external protocol trigger callbacks (ETH transfers, ERC-777, hooks)?

Pattern 2: Semgrep Rule for Automated Detection

rules:
  - id: read-only-reentrancy-price-oracle
    patterns:
      - pattern: |
          function $FUNC(...) ... {
            ...
            $PRICE = $EXTERNAL.$VIEW_FUNC(...);
            ...
            require($AMOUNT <= $PRICE * ..., ...);
            ...
          }
      - metavariable-regex:
          metavariable: $VIEW_FUNC
          regex: (get_virtual_price|getRate|totalAssets|convertToAssets|getPrice|exchangeRate)
    message: External view function used in value calculation
    severity: WARNING
    languages: [solidity]
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Foundry Invariant Test

function invariant_priceOracleConsistency() public {
    uint256 priceBefore = curvePool.get_virtual_price();
    vm.prank(address(exploitContract));
    try curvePool.remove_liquidity(amount, mins) {} catch {}
    uint256 priceAfter = curvePool.get_virtual_price();
    assertApproxEqRel(priceBefore, priceAfter, 0.01e18);
}
Enter fullscreen mode Exit fullscreen mode

Defense Patterns That Actually Work

Defense 1: The Reentrancy Lock Check (Best for Integrators)

Check if the target protocol is mid-execution before trusting its view functions:

interface IReentrancyAware {
    function reentrancyLocked() external view returns (bool);
}

function safeRead(IReentrancyAware target) internal view returns (uint256) {
    require(!target.reentrancyLocked(), "Target mid-execution");
    return target.getRate();
}
Enter fullscreen mode Exit fullscreen mode

Defense 2: TWAP + Bounds (Best for Lending Protocols)

contract SafeOracleAdapter {
    uint256 public constant TWAP_WINDOW = 30 minutes;
    uint256 public constant MAX_DEVIATION = 200; // 2% bps

    function getSafePrice() external view returns (uint256) {
        uint256 spotPrice = externalOracle.getPrice();
        uint256 twapPrice = _calculateTWAP();

        uint256 deviation = spotPrice > twapPrice 
            ? (spotPrice - twapPrice) * 10000 / twapPrice
            : (twapPrice - spotPrice) * 10000 / twapPrice;

        require(deviation <= MAX_DEVIATION, "Price deviation too high");
        return spotPrice < twapPrice ? spotPrice : twapPrice;
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense 3: The Callback Blocker (Best for Protocol Authors)

Extend your reentrancy guard to cover reads:

abstract contract ReadSafeReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    uint256 private _status;

    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED;
        _;
        _status = _NOT_ENTERED;
    }

    // Protect view functions that external protocols depend on
    modifier readSafe() {
        require(_status != _ENTERED, "ReadSafe: inconsistent state");
        _;
    }

    function reentrancyLocked() external view returns (bool) {
        return _status == _ENTERED;
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense 4: The Snapshot Pattern (Best for Cross-Protocol Reads)

contract SnapshotProtectedLender {
    mapping(address => uint256) public cachedPrices;
    mapping(address => uint256) public priceTimestamps;
    uint256 public constant STALE_THRESHOLD = 1 hours;

    function updateOraclePrice(address oracle) external {
        cachedPrices[oracle] = IOracle(oracle).getPrice();
        priceTimestamps[oracle] = block.timestamp;
    }

    function borrow(uint256 collateral, uint256 amount) external {
        uint256 price = cachedPrices[address(oracle)];
        require(block.timestamp - priceTimestamps[address(oracle)] < STALE_THRESHOLD, "Stale");
        uint256 value = collateral * price / 1e18;
        require(amount <= value * MAX_LTV / 10000, "Undercollateralized");
        IERC20(token).transfer(msg.sender, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Solana: Is Read-Only Reentrancy Possible?

Short answer: not in the same way, but analogous risks exist.

Solana's runtime prevents traditional reentrancy through its account locking model. However, Cross-Program Invocations (CPI) can create similar oracle manipulation windows:

pub fn borrow(ctx: Context<Borrow>) -> Result<()> {
    let price = ctx.accounts.price_oracle.load()?.current_price;
    let twap = ctx.accounts.twap_oracle.load()?.weighted_price;
    require!(
        price.abs_diff(twap) * 10000 / twap < MAX_DEVIATION_BPS,
        ErrorCode::PriceDeviationTooHigh
    );
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The Solana-specific risk is stale account data in the same transaction. Always validate prices against secondary sources.


Audit Checklist: 7 Questions to Ask

  1. Which external view functions do we call? Map every cross-protocol read.
  2. Can those protocols trigger callbacks? ETH transfers, ERC-777, ERC-1155 hooks, flash loans.
  3. What happens to those view functions during a callback?
  4. Do we have a TWAP or bounds check? Single-block spot reads are never safe.
  5. Is the external protocol's reentrancy lock status queryable?
  6. Are price-dependent operations in the same tx as external calls? Separate them.
  7. Do invariant tests cover the callback window?

Conclusion

Read-only reentrancy is DeFi's most underestimated vulnerability class because it violates a reasonable-sounding assumption: reading data should be safe. The fix isn't complicated: protect your view functions with reentrancy awareness, never trust single-block spot prices, and test your cross-protocol integrations under callback conditions.

Every protocol that uses another protocol's view function as a price feed is one callback away from being the next case study.


DreamWork Security publishes weekly deep dives on DeFi vulnerability patterns. Follow for detection tools, defense playbooks, and exploit analysis.

Top comments (0)