Read-Only Reentrancy Exploit Generator
Sigvex exploit generator that validates read-only reentrancy vulnerabilities where view functions return stale state during an external call, misleading dependent protocols.
Read-Only Reentrancy Exploit Generator
Overview
The read-only reentrancy exploit generator validates vulnerabilities where a view function on a target contract can be called during an active external call, returning a stale (inconsistent) value that a dependent protocol then uses to make financial decisions. This class of vulnerability is particularly dangerous because conventional reentrancy guards do not protect view functions.
This pattern was the mechanism behind the Curve/Vyper hack (July 2023, $73M), where Vyper’s broken reentrancy lock allowed LP oracle prices to be read at incorrect intermediate values during a withdrawal callback.
Note: Exploit generation in Sigvex is for vulnerability validation purposes only.
Attack Scenario
- Setup: A pool contract holds reserves and exposes a
getPrice()view function. A lending protocol uses this price to determine borrowing capacity. The attacker deposits into both the pool and the lending protocol. - Trigger: The attacker calls
pool.withdraw(shares). The pool computes the ETH payout and sends it to the attacker via an external call. At this moment, the pool’s state variables (totalSupply,ethReserve) have not yet been decremented. - Exploitation: During the ETH callback (
receive()), the attacker callslending.borrow(maxAmount). The lending protocol queriesoracle.getTokenPrice(), which callspool.getPrice(). Because pool state has not updated,getPrice()returns the pre-withdrawal price (inflated by the about-to-be-removed liquidity). The attacker borrows against an inflated collateral value. - Impact: After the withdrawal completes, the pool’s reserves drop and the oracle price corrects. The attacker has already borrowed more than their collateral actually supports, effectively extracting value from the lending protocol’s liquidity providers.
Sigvex models the exploitability numerically:
- Pool reserves before withdrawal:
1000 ETH - Withdrawal amount:
100 ETH - Pool reserves after withdrawal:
900 ETH - Price discrepancy:
(1000 - 900) / 900 * 100 = ~11.1%
A discrepancy greater than 5% triggers a confirmed exploit result.
Exploit Mechanics
Sigvex validates the finding by simulating the price discrepancy without actually executing the full callback chain (the EVM sandbox cannot simulate multi-contract CPI). Instead it:
- Parses the finding: Checks that the detector ID contains
read,view,readonly, orreentrancy. - Computes stale vs. correct price: Uses fixed-point arithmetic on
U256to calculate the percentage difference between the pre-update reserves and post-update reserves. - Threshold check: If the discrepancy exceeds 5%, the result is
ExploitTestResult::successwith the stale price, actual price, and discrepancy percentage stored as evidence. - Estimated gas: Reports
100_000gas for the exploit transaction sequence.
The generated PoC is a multi-contract Solidity demonstration:
// Attacker's receive() is called during pool.withdraw()
receive() external payable {
if (attacking) {
// Pool state NOT yet updated — getPrice() returns stale value
uint256 stalePriceNow = oracle.getTokenPrice();
// Borrow against inflated collateral
try lending.borrow(address(lending).balance) {
stolenFunds += address(this).balance;
} catch { }
}
}
The PoC covers the full three-contract chain: VulnerablePool → PriceOracle → LendingProtocol, showing how the oracle price propagates into the lending decision.
Remediation
- Detector: Read-Only Reentrancy Detector
- Remediation Guide: Read-Only Reentrancy Remediation
Two complementary approaches:
Option 1 — Update state before external calls (checks-effects-interactions):
function withdraw(uint256 shares) external nonReentrant {
uint256 ethAmount = (shares * ethReserve) / totalSupply;
// Effects first
balances[msg.sender] -= shares;
totalSupply -= shares;
ethReserve -= ethAmount;
// Interaction last
(bool success,) = msg.sender.call{value: ethAmount}("");
require(success);
}
Option 2 — Read-only reentrancy guard on view functions: Add a modifier that reverts if the contract is in the middle of a state-modifying call. Dependent protocols should also guard their oracle reads.