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.
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
viewused 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?"