← Research

Read-only reentrancy: the guard that wasn't there

Most teams treat reentrancy as solved. They add a nonReentrant modifier to the functions that move funds, the tests pass, and the box is ticked. Read-only reentrancy is what walks through the gap that box leaves open. It does not need to re-enter the contract that holds the lock. It only needs to read that contract while it is mid-operation, and to have someone else trust what it reads.

The harm rarely lands on the contract with the bug. It lands on a second protocol that uses the first one's view function as a source of truth, at the one moment that truth is false.

What the standard guard actually protects

A reentrancy guard sets a flag on entry and clears it on exit, and reverts if a function runs while the flag is set. Applied to the state-changing functions, it stops the classic attack where an external call lets the attacker call back in and withdraw twice. What it does not do is govern reads. View functions carry no guard, because they change nothing, so they remain callable at every instant, including the instant your contract is paused mid-update with an external call in flight.

That instant exists because of how value leaves a contract. To send tokens or ETH, the contract makes an external call, and a well-behaved contract follows checks-effects-interactions and finalizes its state first. Plenty do not, and even some that do still expose a window where one part of their accounting is updated and another is not.

The shape of the attack

Consider a pool that lets a user remove liquidity. Simplified, the withdrawal path does this:

function removeLiquidity(uint256 shares) external nonReentrant {
    uint256 amount = _amountFor(shares);
    _burn(msg.sender, shares);          // supply decreases now
    token.transfer(msg.sender, amount); // external call — control leaves here
    _refreshVirtualPrice();             // internal price updated AFTER the transfer
}

During token.transfer, control passes to the recipient. The pool's share supply has already dropped, but its cached price has not yet been refreshed, so any view that derives a price from those two values is temporarily wrong. The attacker is the recipient, and inside that callback they do not re-enter the pool. They call a lending protocol that prices this pool's LP token using pool.getVirtualPrice():

  • The lending protocol reads the pool's price view, which is inflated or deflated by the half-finished withdrawal.
  • The attacker borrows against the LP token at the wrong valuation, taking out more than it is worth.
  • The callback returns, the pool finishes its update, and the price snaps back. The loan, made at the bad number, stands.

The pool's own guard did its job. It was never re-entered. The lending protocol had no guard to apply, because it was only reading.

The rule

If a value can be read while your contract is mid-operation, treat the read as part of the attack surface. State that other protocols price against must never be observable in a half-updated form.

Two obligations, on two sides:

  • If you expose state others rely on: finish every state update before making external calls, so checks-effects-interactions holds with no exceptions, and extend the reentrancy lock to the read path. A read-only guard reverts a view that is called while the contract is locked, so no one can observe the intermediate state at all.
  • If you consume another protocol's view as a price: do not assume it is always consistent. Where the design allows, read it in a way that cannot be interleaved with that protocol's own operations, or prefer a source with explicit manipulation resistance.

How we test it

We build a malicious receiver and, from inside its callback, call every externally consumed view on the contract under test. The assertion is that the view either reverts under the lock or returns a value consistent with the not-yet-finished operation:

// inside the attacker's token callback, mid-withdrawal:
function onTokenReceived() external {
    uint256 p = pool.getVirtualPrice();
    // must equal the pre-operation price, or revert — never a transient value
    assert(p == priceBeforeWithdrawal);
}

We also map who reads the contract's views in production, because the test that matters is not "is this contract safe alone" but "is anything that trusts this contract safe when it is mid-flight."

Why it hides

Read-only reentrancy hides because the contract that contains the flaw passes every test you would write for it. It has a reentrancy guard. It is never re-entered. Reviewed on its own, it is clean. The damage is one boundary away, in a different protocol that read a getter at the wrong moment, and you only see it when you stop reviewing a contract in isolation and ask who is reading its state, and exactly when.

:: dr-note-009 ::

If your protocol exposes prices or balances that other systems consume, the read path deserves the same scrutiny as the write path. Related: how price oracle manipulation drains a lending market.