Reentrancy Attacks: From the DAO to Read-Only Variants

Reentrancy has been Ethereum’s most persistent vulnerability class for eight years. The DAO hack in 2016 drained $60 million. Reentrancy variants caused losses at Cream Finance, Lendf.Me, and dForce throughout 2021. The Curve Finance exploit in 2023 drained $70 million — not from a missing guard, but from a Vyper compiler bug that silently broke one.

The pattern keeps reappearing because reentrancy is not one vulnerability. It is a family of three distinct attack classes, each requiring a different detection strategy. A source-level check for one can miss the others entirely at the bytecode level.

The Fundamental Model: Checks-Effects-Interactions

Every reentrancy vulnerability is a violation of the Checks-Effects-Interactions (CEI) pattern. The correct execution order is:

  1. Checks — validate preconditions (require, revert guards)
  2. Effects — update internal state (balance reductions, flag sets)
  3. Interactions — call external contracts or send ETH

When interactions happen before effects, an attacker can re-enter the function before state reflects the first call, allowing the same state to authorize multiple withdrawals.

Classic Reentrancy

The canonical form appears in any function that transfers ETH or calls an external contract before updating state:

// Vulnerable: state update happens after the external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    // Interaction before Effect — VULNERABLE
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");

    // Effect too late: attacker has already re-entered by here
    balances[msg.sender] -= amount;
}

The attacker deploys a contract with a receive() function that calls withdraw() again:

contract Attacker {
    VulnerableVault public vault;
    uint256 public constant DRAIN_AMOUNT = 1 ether;

    constructor(address _vault) {
        vault = VulnerableVault(_vault);
    }

    // Triggered when vault sends ETH
    receive() external payable {
        if (address(vault).balance >= DRAIN_AMOUNT) {
            vault.withdraw(DRAIN_AMOUNT);
        }
    }

    function attack() external {
        vault.withdraw(DRAIN_AMOUNT);
    }
}

Each time the vault sends ETH, receive() fires before balances[attacker] is decremented. The balance check passes repeatedly until the vault is empty.

The fix — CEI pattern:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    // Effect first
    balances[msg.sender] -= amount;

    // Interaction last
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

The alternative fix — reentrancy guard:

bool private _locked;

modifier nonReentrant() {
    require(!_locked, "Reentrant call");
    _locked = true;
    _;
    _locked = false;
}

function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
    balances[msg.sender] -= amount;
}

How Sigvex Detects Classic Reentrancy

Sigvex traces bytecode execution paths looking for the pattern: SLOAD (state read) → CALL (external call with value) → SSTORE (state write to same or dependent slot), where the SSTORE location was loaded before the CALL.

Finding: Reentrancy
Severity: CRITICAL
Confidence: 0.97

Pattern: External call precedes state update
  SLOAD  balances[CALLER]    ; offset 0x1A3
  CALL   msg.sender          ; offset 0x1F8  ← external call
  SSTORE balances[CALLER]    ; offset 0x22C  ← state update after call

Call value: non-zero (ETH transfer detected)
Function: withdraw(uint256) — selector 0x2e1a7d4d

Recommendation: Update balances[CALLER] before the CALL instruction

This analysis operates entirely on bytecode. Variable names like balances are recovered from storage access patterns and mapping slot computations during HIR lifting. No source code is required.

Cross-Function Reentrancy

The single-function guard does not protect against re-entry through a different function that reads the same state. This is the cross-function variant.

Consider a protocol where withdraw() has a reentrancy guard but transfer() reads the same balance:

contract VulnerablePool {
    mapping(address => uint256) public balances;
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "Reentrant call");
        _locked = true;
        _;
        _locked = false;
    }

    // Protected function — but only protects itself
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount);
        (bool success,) = msg.sender.call{value: amount}("");
        require(success);
        balances[msg.sender] -= amount;
    }

    // Unprotected function reading same state
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;   // runs with stale balance
        balances[to] += amount;
    }
}

The attack sequence: call withdraw(), which acquires _locked. During the ETH transfer callback, call transfer() — it is not guarded and reads balances[attacker] before the withdraw() effect has run. The attacker can transfer their full (pre-withdrawal) balance to a second address, then let withdraw() complete and subtract from the depleted balance.

The fix is consistent use of nonReentrant across all functions that read mutable financial state, or (better) consistent CEI ordering so the state is already updated before any external call fires.

How Sigvex Detects Cross-Function Reentrancy

The cross-function reentrancy detector constructs the call graph across all functions in the contract, then tracks storage slots read and written per function. When function A performs an external call before updating slot S, and function B reads slot S without a reentrancy lock, the detector flags the pair:

Finding: Cross-Function Reentrancy
Severity: CRITICAL
Confidence: 0.89

Reentry vector:
  withdraw(uint256) calls msg.sender at offset 0x1F8
  transfer(address,uint256) reads balances[CALLER] at offset 0x3A1

Storage slot overlap:
  Slot keccak256(CALLER, 0x0): read in transfer(), updated after call in withdraw()

Both functions share mutable state. transfer() has no reentrancy guard.

Read-Only Reentrancy

Read-only reentrancy is the most subtle variant and the least well-understood. It does not require writing state during re-entry — only reading it.

The attack works when Protocol A calls Protocol B, and during that external call, an attacker triggers Protocol C which reads stale state from Protocol A. Protocol A’s state is temporarily inconsistent during its own external call (before its internal updates complete), and Protocol C makes financial decisions based on that inconsistent snapshot.

The Curve Finance exploit in 2023 exploited this exact pattern. Curve’s internal accounting (the D invariant value) was in an inconsistent state during the callback from adding liquidity. Protocols that read Curve pool state during that window received a stale price, enabling the attacker to drain those protocols even though Curve itself lost nothing.

// Simplified read-only reentrancy scenario
contract PriceOracle {
    IPool public pool;

    function getPrice() external view returns (uint256) {
        // Reads pool.D — but if called during a pool callback,
        // pool.D may be stale (updated at end of transaction)
        return pool.get_virtual_price();
    }
}

contract LendingProtocol {
    PriceOracle public oracle;

    function borrow(uint256 amount) external {
        // If called during a Curve callback, oracle.getPrice()
        // returns the pre-update virtual price
        uint256 price = oracle.getPrice();
        uint256 collateralValue = collateral[msg.sender] * price;
        // ... allows over-borrowing against stale collateral value
    }
}

The fix is to ensure any oracle that reads AMM state is protected against reads during re-entry. Curve V2 now includes a nonreentrant view lock that reverts any external call to get_virtual_price() while a state-mutating function is executing.

How Sigvex Detects Read-Only Reentrancy

The read-only reentrancy detector combines call graph analysis with state consistency tracking. It identifies functions that perform callbacks before finalizing state (the “dirty window”), then flags external protocols that read that state from within the callback scope:

Finding: Read-Only Reentrancy
Severity: CRITICAL
Confidence: 0.82

Protocol: CurvePool (0x...)
Dirty window: add_liquidity() performs external callback at offset 0x4A3
              before updating D invariant at offset 0x5B1

Dependent read detected:
  External call at 0x4A3 can invoke OracleConsumer.getPrice()
  OracleConsumer.getPrice() calls pool.get_virtual_price()
  get_virtual_price() reads D invariant — stale during dirty window

Similar exploit: Curve Finance July 2023 — $70M

Key Takeaways

  • Reentrancy is a family of three distinct attack classes, not a single pattern
  • Classic reentrancy is fixed by CEI ordering or a reentrancy guard — but neither alone protects against cross-function or read-only variants
  • Read-only reentrancy requires no state mutation: it exploits stale state reads during a callback window
  • Bytecode-level detection covers all three variants regardless of source code availability or compiler version
  • The Curve Finance 2023 exploit was a read-only reentrancy attack on a Vyper-compiled contract — source-code-only tools saw a guard in place and reported no risk

References

  1. SWC-107: Reentrancy
  2. Vyper Security Advisory: Reentrancy Lock Vulnerability
  3. Ethereum Smart Contract Best Practices — consensys.github.io