DEV Community

ohmygod
ohmygod

Posted on

Read-Only Reentrancy Is Still Draining DeFi in 2026: A Defense Playbook for Protocol Developers

The Vulnerability That Won't Die

The OWASP Smart Contract Top 10 for 2026 moved reentrancy from #3 down to #8. Some took this as a sign the problem was solved.

Then January 2026 happened: $86 million drained across DeFi protocols, with reentrancy — particularly read-only variants — playing a role in several exploits. The GMX V1 exploit on Arbitrum in July 2025 had already demonstrated the pattern: manipulate a price mid-transaction via reentrancy, extract $42M from liquidity pools.

The uncomfortable truth: we haven't solved reentrancy. We've just made the exploitable version subtler.

What Makes Read-Only Reentrancy Different

Traditional reentrancy is straightforward: function A calls external contract, external contract re-enters function A before state is updated. The defense is well-known: Checks-Effects-Interactions (CEI), reentrancy guards.

Read-only reentrancy exploits view functions. These functions don't modify state, so developers assume they're safe. But during a transaction, a view function can return inconsistent intermediate values — values that reflect a half-updated state.

Here's the attack flow:

1. Attacker calls Protocol A's withdraw()
2. withdraw() sends ETH via .call{value: amount}("")
3. Attacker's fallback receives ETH, calls Protocol B
4. Protocol B calls Protocol A's getPrice() [view function]
5. getPrice() reads Protocol A's state — which hasn't been updated yet
6. Protocol B uses the stale/inflated price to give attacker extra value
7. Control returns to Protocol A's withdraw(), state finally updates
Enter fullscreen mode Exit fullscreen mode

The critical insight: Protocol A's view function returns truthful data — it's just truthful about an intermediate state that shouldn't be externally visible.

Pattern 1: The Reentrancy Guard for View Functions

Most developers put nonReentrant on state-modifying functions and call it done. That's insufficient.

// ❌ Common pattern — view function unprotected
contract VulnerableVault {
    uint256 private _totalAssets;
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "ReentrancyGuard: reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    function withdraw(uint256 shares) external nonReentrant {
        uint256 assets = convertToAssets(shares);
        _totalAssets -= assets;
        // External call BEFORE totalSupply burn
        (bool ok, ) = msg.sender.call{value: assets}("");
        require(ok);
        _burn(msg.sender, shares);
    }

    // This view function is callable during withdraw's external call
    function convertToAssets(uint256 shares) public view returns (uint256) {
        return (shares * _totalAssets) / totalSupply();
    }
}
Enter fullscreen mode Exit fullscreen mode
// ✅ Protected pattern — view function checks reentrancy state
contract SecureVault {
    uint256 private _totalAssets;
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    uint256 private _status = _NOT_ENTERED;

    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED;
        _;
        _status = _NOT_ENTERED;
    }

    // View function that reverts during reentrancy
    modifier nonReentrantView() {
        require(_status != _ENTERED, "ReentrancyGuard: view during reentrant call");
        _;
    }

    function withdraw(uint256 shares) external nonReentrant {
        uint256 assets = convertToAssets(shares);
        _totalAssets -= assets;
        _burn(msg.sender, shares);
        // External call AFTER state updates (CEI)
        (bool ok, ) = msg.sender.call{value: assets}("");
        require(ok);
    }

    function convertToAssets(uint256 shares) public view nonReentrantView returns (uint256) {
        return (shares * _totalAssets) / totalSupply();
    }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The _status variable is a storage slot. View functions can read it. If a state-modifying function sets _status = _ENTERED and then makes an external call, any view function checking that slot will correctly revert.

Pattern 2: Snapshot-Based Price Feeds

For protocols that expose pricing data to other protocols, snapshot isolation prevents mid-transaction manipulation:

contract SnapshotPriceVault {
    struct PriceSnapshot {
        uint256 price;
        uint256 blockNumber;
    }

    PriceSnapshot private _lastSnapshot;

    function _updateSnapshot() internal {
        if (_lastSnapshot.blockNumber < block.number) {
            _lastSnapshot = PriceSnapshot({
                price: _calculateCurrentPrice(),
                blockNumber: block.number
            });
        }
    }

    // External consumers get the snapshot, not live calculation
    function getPrice() external view returns (uint256) {
        return _lastSnapshot.price;
    }

    function deposit(uint256 amount) external {
        _updateSnapshot();
        // ... deposit logic
    }

    function withdraw(uint256 shares) external {
        _updateSnapshot();
        // ... withdraw logic
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that getPrice() always returns a block-boundary consistent value, never a mid-transaction intermediate.

Pattern 3: Cross-Protocol Integration Guards

If your protocol reads from another protocol's view functions, you need to validate:

contract SafeIntegration {
    IExternalVault public immutable vault;

    function _validateExternalState() internal view {
        try vault.previewDeposit(0) returns (uint256) {
            // Safe — vault is not in reentrant state
        } catch {
            revert("External vault in inconsistent state");
        }
    }

    function _getValidatedPrice() internal view returns (uint256) {
        uint256 vaultPrice = vault.getPrice();
        uint256 oraclePrice = chainlinkOracle.latestAnswer();

        uint256 deviation = vaultPrice > oraclePrice 
            ? ((vaultPrice - oraclePrice) * 10000) / oraclePrice
            : ((oraclePrice - vaultPrice) * 10000) / oraclePrice;

        require(deviation < 500, "Price deviation too high");
        return vaultPrice;
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: The Transient Storage Solution (EIP-1153)

With EIP-1153 now widely available post-Dencun, reentrancy guards become cheaper:

contract TransientGuardVault {
    bytes32 constant REENTRANCY_SLOT = keccak256("reentrancy.guard");

    modifier nonReentrantTransient() {
        assembly {
            if tload(REENTRANCY_SLOT) {
                mstore(0, 0x08c379a0)
                revert(0, 4)
            }
            tstore(REENTRANCY_SLOT, 1)
        }
        _;
        assembly {
            tstore(REENTRANCY_SLOT, 0)
        }
    }

    modifier viewGuardTransient() {
        assembly {
            if tload(REENTRANCY_SLOT) {
                mstore(0, 0x08c379a0)
                revert(0, 4)
            }
        }
        _;
    }

    // Gas cost: ~100 gas vs ~5000 for SSTORE-based guards
    function withdraw(uint256 shares) external nonReentrantTransient { }
    function getPrice() external view viewGuardTransient returns (uint256) { }
}
Enter fullscreen mode Exit fullscreen mode

Warning: Transient storage resets at transaction end, not call end — an advantage for cross-contract detection, but test thoroughly.

The Audit Checklist

Before your next deployment, verify:

  • View function exposure — Do any view functions return values derived from mutable state that changes during external calls?
  • CEI compliance — Are ALL state updates completed before ANY external calls?
  • Guard coverage — Are reentrancy guards on view functions that other protocols depend on?
  • Cross-protocol reads — Do you validate external view function data against independent oracles?
  • Transient storage — If using EIP-1153 guards, have you tested cross-contract scenarios?
  • Callback patterns — Do ERC-777, ERC-1155, or flash loan callbacks create intermediate states visible to view functions?

The Uncomfortable Conclusion

Read-only reentrancy isn't a Solidity bug. It's a composability hazard — an emergent property of protocols reading each other's intermediate states. No single protocol is "wrong." The vulnerability exists in the gap between them.

As protocols become more interconnected — restaking layers reading from liquid staking derivatives reading from lending markets reading from AMM pools — the attack surface for read-only reentrancy only grows.

Protect your view functions. Validate your external reads. And stop assuming that view means safe.


This article is part of a series on DeFi security patterns. Follow for more on smart contract security, vulnerability analysis, and audit tooling.

Top comments (0)