Traditional reentrancy has a signature that every auditor can spot — a state change after an external call. But read-only reentrancy hides in plain sight: it targets view functions that return stale data during an ongoing callback, poisoning every protocol that reads from them. In Q1 2026 alone, at least $47M in losses trace back to price feeds that were technically "correct" — just queried at exactly the wrong moment.
This article dissects the mechanics, shows you how to detect it in your own codebase, and provides battle-tested defense patterns that work at scale.
The Anatomy of Read-Only Reentrancy
Why Traditional Guards Don't Catch It
A standard nonReentrant modifier protects state-modifying functions. But view functions — getVirtualPrice(), totalAssets(), getRate() — are left unguarded because they "don't change state." The assumption: reading data is always safe.
That assumption is wrong.
When Protocol A makes an external call (e.g., transferring ETH via a callback), execution briefly returns to the caller. During that callback window, Protocol A's internal balances are inconsistent — some state has been updated, some hasn't. If Protocol B calls Protocol A's view function during this window, it gets a snapshot of an incomplete state transition.
Timeline of a Read-Only Reentrancy Attack:
1. Attacker calls Protocol A's withdraw()
2. Protocol A updates internal accounting (partial)
3. Protocol A sends ETH to attacker → triggers fallback
4. In fallback, attacker calls Protocol B
5. Protocol B reads Protocol A's getVirtualPrice() → STALE VALUE
6. Protocol B executes trade/borrow based on inflated price
7. Protocol A's withdraw() completes, price normalizes
8. Attacker profits from the price discrepancy
The Composability Trap
This isn't just a bug in one protocol — it's a systemic design flaw in DeFi composability. Every protocol that uses another protocol's view function as a price oracle is potentially exposed. The vulnerable protocol might be perfectly secure in isolation. The exploit only manifests in the interaction between protocols.
Real examples of dangerous price dependencies:
// Lending protocol using Curve's virtual price as collateral oracle
function getCollateralValue(uint256 lpTokens) public view returns (uint256) {
// This call is safe in isolation
// But deadly if called during a Curve pool's callback window
uint256 virtualPrice = curvePool.get_virtual_price();
return lpTokens * virtualPrice / 1e18;
}
Case Study: The Curve LP Oracle Manipulation Pattern
The canonical read-only reentrancy vector involves Curve Finance's get_virtual_price() function.
How get_virtual_price() Works
Curve's virtual price represents the value of 1 LP token in the pool's base currency. It's calculated from the pool's internal balances and the invariant. During a remove_liquidity call:
- The pool calculates withdrawal amounts
- Tokens are transferred to the user (external call!)
- The pool's internal balances are updated
Between steps 2 and 3, get_virtual_price() reads balances that haven't been fully updated yet — inflating the apparent value of each LP token.
The Attack Chain
// VULNERABLE: Lending protocol that accepts Curve LP as collateral
contract VulnerableLender {
ICurvePool public curvePool;
function borrow(uint256 lpCollateral, uint256 borrowAmount) external {
IERC20(curvePool.lp_token()).transferFrom(msg.sender, address(this), lpCollateral);
// ⚠️ VULNERABLE: This reads stale data during reentrancy
uint256 collateralValue = lpCollateral * curvePool.get_virtual_price() / 1e18;
require(borrowAmount <= collateralValue * MAX_LTV / 10000, "Undercollateralized");
IERC20(borrowToken).transfer(msg.sender, borrowAmount);
}
}
The attacker's contract:
contract ReadOnlyReentrancyExploit {
ICurvePool public curvePool;
VulnerableLender public lender;
function attack() external {
curvePool.add_liquidity([1000e18, 1000e18], 0);
curvePool.remove_liquidity(curvePool.balanceOf(address(this)), [0, 0]);
}
receive() external payable {
// During callback, virtual price is inflated
uint256 lpBalance = IERC20(curvePool.lp_token()).balanceOf(address(this));
lender.borrow(lpBalance, OVERBORROW_AMOUNT);
}
}
Five Protocols That Got Burned
| Protocol | Date | Loss | Root Cause |
|---|---|---|---|
| Sentiment Protocol | Apr 2023 | $1M | Read-only reentrancy via Balancer getRate()
|
| Sturdy Finance | Jun 2023 | $800K | Stale Balancer pool price during callback |
| EraLend (zkSync) | Jul 2023 | $3.4M | Read-only reentrancy via SyncSwap oracle |
| Balancer V2 Composable | Nov 2025 | $100M+ | Precision + reentrancy interaction |
| Venus Protocol (THE) | Mar 2026 | $3.7M | Supply cap bypass via inflated oracle read |
Detection: Finding Read-Only Reentrancy in Your Codebase
Pattern 1: Cross-Protocol View Dependencies
Search for any external view call used in a value calculation:
# Slither custom detector approach
slither . --detect reentrancy-no-eth --print call-graph
Manual checklist:
- Does your protocol call
getRate(),get_virtual_price(),totalAssets(),convertToAssets(), orgetPrice()on external contracts? - Are those values used to determine collateral value, exchange rates, or reward amounts?
- Can the external protocol trigger callbacks (ETH transfers, ERC-777, hooks)?
Pattern 2: Semgrep Rule for Automated Detection
rules:
- id: read-only-reentrancy-price-oracle
patterns:
- pattern: |
function $FUNC(...) ... {
...
$PRICE = $EXTERNAL.$VIEW_FUNC(...);
...
require($AMOUNT <= $PRICE * ..., ...);
...
}
- metavariable-regex:
metavariable: $VIEW_FUNC
regex: (get_virtual_price|getRate|totalAssets|convertToAssets|getPrice|exchangeRate)
message: External view function used in value calculation
severity: WARNING
languages: [solidity]
Pattern 3: Foundry Invariant Test
function invariant_priceOracleConsistency() public {
uint256 priceBefore = curvePool.get_virtual_price();
vm.prank(address(exploitContract));
try curvePool.remove_liquidity(amount, mins) {} catch {}
uint256 priceAfter = curvePool.get_virtual_price();
assertApproxEqRel(priceBefore, priceAfter, 0.01e18);
}
Defense Patterns That Actually Work
Defense 1: The Reentrancy Lock Check (Best for Integrators)
Check if the target protocol is mid-execution before trusting its view functions:
interface IReentrancyAware {
function reentrancyLocked() external view returns (bool);
}
function safeRead(IReentrancyAware target) internal view returns (uint256) {
require(!target.reentrancyLocked(), "Target mid-execution");
return target.getRate();
}
Defense 2: TWAP + Bounds (Best for Lending Protocols)
contract SafeOracleAdapter {
uint256 public constant TWAP_WINDOW = 30 minutes;
uint256 public constant MAX_DEVIATION = 200; // 2% bps
function getSafePrice() external view returns (uint256) {
uint256 spotPrice = externalOracle.getPrice();
uint256 twapPrice = _calculateTWAP();
uint256 deviation = spotPrice > twapPrice
? (spotPrice - twapPrice) * 10000 / twapPrice
: (twapPrice - spotPrice) * 10000 / twapPrice;
require(deviation <= MAX_DEVIATION, "Price deviation too high");
return spotPrice < twapPrice ? spotPrice : twapPrice;
}
}
Defense 3: The Callback Blocker (Best for Protocol Authors)
Extend your reentrancy guard to cover reads:
abstract contract ReadSafeReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
// Protect view functions that external protocols depend on
modifier readSafe() {
require(_status != _ENTERED, "ReadSafe: inconsistent state");
_;
}
function reentrancyLocked() external view returns (bool) {
return _status == _ENTERED;
}
}
Defense 4: The Snapshot Pattern (Best for Cross-Protocol Reads)
contract SnapshotProtectedLender {
mapping(address => uint256) public cachedPrices;
mapping(address => uint256) public priceTimestamps;
uint256 public constant STALE_THRESHOLD = 1 hours;
function updateOraclePrice(address oracle) external {
cachedPrices[oracle] = IOracle(oracle).getPrice();
priceTimestamps[oracle] = block.timestamp;
}
function borrow(uint256 collateral, uint256 amount) external {
uint256 price = cachedPrices[address(oracle)];
require(block.timestamp - priceTimestamps[address(oracle)] < STALE_THRESHOLD, "Stale");
uint256 value = collateral * price / 1e18;
require(amount <= value * MAX_LTV / 10000, "Undercollateralized");
IERC20(token).transfer(msg.sender, amount);
}
}
Solana: Is Read-Only Reentrancy Possible?
Short answer: not in the same way, but analogous risks exist.
Solana's runtime prevents traditional reentrancy through its account locking model. However, Cross-Program Invocations (CPI) can create similar oracle manipulation windows:
pub fn borrow(ctx: Context<Borrow>) -> Result<()> {
let price = ctx.accounts.price_oracle.load()?.current_price;
let twap = ctx.accounts.twap_oracle.load()?.weighted_price;
require!(
price.abs_diff(twap) * 10000 / twap < MAX_DEVIATION_BPS,
ErrorCode::PriceDeviationTooHigh
);
Ok(())
}
The Solana-specific risk is stale account data in the same transaction. Always validate prices against secondary sources.
Audit Checklist: 7 Questions to Ask
-
Which external
viewfunctions do we call? Map every cross-protocol read. - Can those protocols trigger callbacks? ETH transfers, ERC-777, ERC-1155 hooks, flash loans.
- What happens to those
viewfunctions during a callback? - Do we have a TWAP or bounds check? Single-block spot reads are never safe.
- Is the external protocol's reentrancy lock status queryable?
- Are price-dependent operations in the same tx as external calls? Separate them.
- Do invariant tests cover the callback window?
Conclusion
Read-only reentrancy is DeFi's most underestimated vulnerability class because it violates a reasonable-sounding assumption: reading data should be safe. The fix isn't complicated: protect your view functions with reentrancy awareness, never trust single-block spot prices, and test your cross-protocol integrations under callback conditions.
Every protocol that uses another protocol's view function as a price feed is one callback away from being the next case study.
DreamWork Security publishes weekly deep dives on DeFi vulnerability patterns. Follow for detection tools, defense playbooks, and exploit analysis.
Top comments (0)