Read-Only Reentrancy
Detects view functions that read shared state mid-execution of a mutating call, allowing attackers to observe and exploit inconsistent intermediate state without modifying it.
Read-Only Reentrancy
Overview
Remediation Guide: How to Fix Read-Only Reentrancy
The read-only reentrancy detector identifies external contracts that invoke STATICCALL to read state from a contract mid-execution of a non-atomic, multi-step operation. Unlike classic reentrancy — where the attacker re-enters and modifies state — read-only reentrancy exploits the fact that a contract’s state is temporarily inconsistent during the execution of a multi-step write operation. An attacker calls a secondary contract (such as a price oracle or collateral calculator) that reads the primary contract’s stale view via a read-only call, and then makes financial decisions based on that stale data.
Sigvex detects this pattern by analyzing the execution ordering of state-writing operations (SSTORE) relative to outgoing external calls (CALL, DELEGATECALL). When a function updates state in multiple steps and makes an outgoing call between those steps — creating a window where external view functions can observe an inconsistent intermediate state — the detector flags the pattern as a read-only reentrancy risk.
This vulnerability became prominent with Curve Finance’s multi-token pools, where LP token prices read from external view functions could be manipulated by an attacker who exploited the gap between balance updates and price recalculations during a single transaction.
Why This Is an Issue
Read-only reentrancy is particularly insidious because the reentering contract does not need write access to exploit it. Protocols that use a DeFi pool’s get_virtual_price() or similar view functions as collateral oracles are vulnerable when those views read state that can be temporarily inconsistent during pool operations.
The Curve Finance reentrancy exploit class demonstrated losses across multiple protocols that used Curve’s get_virtual_price() as a pricing source. Attackers could manipulate the reported price by re-entering during a Curve remove_liquidity call while balances had not yet been fully updated, then exploit the artificially inflated price in a third-party lending protocol.
Real-world incidents in 2023 affected multiple lending protocols for tens of millions of dollars, exploiting shared read-only price feeds from Curve pools.
How to Resolve
// Before: Vulnerable — price read from external contract while state is partially updated
contract VulnerableLending {
ICurvePool public pool;
function liquidate(address borrower) external {
// Reads Curve's get_virtual_price() — can return stale value mid-Curve-operation
uint256 price = pool.get_virtual_price();
uint256 collateralValue = calculateCollateral(price);
// Uses potentially manipulated price for critical decision
if (collateralValue < getDebt(borrower)) {
_liquidate(borrower);
}
}
}
// After: Add reentrancy guard to price-reading functions
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureLending is ReentrancyGuard {
ICurvePool public pool;
function liquidate(address borrower) external nonReentrant {
uint256 price = pool.get_virtual_price();
uint256 collateralValue = calculateCollateral(price);
if (collateralValue < getDebt(borrower)) {
_liquidate(borrower);
}
}
}
Alternatively, use a time-weighted price or add an explicit reentrancy lock on the price-reading path:
// Also consider checking Curve's lock status before reading price
contract SecureLending {
ICurvePool public pool;
function getSecurePrice() internal view returns (uint256) {
// Some Curve pools expose a reentrancy guard check
// Check the pool's lock status if available, then read price
return pool.get_virtual_price();
}
}
Examples
Vulnerable Code
// Protocol A: DeFi pool that updates state in multiple steps with external call in between
contract VulnerablePool {
uint256 public totalShares;
mapping(address => uint256) public balances;
function removeLiquidity(uint256 shares) external {
uint256 amount = (shares * address(this).balance) / totalShares;
totalShares -= shares; // Step 1: shares updated
// VULNERABLE: external call here while balances[msg.sender] not yet zeroed
// An attacker can re-enter a Protocol B that reads balances[msg.sender]
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] -= shares; // Step 2: not yet updated when call happens
}
// This view can return stale data during removeLiquidity execution
function getVirtualPrice() external view returns (uint256) {
return (address(this).balance * 1e18) / totalShares;
}
}
// Protocol B: Lending protocol using Protocol A's view as oracle — VULNERABLE
contract VulnerableLender {
IPool public pool;
function borrow(uint256 amount) external {
uint256 price = pool.getVirtualPrice(); // Reads mid-removeLiquidity — stale!
// price is artificially high because balances are inconsistent
require(getCollateral(msg.sender) >= amount * price / 1e18);
_sendTokens(msg.sender, amount);
}
}
Fixed Code
// Protocol B fixed: use reentrancy guard to prevent mid-state reads
contract SecureLender is ReentrancyGuard {
IPool public pool;
function borrow(uint256 amount) external nonReentrant {
uint256 price = pool.getVirtualPrice();
require(getCollateral(msg.sender) >= amount * price / 1e18);
_sendTokens(msg.sender, amount);
}
}
// Protocol A fixed: complete all state updates before external call
contract SecurePool {
uint256 public totalShares;
mapping(address => uint256) public balances;
function removeLiquidity(uint256 shares) external {
uint256 amount = (shares * address(this).balance) / totalShares;
totalShares -= shares;
balances[msg.sender] -= shares; // Complete ALL state updates first
// External call after all state is consistent
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}
Sample Sigvex Output
{
"detector_id": "read-only-reentrancy",
"severity": "critical",
"confidence": 0.70,
"description": "Function removeLiquidity() makes an external CALL at offset 0x8c while storage state (totalShares) has been updated but balances[msg.sender] has not yet been decremented. A STATICCALL to getVirtualPrice() during this window returns an inconsistent price.",
"location": { "function": "removeLiquidity(uint256)", "offset": 140 }
}
Detection Methodology
Sigvex detects read-only reentrancy using a multi-pass CFG analysis:
- State-update sequencing: Identifies functions with multiple
SSTOREoperations that affect related storage slots (e.g., total supply and individual balance). Detects cases where these writes are split across an external call boundary. - External call identification: Marks
CALL,DELEGATECALL, andCALLCODEopcodes that transfer control to user-controlled addresses. - View function cross-reference: Identifies
STATICCALLtargets that access storage slots involved in the split write. When a view function can read those slots during the inconsistent window, the pattern is flagged. - Confidence calibration: Higher confidence when the split writes clearly affect a shared price or accounting variable; lower confidence when the relationship between the write slots is unclear.
Limitations
False positives:
- Contracts that use a reentrancy guard on both the view function and the write function are correctly protected but may still be flagged if the guard is implemented via a custom mechanism not recognized at the bytecode level.
- External calls to trusted, known-safe addresses (e.g., ERC-20 token contracts that do not invoke back) may be flagged.
False negatives:
- Cross-contract read-only reentrancy where the vulnerable view function is in a separate contract (not the one being analyzed) is detected with lower confidence.
- Novel patterns where the “view function” is implemented via
CALLwith no state changes (rather thanSTATICCALL) may be missed.
Related Detectors
- Reentrancy — detects classic single-function reentrancy with state modification
- Cross-Function Reentrancy — detects reentrancy that pivots through a second function
- Flash Loan — flash loans are frequently used to capitalize on read-only reentrancy windows