ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

Read-Only Reentrancy

A view function returns a stale or self-inconsistent value while the contract is mid-reentrancy. The function itself changes nothing, so a classic reentrancy guard on state-changing paths never trips — but an integrating protocol reads that lying value and makes a real decision on it.

Classification

Domain
Web3 & smart contracts
ROOTVIEL support
Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
Evidence scope
Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
Analysis family
Rootchain
CWE
CWE-841
OWASP
SC05

Definition

Read-only reentrancy is reentrancy whose damage lands through a function that only reads. During an external call, a contract can be in a transient, internally inconsistent state: some balances already updated, an accounting total not yet reconciled, a share price computed from a numerator and denominator that no longer agree. A view function queried at that exact moment returns a number that is arithmetically well-formed but semantically false. The vulnerable party is usually not the contract being re-entered — it is a second protocol that calls the view function and trusts the answer.

The defining feature, and the reason it evades the usual defenses, is that the exploited function has no state writes and therefore is never protected by a reentrancy guard. Developers reason "a view cannot be reentered, there is nothing to corrupt" — which is true of the view in isolation and false of the system it feeds.

Context

This class became prominent as DeFi protocols began composing: one contract exposes a price or share-value getter, and many others read it as an oracle. The getter is honest in steady state and deceptive for the duration of a callback. The attack surface is therefore not a single contract but the seam between a contract that can be transiently inconsistent and a contract that reads it synchronously.

  • ▸Pool contracts (AMMs, stable-swap style pools) that expose a spot price or virtual-price getter derived from live reserves.
  • ▸Share-based vaults whose pricePerShare reads totalAssets and totalSupply, either of which can be mid-update during a withdrawal callback.
  • ▸Lending markets and liquidation engines that pull a collateral valuation from such a getter at an arbitrary attacker-chosen instant.
Note

The poisoned value and the victim can live in different codebases and different teams. A contract can be fully "guarded" for its own writes and still be the source of a lying read that breaks a downstream integrator.

Solidity & EVM fundamentals

The EVM executes external calls synchronously and lets control transfer to the callee mid-transaction. When contract A calls address B — through call, or implicitly by sending native value to a contract, or via a token hook — B runs to completion before A continues. If A has already mutated some storage but not yet finished its bookkeeping, B (and anyone B calls) observes A in that half-written state.

Three EVM realities combine into this bug:

Synchronous reentry
An external call hands control to the callee immediately; the caller resumes only after the callee returns. The caller is "paused" in whatever state it had reached.
Value transfer is a call
Sending native value with call, or (historically) transfer/send, invokes the recipient’s receive/fallback. Any address paid out can run code before the payer finishes.
view is advisory, not atomic
The view/pure modifier only promises the function writes no state. It does not promise the values it reads are mutually consistent, and nonReentrant guards are almost never placed on view functions.

Checks-Effects-Interactions (CEI) is the canonical discipline: validate, then write all state, then make external calls last. A contract that follows CEI perfectly for its own safety can still expose a view that is read during the "interactions" window — CEI protects the contract’s own invariants after the call returns, not the snapshot a third party sees during the call.

Root cause

The root cause is a trust-in-synchronous-read placed across a reentrancy boundary. One contract computes a value from state that is only consistent at rest; another contract reads that value at a caller-chosen moment and treats the answer as authoritative. The defect is in the coupling, not necessarily in either contract alone.

Two independent design choices create it, and removing either blunts the attack: (1) the provider performs an external call (a payout, a token hook) while its reported value is derived from not-yet-reconciled state; (2) the consumer reads that value live, inside a transaction it does not control, instead of using a manipulation-resistant source or a guarded accessor.

How the attack works, step by step

  1. 1The attacker finds a provider whose view getter is derived from state that is transiently inconsistent during an external call — e.g. a share price computed from reserves that are updated before a payout completes.
  2. 2The attacker triggers an operation on the provider that performs an external call back to an attacker-controlled address — typically a withdrawal that returns native value, or a transfer that invokes a token hook.
  3. 3Inside that callback, with the provider frozen mid-update, the attacker calls (directly or via a victim entrypoint) the provider’s view getter. It returns a value that is arithmetically valid but reflects the half-finished state.
  4. 4A consumer protocol reads this poisoned value as its price/collateral oracle and acts on it: minting too many shares, allowing an over-sized borrow, or letting a liquidation through at a wrong valuation.
  5. 5The callback returns, the provider finishes its update and looks perfectly consistent afterward. The damage has already been committed in the consumer, and no reentrancy guard on any state-changing path ever fired.

Vulnerable example

A synthetic, illustrative pool. The withdraw path updates reserves, pays the user with a native-value call (which can re-enter), and only then clears accounting. The getPrice view reads reserves live, so during the payout callback it reports a price computed from a reserve already debited but not yet reconciled.

synthetic — do not use; illustrates the flaw
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: price getter is readable mid-payout and reflects a transient state. contract VulnerablePool { uint256 public reserveToken; // units of the asset in the pool uint256 public reserveValue; // value backing those units mapping(address => uint256) public shares; // A spot "oracle" other protocols read. No guard; it is only a view. function getPrice() external view returns (uint256) { if (reserveToken == 0) return 0; return (reserveValue * 1e18) / reserveToken; // value per token } function withdraw(uint256 amount) external { require(shares[msg.sender] >= amount, "insufficient"); // Effect #1: debit token reserve and the user's shares... reserveToken -= amount; shares[msg.sender] -= amount; // Interaction: pay out BEFORE reserveValue is reconciled. // The recipient can re-enter and read getPrice() right here. (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "pay failed"); // Effect #2: reconcile value AFTER the external call (too late). reserveValue -= amount; // during the callback above, this had NOT run } }
Note

During the callback, reserveToken is already reduced but reserveValue is not, so getPrice() returns an inflated value-per-token. A guard on withdraw would stop the pool being drained via withdraw, but it would not stop a read-only reentrant query of getPrice() if the guard is not also exposed to readers.

Impact

  • ▸Integrity of valuation: any consumer using the getter as a price/collateral oracle acts on a number that is false for the callback window — mispriced mints, borrows, redemptions or liquidations.
  • ▸Confused-deputy composition: the harm materializes in a protocol that did nothing wrong except trust a synchronous read of a composable contract.
  • ▸Guard evasion: because the exploited function writes nothing, standard nonReentrant guards and audits focused on state-changing paths can miss it entirely.
  • ▸Detection difficulty: the provider’s post-transaction state is consistent, so forensic review of final balances shows nothing anomalous in the provider itself.
Note

Honesty: the existence of a transiently inconsistent getter plus a reentrant payout is a SIGNAL that read-only reentrancy may be reachable. It becomes a FINDING only when a concrete consumer is shown to read the poisoned value and take a state-changing decision on it, demonstrated in a controlled run.

Detection: static, dynamic, symbolic, fuzzing, formal

Static
Flag view/pure getters whose returned value derives from storage that is also written on a path containing an external call, where state is not fully reconciled before that call (CEI violated for the getter’s inputs). This is a heuristic signal, not proof.
Dynamic
In a controlled environment, deploy an attacker contract that re-enters during the payout callback and records getPrice() inside the callback vs at rest. A measured divergence is evidence the getter can be poisoned.
Symbolic
Use symbolic execution to ask whether a path exists in which the getter’s inputs are read between a state write and its reconciling write, with an attacker-reachable external call in between.
Fuzzing / invariant
Assert an invariant such as "getPrice() observed from within any external callback equals getPrice() at rest" and let a fuzzer (e.g. Echidna/Foundry) search for a reentrant sequence that breaks it.
Formal
State and prove a property that the getter is a pure function of reconciled state — i.e. that no reachable state exposes a value computed from partially-updated storage. A proof here is strong; absence of a counterexample from a bounded tool is not a proof.

Keep the epistemic status explicit: a scanner match is a heuristic SIGNAL; a divergence observed in a lab run is EVIDENCE for a reproducible path; a formally proven invariant is the only thing that approaches a guarantee, and only within its stated model. A clean fuzzing run means the search found nothing in its budget — UNKNOWN, not "safe".

How autonomous agents investigate it

An autonomous investigator treats read-only reentrancy as a reachability question connecting two facts: "can control return to an attacker while the provider is mid-update?" and "does any consumer read a getter derived from that mid-update state?" It proceeds along an epistemic ladder rather than declaring a verdict from pattern-matching.

  1. 1Observation: inventory view/pure getters and the storage they read; inventory every external call (native value transfers, token hooks) and the storage written around each.
  2. 2Hypothesis: form the candidate "getter G is derived from storage that is inconsistent during callback C"; mark it a lead, not a conclusion.
  3. 3Execution paths: build the concrete call sequence attacker -> provider.withdraw -> callback -> G, on a disposable local chain or a local fork, with an attacker contract that re-enters.
  4. 4Fuzzing / invariant checks: encode "G during any callback == G at rest" and search for a counterexample; encode a consumer invariant (e.g. "shares minted match value deposited") and search for a reentrant violation.
  5. 5Adversarial review: deliberately try to refute the lead — is the getter actually reachable during the callback? does any real consumer read it synchronously? If reconciliation happens before the call, the hypothesis dies and is recorded as refuted.
Note

An agent that only "reasons" that a getter looks poisonable has a hypothesis. A counterexample sequence produced by a tool is a tool-generated artifact that still must be replayed and checked. Only a replayed, deterministically confirmed divergence-plus-consumer-effect is a finding. Plausibility from an LLM is never, by itself, truth.

How Rootchain analyzes it

Rootchain is evidence-first. A scanner heuristic or an LLM’s suspicion that a getter is poisonable is treated as a lead, never as a result. Rootchain promotes a lead to a Finding only after proving it: it reconstructs the attack in a controlled environment — a disposable LOCAL blockchain, or a LOCAL fork of real on-chain state — and replays the reentrant sequence. There is no mode that attacks a live public network.

Epistemic ladder
Observation → Hypothesis → Experiment → Evidence → Verification → Finding. For read-only reentrancy, Evidence is a measured divergence between the getter read inside a callback and at rest, plus a consumer taking a decision on the poisoned value.
AI composes, it never decides
The model proposes candidate attacker contracts and call orderings. Whether the attack actually succeeded is decided by a deterministic, AI-free verifier that compares the observed getter values and the consumer’s resulting state against the expected-safe baseline.
Signal vs Finding
A static match or a fuzzer flag is a Signal recorded with its provenance. A Finding requires the controlled, reproducible demonstration confirmed by the verifier.
Partial

Reentrancy is one of the more mature execute-and-verify families, but maturity is never total. If the only path Rootchain can build depends on an assumption it cannot realize in the controlled environment (e.g. a consumer it cannot instantiate), it reports UNKNOWN — not "secure". Rootchain never calls a contract safe.

Mitigation

Fix it on both sides of the seam, and put the load-bearing control in deterministic code rather than in a comment that warns readers to be careful.

  • ▸Provider side: follow Checks-Effects-Interactions so that all state a getter depends on is fully reconciled before any external call — the getter should never be derived from half-written storage.
  • ▸Provider side: expose a guarded read path. A common pattern is a reentrancy-aware accessor that reverts if called while a nonReentrant section is active, so a getter cannot be read mid-operation. Equivalently, compute cached values at rest and serve those.
  • ▸Consumer side: do not read a spot getter synchronously as an oracle. Prefer manipulation-resistant sources (time-weighted averages, or a value that cannot be moved within a single transaction) and sanity-bound inputs.
  • ▸Reduce reentrancy surface: prefer pull-payment patterns and well-understood transfer semantics; minimize code that runs during value transfer.

Corrected example

The corrected pool reconciles all accounting before paying out (true CEI), and additionally exposes a reentrancy status so a getter can refuse to answer during an active operation. Why it works: once both reserve fields are updated before the external call, there is no window in which the getter is derived from inconsistent state; the guarded read makes mid-operation queries fail loudly rather than return a lie.

synthetic — illustrates the fix and its limits
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferPool { uint256 public reserveToken; uint256 public reserveValue; mapping(address => uint256) public shares; // Minimal reentrancy status, also readable by getters. uint256 private _status; // 1 = idle, 2 = entered modifier nonReentrant() { require(_status != 2, "reentrant"); _status = 2; _; _status = 1; } // Getter refuses to answer while an operation is in flight. function getPrice() external view returns (uint256) { require(_status != 2, "price unavailable mid-op"); if (reserveToken == 0) return 0; return (reserveValue * 1e18) / reserveToken; } function withdraw(uint256 amount) external nonReentrant { require(shares[msg.sender] >= amount, "insufficient"); // Effects FIRST: reconcile ALL accounting before any external call. reserveToken -= amount; reserveValue -= amount; shares[msg.sender] -= amount; // Interaction LAST: even if the recipient re-enters, state is // already consistent AND getPrice() reverts while _status == 2. (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "pay failed"); } }
Note

Limits of the fix: this protects callers of SaferPool.getPrice(). It does not protect a consumer that derives a price from SaferPool via some other live-reserve path, nor does it address economic manipulation of the price over multiple transactions. CEI plus a guarded getter removes the transient-inconsistency window; it does not make a spot price a sound oracle.

Verifying the fix

Verify by trying to break the fix in a controlled environment and failing, with reproducible tests — conceptually, Foundry for scripted scenarios and Echidna for property search.

  1. 1Reproduction test (must now fail to exploit): deploy the attacker contract that re-enters during withdraw and reads getPrice() inside the callback; assert the call reverts ("price unavailable mid-op") rather than returning a poisoned value.
  2. 2Property test: assert the invariant getPrice()-during-callback is never observable as a successful, divergent read — encode it so any reentrant sequence that returns a value mid-op is a counterexample.
  3. 3Consumer invariant (Echidna-style): wire a minimal consumer that mints against getPrice() and fuzz for a reentrant sequence producing more shares than value deposited; the property should hold across the search budget.
  4. 4Baseline check: confirm at-rest getPrice() still returns the correct value, so the guard did not simply break normal reads.
Note

Report results honestly: "the reproduction no longer succeeds and the property held within the tested budget" is a reproduced-defense result, not a proof of immunity. A clean Echidna run is UNKNOWN beyond its search space; only a formal proof of the reconciled-state property approaches a guarantee, within its model.

Limitations

  • ▸Cross-codebase blind spots: the provider can be fixed while an unknown downstream consumer still reads a different live-derived value; Rootchain can only prove what it can instantiate in the controlled environment.
  • ▸A guarded getter stops mid-operation reads but does not address multi-transaction economic manipulation of the underlying value — a separate class.
  • ▸Fuzzing and symbolic search are bounded; absence of a counterexample is UNKNOWN, not safety.
  • ▸Static heuristics over-flag getters that happen to touch written storage but are actually reconciled before any external call — human and experimental confirmation is required.

References

  1. [1]SWC-107 — Reentrancy (SWC Registry)
  2. [2]Solidity docs — Security Considerations (reentrancy, use-the-checks-effects-interactions-pattern)
  3. [3]OWASP Smart Contract Top 10 — SC05 Reentrancy Attacks
  4. [4]MITRE CWE-841 — Improper Enforcement of Behavioral Workflow
  5. [5]Foundry Book (forge test / fork testing)
  6. [6]Echidna — property-based fuzzer for Ethereum

References point to public, well-known technical sources for further study.

Related articles