Skip to content
Quantix Security Labs
Primer

Read-only reentrancy: when a view function tells the truth too early

A contract can be safe from classic reentrancy and still expose an inconsistent state to anyone who reads it mid-call. How the pattern works, and how integrators should defend against it.

Written by
Quantix Research, Smart Contract Security
Published
Reading time
8 minutes
Category
Smart Contract Vulnerabilities

Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Illustration: a pool makes an external call that loops back into a lender, which reads the pool’s view while one reserve is updated and the other is not.
Contents
  1. Reentrancy without writes
  2. How the inconsistent read happens
  3. A simplified example
  4. The same shape elsewhere
  5. Why the usual guard is not enough
  6. Defences on both sides
  7. What reviewers check
  8. Conclusions
  9. Recommendations
  10. Bibliography

Most engineers learn reentrancy through a single picture: a contract sends funds before it updates a balance, and the recipient calls back in to withdraw again. The defence is just as familiar: update state first, or put a reentrancy guard on every function that changes state. Both are correct, and both leave one door open.

Read-only reentrancy does not re-enter a function that writes. It re-enters one that only reads, at a moment when the contract’s state is half updated, and it does so through another protocol that trusts the answer. The vulnerable contract never loses funds directly. The protocol that read from it does.

Reentrancy without writes

A view function promises not to modify state. It makes no promise that the state it reads is consistent. During an external call, a contract’s storage is frozen exactly as it was when control left it. If some variables have been updated and others have not, every view function that combines them will return a value that is true of no real state of the system.

On its own this is harmless: the contract will finish its own accounting when control returns. It becomes a vulnerability when a second protocol reads that value during the window and acts on it, for example by pricing collateral, minting a derivative, or settling a trade.

How the inconsistent read happens

The pattern needs three ingredients:

  1. A function that hands control to an untrusted address part-way through its accounting. Native-coin transfers, ERC-777 hooks, ERC-721 and ERC-1155 receiver callbacks and arbitrary low-level calls all qualify.
  2. A public view function whose result depends on two or more values that are updated on different sides of that external call, such as reserves and total supply.
  3. An integrating protocol that calls the view function and treats the result as authoritative.

The attacker triggers the first function, receives control in the callback, and from inside that callback interacts with the integrator. The integrator reads a price that is briefly wrong, usually inflated or deflated in a direction the attacker chose, and the attacker borrows, liquidates or redeems against it before the original call completes.

The pattern drew wide attention in 2022, when researchers showed that several lending integrations priced liquidity-pool tokens using a pool function that could be read mid-withdrawal. The underlying idea is general, and it appears wherever a protocol exposes derived values for others to consume.

Sequence of a read-only reentrancy attackThree lifelines: attacker contract, liquidity pool and lending market. The attacker calls removeLiquidity on the pool. The pool reduces total shares, then sends native coin to the attacker, which runs the attacker's receive hook. Inside the hook the attacker borrows from the lending market, which reads the pool's share price while the pool's reserves are not yet updated, and receives an inflated price. The loan is issued. Only afterwards does the pool reduce its native reserve.Attacker contractLiquidity poolLending market1removeLiquidity(amount)totalShares −= amount23native coin sent: receive hook runs4borrow(max) from inside the hook5sharePrice() read mid-callinflated price returned6loan issued against overvalued collateralreserveNative −= nativeOut7too lateWindow in which the pool’s public views combine updated and stale values
The attacker’s receive hook runs while the pool’s accounting is half complete. The lending market reads the pool’s view inside that window.

A simplified example

The pool below holds a native coin and an ERC-20 token and issues shares against them. During removeLiquidity it reduces totalShares before sending the native coin, but reduces reserveNative only afterwards. The code is deliberately reduced to the essentials.

LiquidityPool.sol (illustrative)solidity
// Simplified, illustrative pool. Not production code.contract LiquidityPool is ReentrancyGuard {    uint256 public totalShares;    uint256 public reserveNative;   // native coin held by the pool    uint256 public reserveToken;    // ERC-20 held by the pool    mapping(address => uint256) public shares;     /// Price of one share, read by external protocols.    function sharePrice() external view returns (uint256) {        uint256 value = reserveNative + _tokenValueInNative(reserveToken);        return (value * 1e18) / totalShares;    }     function removeLiquidity(uint256 amount) external nonReentrant {        uint256 nativeOut = (reserveNative * amount) / totalShares;        uint256 tokenOut  = (reserveToken  * amount) / totalShares;         shares[msg.sender] -= amount;        totalShares        -= amount;     // supply already reduced...        reserveToken       -= tokenOut;         // ...but reserveNative is updated only after the external call.        (bool ok, ) = msg.sender.call{value: nativeOut}("");        require(ok, "send failed");        reserveNative -= nativeOut;         token.transfer(msg.sender, tokenOut);    }}

Inside the callback on line 23, totalShares has fallen but reserveNative still holds the coins being withdrawn. sharePrice() divides an unchanged numerator by a smaller denominator and reports a higher price than the pool can actually support.

LendingMarket.sol (illustrative)solidity
// A lending market that values pool shares as collateral.function collateralValue(address user) public view returns (uint256) {    uint256 price = pool.sharePrice();   // read during the attacker's callback    return (poolShares[user] * price) / 1e18;}

An attacker who holds pool shares as collateral in the lending market removes a large amount of liquidity, and while the pool is mid-call, borrows against the inflated collateral value. When the callback returns, the pool finishes its accounting, the price falls back, and the attacker’s position is undercollateralised. The lending market holds the loss.

The same shape elsewhere

Liquidity pools are the textbook case, but the shape recurs wherever a contract exposes a derived value and hands control to someone else before that value is consistent again.

  • Vault share prices. A vault that transfers assets out, triggering a token hook, before burning the corresponding shares will report an inflated convertToAssets during the hook. Any protocol that accepts the vault’s shares as collateral inherits the problem.
  • Token hooks. ERC-777 lets a sender’s registered hook run before balances move, and receiver hooks for ERC-721 and ERC-1155 run during transfers and mints. Code that updates supply, ownership counters or reward indexes around those calls must assume the hook can observe every intermediate state.
  • Reward and index accounting. Staking contracts that pay rewards before updating a reward-per-share index can expose a stale index to anyone who reads it during the payout, which matters when the index is used by another contract to price a receipt token.
  • Governance snapshots. Voting power read from a token whose balances are mid-update can be double counted if a callback lets the same tokens be observed in two places at once.

In each case the question is the same: at the moment control leaves the contract, which public answers are temporarily untrue, and who is listening for them?

Why the usual guard is not enough

A standard reentrancy guard sets a flag on entry to a protected function and reverts if another protected function is entered while the flag is set. View functions are not protected because a modifier that writes the flag cannot run in a view context, and because the conventional threat model treats reads as safe.

The integrator cannot rely on the guard either. Its call to sharePrice() succeeds, because nothing stops a view from being called while the lock is held. From the integrator’s point of view the pool answered normally.

Checks-effects-interactions, applied strictly, removes the problem at its source: if every effect is complete before any interaction, there is no window in which state is inconsistent. In practice, code bases often apply the pattern only to the variables that guard against classic reentrancy, and leave derived values out of step.

Defences on both sides

Both the protocol that exposes the value and the protocol that consumes it can close the gap. Ideally both do.

Two fixes for the poolsolidity
// Option 1: make the pool's own views refuse to answer mid-call.function sharePrice() external view returns (uint256) {    if (_reentrancyGuardEntered()) revert PoolLocked();    uint256 value = reserveNative + _tokenValueInNative(reserveToken);    return (value * 1e18) / totalShares;} // Option 2: remove the inconsistency by finishing all accounting first.function removeLiquidity(uint256 amount) external nonReentrant {    uint256 nativeOut = (reserveNative * amount) / totalShares;    uint256 tokenOut  = (reserveToken  * amount) / totalShares;     shares[msg.sender] -= amount;    totalShares        -= amount;    reserveToken       -= tokenOut;    reserveNative      -= nativeOut;  // effects complete before any interaction     (bool ok, ) = msg.sender.call{value: nativeOut}("");    require(ok, "send failed");    token.transfer(msg.sender, tokenOut);}

OpenZeppelin’s ReentrancyGuard exposes _reentrancyGuardEntered(), an internal view that returns true while a nonReentrant function is on the call stack. A pool can use it to make its own views revert during guarded calls. Integrators that cannot change the pool have fewer options, but they are not powerless.

WhoDefenceTrade-off
Value providerComplete all state updates before external calls (checks-effects-interactions across every variable a view reads).Requires discipline across the whole contract; easy to regress.
Value providerMake public views revert while the reentrancy lock is held.Integrators must handle the revert; some may break.
Value providerDocument which views are safe to consume on-chain, and when.Only helps integrators who read the documentation.
IntegratorBefore reading, call a cheap state-changing function on the provider that is protected by the same lock; if it reverts, the provider is mid-call.Costs gas, and depends on the provider exposing such a function.
IntegratorPrice collateral from an independent source and bound the provider’s value against it.Adds an oracle dependency; needs sensible deviation thresholds.
Defences against read-only reentrancy, by who controls the code

For integrators the most robust stance is to treat any value read from another contract as an input that can be wrong for the duration of a transaction, and to ask what happens if it is. If the answer is that collateral can be overvalued, the read needs a second opinion.

What reviewers check

When Quantix reviewers assess a protocol for this class of issue, the questions are concrete:

  • Which functions transfer control to an address that is not fully trusted, and at what point in their accounting?
  • For each such call, which storage variables have been updated and which have not?
  • Which public or external view functions read any of the variables that are out of step?
  • Are those views documented, or known, to be consumed by other protocols on-chain?
  • For integrations under review, which external views does the protocol read, and can the provider be mid-call when it does?

The last question matters most for lending markets, vaults and derivatives that accept other protocols’ tokens as collateral. The provider may have been audited, and correctly found safe for its own users. The integration still needs its own threat model.

Where a candidate issue is found, reviewers reproduce it before reporting it. A proof of concept for this class usually needs three contracts: the provider in a forked or simulated state, a small attacker contract whose receive hook calls the integrator, and the integrator itself. The test asserts that the value read inside the hook differs from the value read before and after the call. That single assertion turns a plausible concern into a demonstrable finding, and it later becomes the regression test that proves the fix.

Conclusions

Read-only reentrancy turns a harmless-looking view function into an attack surface for every protocol that reads it. The contract that exposes the inconsistent value is often safe for its own users, which is why the issue survives reviews scoped to a single code base.

The provider closes the gap by finishing its accounting before any external call, or by making its views refuse to answer while a guarded function is running. The consumer closes it by treating any value read from another contract as possibly wrong for the duration of the transaction, and bounding it against an independent source.

Recommendations

Value providers

  1. Apply checks-effects-interactions to every variable that a public view reads, not only to balances.
  2. Make public views revert while the reentrancy lock is held, or expose the lock status to integrators.
  3. Document which views are safe to consume on-chain, and under what conditions.

Integrators

  1. Before reading a derived value, confirm the provider is not mid-call, or bound the value against an independent price.
  2. Include the provider’s callback paths in your own threat model.

Reviewers

  1. For every external call, list the variables that are stale at that point and the views that read them.
  2. Reproduce candidate issues with a test that asserts the view differs inside the callback from before and after.

Bibliography

Primary sources: standards, official documentation, incident reports and papers.

  1. 1Security considerations: reentrancy(opens in a new tab)Solidity documentation
  2. 2ReentrancyGuard and _reentrancyGuardEntered()(opens in a new tab)OpenZeppelin Contracts 5.x API: Utils
  3. 3SWC-107: Reentrancy(opens in a new tab)Smart Contract Weakness Classification Registry
  4. 4EIP-777: Token Standard (hooks)(opens in a new tab)Ethereum Improvement Proposals

Applied on this site

Assessments, case studies and tools that apply this publication.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.