Skip to main content
Sigvex

Reentrancy Attacks: From the DAO to Read-Only Variants

A technical breakdown of the three reentrancy vulnerability classes — classic, cross-function, and read-only — with real exploit patterns and bytecode-level detection strategies.

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 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.

Pattern flagged — classic reentrancy:
  SLOAD  balances[CALLER]    ; read balance
  CALL   msg.sender          ; external call, forwards value
  SSTORE balances[CALLER]    ; state written AFTER the call

The written slot was loaded before the CALL and the call forwards
value — the classic reentrancy shape.
Remediation: update balances[CALLER] before the CALL.

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:

Pattern flagged — cross-function reentrancy:
  withdraw(uint256)          calls msg.sender (external call)
  transfer(address,uint256)  reads balances[CALLER]

Both touch the same storage slot, keccak256(CALLER, slot 0):
transfer() reads it, withdraw() updates it only after its external
call, and 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 canonical example is a Curve-style pool. The pool’s internal accounting — its D invariant, exposed through get_virtual_price() — is briefly inconsistent during the callback that adding or removing liquidity performs. A protocol that reads the pool’s virtual price during that window receives a stale value and can be drained, even though the pool itself loses nothing. (This is a different failure from the 2023 Curve/Vyper incident above, which was classic reentrancy caused by a broken compiler-generated lock, not read-only.)

// 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 pool 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. The affected pool implementation 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:

Pattern flagged — read-only reentrancy:
Dirty window: add_liquidity() performs an external callback before
it updates the pool's D invariant.

Dependent read during that window:
  the callback can invoke a consumer that calls get_virtual_price()
  get_virtual_price() reads the D invariant — stale mid-update

Any external protocol pricing off get_virtual_price() inside the
callback reads a manipulated value.

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 2023 Curve/Vyper incident was classic reentrancy caused by a compiler bug that silently broke the guard — the lock was present in the source but absent in the deployed bytecode, so source-only tools reported no risk

References

  1. SWC-107: Reentrancy
  2. Vyper Security Advisory: Reentrancy Lock Vulnerability