Skip to main content
Sigvex

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.

The read-only reentrancy class was disclosed by ChainSecurity in April 2022. They found that Curve’s get_virtual_price view function could be manipulated by reentering it during liquidity removal, which put the price feeds of several lending protocols at risk. The first exploit in the wild followed in October 2022, when an attacker manipulated a Curve LP oracle used by the Market.xyz lending market and drained roughly 138 ETH.

Note: Exploit generation in Sigvex is for vulnerability validation purposes only.

Attack Scenario

  1. 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.
  2. 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.
  3. Exploitation: During the ETH callback (receive()), the attacker calls lending.borrow(maxAmount). The lending protocol queries oracle.getTokenPrice(), which calls pool.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.
  4. 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:

  1. Parses the finding: Checks that the detector ID contains read, view, readonly, or reentrancy.
  2. Computes stale vs. correct price: Uses fixed-point arithmetic on U256 to calculate the percentage difference between the pre-update reserves and post-update reserves.
  3. Threshold check: If the discrepancy exceeds 5%, the result is ExploitTestResult::success with the stale price, actual price, and discrepancy percentage stored as evidence.
  4. Estimated gas: Reports 100_000 gas 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: VulnerablePoolPriceOracleLendingProtocol, showing how the oracle price propagates into the lending decision.

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.

References