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 a major stable-swap protocol’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 stable-swap read-only reentrancy exploit class demonstrated losses across multiple protocols that used a stable-swap pool’s get_virtual_price() as a pricing source. Attackers could manipulate the reported price by re-entering during a pool’s 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 stable-swap pools.
How to Resolve
// Before: Vulnerable — price read from external contract while state is partially updated
contract VulnerableLending {
IStableSwapPool public pool;
function liquidate(address borrower) external {
// Reads the pool's get_virtual_price() — can return stale value mid-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 "@audited/utils/ReentrancyGuard.sol";
contract SecureLending is ReentrancyGuard {
IStableSwapPool 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 the stable-swap AMM's lock status before reading price
contract SecureLending {
IStableSwapPool public pool;
function getSecurePrice() internal view returns (uint256) {
// Some stable-swap 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