Remediating Read-Only Reentrancy
How to prevent read-only reentrancy by ensuring state is consistent before external calls and protecting price-reading functions from mid-execution observations.
Remediating Read-Only Reentrancy
Overview
Related Detector: Read-Only Reentrancy
Read-only reentrancy occurs when a contract’s state is temporarily inconsistent during a multi-step write operation, and an external view function can be called by an attacker to observe that inconsistency. The fix requires eliminating the inconsistency window — either by completing all state updates before making external calls, or by protecting the consuming protocol’s price-reading code with a reentrancy guard.
Recommended Fix
Before (Vulnerable)
// Pool: state updates split across an external call
contract VulnerablePool {
uint256 public totalShares;
mapping(address => uint256) public userShares;
function removeLiquidity(uint256 shares) external {
uint256 ethAmount = (shares * address(this).balance) / totalShares;
// State partially updated here
totalShares -= shares;
// External call BEFORE completing state update
(bool ok,) = msg.sender.call{value: ethAmount}("");
require(ok);
// Second state update AFTER external call — creates inconsistency window
userShares[msg.sender] -= shares;
}
// View returns inconsistent price during removeLiquidity
function getVirtualPrice() external view returns (uint256) {
return address(this).balance * 1e18 / totalShares;
}
}
// Lender: reads pool price without reentrancy protection
contract VulnerableLender {
IPool pool;
function borrow(uint256 amount) external {
uint256 price = pool.getVirtualPrice(); // Reads stale intermediate state
// Attacker exploits inflated price to borrow more than collateral covers
require(userCollateral[msg.sender] * price / 1e18 >= amount);
_transfer(msg.sender, amount);
}
}
After (Fixed)
// Fix 1: In the pool — complete all state updates before external calls
contract SecurePool {
uint256 public totalShares;
mapping(address => uint256) public userShares;
function removeLiquidity(uint256 shares) external {
uint256 ethAmount = (shares * address(this).balance) / totalShares;
// ALL state updates complete before the external call
totalShares -= shares;
userShares[msg.sender] -= shares;
// External call only after state is fully consistent
(bool ok,) = msg.sender.call{value: ethAmount}("");
require(ok);
}
}
// Fix 2: In the lender — add nonReentrant guard to price-consuming functions
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureLender is ReentrancyGuard {
IPool pool;
function borrow(uint256 amount) external nonReentrant {
uint256 price = pool.getVirtualPrice();
require(userCollateral[msg.sender] * price / 1e18 >= amount);
_transfer(msg.sender, amount);
}
}
The pool-side fix eliminates the vulnerability at the source. The lender-side fix is a defensive measure for protocols that cannot control the pool they depend on. Both fixes together provide defense in depth.
Alternative Mitigations
Use a Manipulation-Resistant Oracle
Instead of reading spot state from a pool, use a time-weighted average or an off-chain aggregated oracle:
// Use Chainlink instead of on-chain spot price
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecureLender {
AggregatorV3Interface public priceFeed;
uint256 constant MAX_STALENESS = 3600;
function getSecurePrice() internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt <= MAX_STALENESS, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}
}
Check Pool Reentrancy Lock (When Available)
Some pools expose their reentrancy lock status. Check it before reading price:
interface IPoolWithLock {
function locked() external view returns (bool);
function get_virtual_price() external view returns (uint256);
}
function getSafePrice(IPoolWithLock pool) internal view returns (uint256) {
require(!pool.locked(), "Pool is locked — mid-execution read rejected");
return pool.get_virtual_price();
}
Common Mistakes
Mistake 1: Only Protecting the Write Function
// INCORRECT: reentrancy guard on the pool does not prevent external reads
contract MisguidedPool is ReentrancyGuard {
function removeLiquidity(uint256 shares) external nonReentrant {
// nonReentrant prevents re-entering THIS function
// but does NOT prevent a STATICCALL to getVirtualPrice() during execution
totalShares -= shares;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
userShares[msg.sender] -= shares;
}
}
nonReentrant prevents re-entry into the same contract but does not block STATICCALL reads from external contracts during the execution window.
Mistake 2: Relying on transfer() or send() as a Fix
// INCORRECT: transfer() has a 2300 gas stipend, but this is not a reliable defense
contract MisguidedPool {
function removeLiquidity(uint256 shares) external {
totalShares -= shares;
// transfer() still allows read-only reentrancy from callback chains
payable(msg.sender).transfer(amount);
userShares[msg.sender] -= shares;
}
}
The 2300-gas stipend from transfer() prevents complex logic but not all read-only reentrancy scenarios, especially in future EVM versions.
Mistake 3: Not Accounting for Third-Party Consumers
The primary fix (move all writes before the external call) does not automatically protect all downstream protocols that read from your view functions. Coordinate with protocols that use your pool as a price source and advise them to add their own reentrancy guards.