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
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();
}
}
// ✅ 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();
}
}
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
}
}
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;
}
}
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) { }
}
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)