Read-Only Reentrancy: When View Functions Lie

A reentrancy guard protects state changes, not state reads. How an honest-looking view function can hand a mid-transaction price to every protocol that trusts it.

Published by Xevry

Most Solidity developers know the classic reentrancy pattern: an external call happens before state is updated, and the callee re-enters to act on stale state. The standard defense, a nonReentrant modifier, works by locking state-changing functions while a call is in flight.

Read-only reentrancy is the variant that slips past that defense. The attacker never re-enters a state-changing function. Instead, during the callback, they cause a different protocol to read a view function on the vulnerable contract — and that view function returns a value computed from half-updated state.

The shape of the bug

Consider a simplified pool that holds ETH and issues shares. It exposes a pricePerShare() view that other protocols use to value those shares, for example as lending collateral.

contract Pool is ReentrancyGuard {    uint256 public totalShares;    mapping(address => uint256) public shares;     function pricePerShare() external view returns (uint256) {        return address(this).balance * 1e18 / totalShares;    }     function withdraw(uint256 amount) external nonReentrant {        uint256 owed = amount * address(this).balance / totalShares;        shares[msg.sender] -= amount;        (bool ok, ) = msg.sender.call{value: owed}("");  // balance drops here        require(ok, "transfer failed");        totalShares -= amount;                            // supply drops later    }}

When withdraw sends ETH, the contract's balance has already decreased, but totalShares has not. For the duration of the callback, pricePerShare() returns a value that is too low. The nonReentrant guard is irrelevant: nothing re-enters withdraw.

Why integrations are the real victim

The pool itself loses nothing. The damage lands on whoever trusts the view. Inside the callback, an attacker can call a lending market that prices pool shares using pricePerShare(). With collateral temporarily undervalued, healthy positions look insolvent and can be liquidated at a discount. If the ordering were reversed — supply reduced before assets leave — the price would be inflated instead, and the attacker could borrow against overvalued collateral.

This is not hypothetical. Read-only reentrancy was publicly disclosed in integrations that priced Curve LP tokens via get_virtual_price() during ETH withdrawals, and the pattern has since appeared in several unrelated codebases.

Audit heuristicFor every external call, write down which state variables are updated after it. Then list every view function that reads any of them. Each pair is a candidate read-only reentrancy.

Fixing it on both sides

1. In the protocol: finish your accounting first

Checks-effects-interactions applies to views too. Update every piece of state the view depends on before making the external call.

function withdraw(uint256 amount) external nonReentrant {    uint256 owed = amount * address(this).balance / totalShares;    shares[msg.sender] -= amount;    totalShares -= amount;                 // effects complete    (bool ok, ) = msg.sender.call{value: owed}("");    require(ok, "transfer failed");}

For native ETH, the value moves before the recipient's code runs, so by the time any callback executes both the balance and the supply are updated. For tokens with transfer hooks (such as ERC-777), reason carefully about exactly when balances change relative to the hook.

2. In the protocol: make views refuse to lie

If complete ordering is impractical, make price-sensitive views revert while the lock is held. OpenZeppelin's ReentrancyGuard exposes _reentrancyGuardEntered() for exactly this purpose.

function pricePerShare() external view returns (uint256) {    require(!_reentrancyGuardEntered(), "reentrant read");    return address(this).balance * 1e18 / totalShares;}

3. In the integration: verify, don't assume

If you consume a third-party price, check whether that protocol exposes a lock state or a reentrancy-safe accessor, and use it. Where none exists, a common pattern is to call a cheap state-changing function protected by the same lock — if it reverts, you are inside a callback and should not trust the price.

Takeaways

  • A reentrancy guard is a lock on writes. It says nothing about the consistency of reads.
  • Any view used as a price or exchange rate is part of your security surface, even if your own code never calls it.
  • When reviewing integrations, ask not only "is this oracle manipulable?" but "can this oracle be read mid-transaction?"
All research Next: Rounding Is a Security Decision
Start a conversation

Let's discuss your security needs.

Tell us what you're building. We'll respond with clear questions, an honest view of whether we're the right fit, and a scoped proposal if we are.