Reentrancy attacks have haunted smart contracts since the infamous 2016 DAO hack, which drained roughly $60 million worth of Ether. Since then, developers have learned to guard state-modifying functions with reentrancy locks. But a subtler variant has emerged - one that targets view functions and exploits inconsistent contract state to manipulate the data other protocols depend on.

This variant is known as read-only reentrancy, and it represents one of the most underestimated threats in DeFi security today.

What Is Read-Only Reentrancy?

Read-only reentrancy is a reentrancy scenario where a view function is reentered during an external call. Because view functions do not modify the contract's state, they are typically left unguarded by reentrancy locks. However, if the contract's state is inconsistent at the moment the view function is called - for example, midway through a withdrawal where balances have not yet been updated - the function can return incorrect values.

Unlike traditional reentrancy, where the attacker directly manipulates state variables to steal funds or corrupt the contract, read-only reentrancy targets contracts during read operations. The attacker does not need to re-enter a state-changing function. Instead, they exploit the gap between what a view function reports and what the contract's actual state should reflect once the current transaction completes.

Key Distinction

Traditional reentrancy re-enters state-modifying functions to drain funds directly. Read-only reentrancy re-enters view functions to produce misleading data, which is then consumed by other protocols to the attacker's advantage.

Why View Functions Are Dangerous

Solidity's view and pure function modifiers signal that a function does not alter blockchain state. This has led to a widespread assumption that these functions are inherently safe - that because they only read data, they cannot be exploited.

This assumption is wrong.

Consider a vault contract that tracks totalTokens and totalStake. It exposes a getCurrentPrice() view function that calculates the token price as:

Solidity VulnerableVault.sol
function getCurrentPrice() public view returns (uint256) {
    return totalTokens * 1e18 / totalStake;
}

During a normal withdraw() call, the contract sends ETH to the user via a low-level call before updating totalTokens. If an attacker triggers a fallback during that ETH transfer and calls getCurrentPrice(), the function will return an inflated value because totalTokens has not yet been decremented.

Any protocol reading that price during this window - an oracle, a lending market, a DEX - would receive manipulated data.

How the Attack Works

The read-only reentrancy attack follows a specific sequence of steps. Understanding this flow is critical for both auditors and developers.

Attack Flow

  1. Setup: The attacker deploys a malicious contract and deposits funds into a target vault (the "reentrant contract") that other DeFi protocols rely on for pricing or state data.
  2. Trigger: The attacker calls withdraw() on the reentrant contract. The contract sends ETH via a low-level call before updating its internal state.
  3. Callback: The ETH transfer triggers the attacker contract's receive() or fallback() function. At this point, the reentrant contract's state is inconsistent - funds have been sent, but internal accounting (like totalTokens) has not been updated.
  4. Exploitation: Inside the callback, the attacker calls a victim protocol that reads pricing data from the reentrant contract's view function. The victim protocol receives manipulated values and executes operations (deposits, borrows, swaps) based on incorrect prices.
  5. Profit: The attacker completes the transaction, having extracted value from the victim protocol through price manipulation.

Vulnerable Contract Example

Below is a simplified example demonstrating how a vulnerable vault contract might look. Notice that withdraw() sends ETH before updating internal state, and getCurrentPrice() is not protected by any reentrancy guard:

Solidity VulnerableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract VulnerableVault is ReentrancyGuard {
    uint256 public totalTokens;
    uint256 public totalStake;
    mapping(address => uint256) public balances;

    function deposit() external payable nonReentrant {
        uint256 tokens = msg.value * getCurrentPrice() / 1e18;
        balances[msg.sender] += tokens;
        totalTokens += tokens;
        totalStake += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        uint256 ethAmount = amount * 1e18 / getCurrentPrice();

        // External call BEFORE state update - vulnerable!
        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "Transfer failed");

        // State updated AFTER the external call
        balances[msg.sender] -= amount;
        totalTokens -= amount;
        totalStake -= ethAmount;
    }

    // View function - no reentrancy guard!
    function getCurrentPrice() public view returns (uint256) {
        if (totalStake == 0) return 1e18;
        return totalTokens * 1e18 / totalStake;
    }
}

The withdraw() function is protected by nonReentrant, which prevents direct re-entry into state-changing functions. But getCurrentPrice() is a view function and is not guarded. During the external call in withdraw(), any contract that reads getCurrentPrice() will get a stale, manipulated value.

The Attacker Contract

The attacker contract exploits this window by depositing into both the vulnerable vault and a victim protocol, then triggering the read-only reentrancy during withdrawal:

Solidity Attacker.sol
contract Attacker {
    VulnerableVault public vault;
    VictimProtocol public victim;

    constructor(address _vault, address _victim) {
        vault = VulnerableVault(_vault);
        victim = VictimProtocol(_victim);
    }

    function attack() external payable {
        // Step 1: Deposit into the vault
        vault.deposit{value: msg.value}();

        // Step 2: Withdraw to trigger reentrancy
        vault.withdraw(vault.balances(address(this)));
    }

    // Step 3: Callback during withdrawal
    receive() external payable {
        // vault.getCurrentPrice() returns inflated value here
        // because totalTokens hasn't been decremented yet
        victim.deposit{value: 1 ether}();
    }
}

When the vault sends ETH to the attacker, the receive() function fires. At this point, the vault's totalTokens has not been reduced, so getCurrentPrice() reports an inflated price. The victim protocol, which relies on this price, accepts the deposit at an artificially favorable rate.

Real-World Incident: The Sentiment Hack

On April 4, 2023, the Sentiment platform - a lending protocol deployed on Arbitrum - was attacked using read-only reentrancy. The attacker exploited the platform's integration with Balancer pools to manipulate asset prices and extracted approximately $1 million in stolen funds.

How Sentiment Was Exploited

The attack unfolded as follows:

  1. The attacker obtained a flash loan from Aave v3, borrowing BTC, ETH, and USDC.
  2. They opened an account on Sentiment and deposited 50 WETH, selecting a Balancer B-33WETH-33WBTC-33USDC pool as their asset.
  3. Using joinPool(), the attacker deposited the entire flash loan amount into the Balancer pool, dramatically increasing the pool's total value.
  4. They then called exitPool() to withdraw. During the withdrawal, while Balancer's internal state was inconsistent, Sentiment's price oracle read the Balancer vault's getPrice() function.
  5. Because the Balancer pool's state had not fully settled, the oracle returned an inflated price, allowing the attacker to borrow against an artificially high collateral value on Sentiment.
  6. The attacker withdrew the borrowed funds and repaid the flash loan, keeping the profit.
Impact

The Sentiment team ultimately recovered roughly 90% of the stolen funds through negotiation with the attacker. However, the incident underscored how dangerous it is to rely on view functions from external protocols without verifying state consistency.

Why Standard Reentrancy Guards Fall Short

Most developers are familiar with OpenZeppelin's ReentrancyGuard and its nonReentrant modifier. This pattern is effective at preventing re-entry into state-modifying functions - but it does nothing for view functions.

The reason is straightforward: reentrancy guards work by setting a lock flag before execution and clearing it afterward. View functions, by definition, do not modify state, so they cannot set or check this flag within the EVM's execution model. They can still be called freely even while the lock is engaged.

This means that a contract can be fully protected against traditional reentrancy while remaining completely vulnerable to read-only reentrancy. The nonReentrant modifier on withdraw() prevents an attacker from calling withdraw() again, but it does not prevent them from calling getCurrentPrice() - or any other contract from doing so.

Mitigation Strategies

The Checks-Effects-Interactions Pattern

The most fundamental defense is to follow the Checks-Effects-Interactions (CEI) pattern rigorously. This means:

  1. Checks: Validate all conditions and requirements first.
  2. Effects: Update all internal state variables.
  3. Interactions: Only then perform external calls (sending ETH, calling other contracts).

If the vulnerable vault from our earlier example followed CEI, the state would be updated before the external call, and getCurrentPrice() would return accurate values at every point during execution:

Solidity SecureVault.sol
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    uint256 ethAmount = amount * 1e18 / getCurrentPrice();

    // Effects FIRST - update state before external call
    balances[msg.sender] -= amount;
    totalTokens -= amount;
    totalStake -= ethAmount;

    // Interactions LAST - external call after state is consistent
    (bool success, ) = msg.sender.call{value: ethAmount}("");
    require(success, "Transfer failed");
}

Read-Only Reentrancy Guard

For contracts where reordering operations is not feasible, a dedicated read-only reentrancy guard can be implemented. The idea is to check whether the standard reentrancy lock is currently engaged before allowing view functions to return data:

Solidity ReadOnlyGuard.sol
contract ReadOnlyGuard is ReentrancyGuard {

    modifier noReadReentrancy() {
        require(!_reentrancyGuardEntered(), "Read-only reentrancy");
        _;
    }

    function getCurrentPrice() public view noReadReentrancy returns (uint256) {
        if (totalStake == 0) return 1e18;
        return totalTokens * 1e18 / totalStake;
    }
}

This approach works because OpenZeppelin's ReentrancyGuard (since v4.9) exposes _reentrancyGuardEntered(), which returns true while a nonReentrant function is executing. By checking this in view functions, you can revert any calls made during an inconsistent state window.

Note

For existing contracts that cannot be upgraded, consuming protocols should implement their own checks. Before reading data from an external contract, verify that the external contract's reentrancy guard is not currently locked.

External Price Oracles

Read-only reentrancy is frequently exploited to manipulate the perceived value of tokens. One of the most effective defenses is to avoid deriving prices from on-chain state that can become inconsistent.

Using trusted external price oracles - such as Chainlink, Pyth, or Redstone - eliminates the attack vector entirely. These oracles derive prices from off-chain data aggregation and are not susceptible to intra-transaction state manipulation.

  • Chainlink Price Feeds: Widely adopted, decentralized oracle network with broad asset coverage
  • Time-Weighted Average Prices (TWAPs): Use historical price averages that resist single-block manipulation
  • Multiple oracle sources: Cross-reference prices from independent sources to detect anomalies

Pull-Over-Push Payments

Another mitigation is to avoid sending ETH directly via low-level calls during state-changing operations. Instead, use a pull-based withdrawal pattern where users claim their funds in a separate transaction. This eliminates the callback opportunity that makes read-only reentrancy possible.

Detecting the Vulnerability

Identifying read-only reentrancy during audits requires attention to specific patterns:

  • External calls before state updates: Any function that performs an external call (sending ETH, calling another contract) before updating internal variables is a potential source of inconsistent state.
  • Unguarded view functions: View functions that compute values from state variables (prices, ratios, balances) and are exposed publicly are potential targets.
  • Cross-protocol dependencies: Protocols that read state from other contracts for pricing, collateral valuation, or access control decisions are vulnerable consumers.
  • Composability chains: In DeFi, protocols are stacked on top of each other. A vulnerability in one layer can cascade through the entire stack.
Auditor Checklist

When reviewing contracts, ask: "If a view function is called during an external call in a state-changing function, will it return correct values?" If the answer is no, the contract may be vulnerable to read-only reentrancy.

Broader Implications for DeFi

Read-only reentrancy is particularly dangerous in DeFi because of composability. Protocols do not exist in isolation - they read data from each other, use each other as collateral sources, and stack yield strategies across multiple layers.

A single vulnerable view function in a widely-used protocol (such as Balancer, Curve, or Aave) can expose every protocol that consumes its data. This creates a systemic risk that extends far beyond the vulnerable contract itself.

The Sentiment hack demonstrated this clearly: Sentiment's own code had no reentrancy bugs. The vulnerability existed in how Sentiment's oracle consumed Balancer's view functions during inconsistent state. The fix had to come from both sides - Balancer needed to guard its view functions, and consuming protocols needed to verify state consistency before trusting external data.

Conclusion

Read-only reentrancy is a reminder that security in smart contracts extends beyond protecting state-changing functions. View functions - commonly assumed to be harmless - can become powerful attack vectors when they expose inconsistent state during execution.

The vulnerability primarily hinges on the order and timing of function calls, and manipulations can lead to reporting incorrect values that ripple through interconnected DeFi protocols.

Defending against this threat requires a multi-layered approach:

  • Follow the CEI pattern to ensure state consistency before external calls
  • Implement read-only reentrancy guards on sensitive view functions
  • Use external oracles rather than deriving prices from on-chain state
  • Apply pull-over-push payment patterns to eliminate callback opportunities
  • Audit cross-protocol integrations - the vulnerability may not be in your code, but in how you consume external data

At Zokyo, we believe that continuous learning, vigilance, and innovation are essential as blockchain technology advances and new attack vectors emerge. A comprehensive smart contract audit that specifically tests for read-only reentrancy - alongside traditional reentrancy and other vulnerability classes - remains the most effective way to protect your protocol and its users.