Back to Blog

Reentrancy in 2026: The Bug That Refuses to Die

|Odin Scan Team
Reentrancy in 2026: The Bug That Refuses to Die

The DAO hack was in June 2016. Ten years ago. Reentrancy was the bug. It drained $60 million and split Ethereum into two chains.

A decade later, reentrancy is still in production. Not because developers have not heard of it. Because the vulnerability has evolved faster than the defenses most teams actually use.

Classic reentrancy, where a withdrawal function calls an external contract that re-enters the same function, is well-defended. OpenZeppelin's nonReentrant modifier blocks it. The checks-effects-interactions pattern prevents it. Most developers know this.

The variants that are draining protocols in 2025 and 2026 are different. Read-only reentrancy. Cross-function reentrancy. Cross-contract reentrancy. Cross-protocol reentrancy. Each exploits a gap that the standard defenses do not cover.


Classic Reentrancy: The Baseline

For context, the original pattern:

// Vulnerable
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool success,) = msg.sender.call{value: amount}(""); // external call
    require(success);
    balances[msg.sender] = 0; // state update after call
}

The attacker's contract receives ETH, re-enters withdraw(), and because balances[msg.sender] has not been zeroed yet, withdraws again. Repeat until the contract is drained.

Defense: Update state before the external call, and use nonReentrant.

function withdraw() external nonReentrant {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0; // state update BEFORE call
    (bool success,) = msg.sender.call{value: amount}("");
    require(success);
}

This is the 2016 version. It is solved. What follows is not.


Variant 1: Cross-Function Reentrancy

The attacker does not re-enter the same function. They re-enter a different function that reads the same state.

contract Vault {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        (bool success,) = msg.sender.call{value: amount}("");
        require(success);
        balances[msg.sender] = 0;
    }

    // This function is NOT protected by nonReentrant
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

The attack: call withdraw(). During the external call callback, call transfer() to move the balance to another address. Now withdraw() completes and zeros the balance, but the attacker has already transferred it elsewhere. The attacker withdraws from the second address.

Defense: Apply nonReentrant to every function that reads or writes shared state, not just the function with the external call. OpenZeppelin's ReentrancyGuard uses a single mutex, so the modifier blocks entry to any nonReentrant function while another is executing.


Variant 2: Read-Only Reentrancy

This is the variant catching the most protocols off guard in 2025-2026.

A view function (read-only) is called during a transaction that has modified state but not yet completed. The view function returns stale data because the state update has not finalized.

contract Pool {
    uint256 public totalAssets;
    uint256 public totalShares;

    function deposit() external payable nonReentrant {
        // Step 1: mint shares based on current ratio
        uint256 shares = msg.value * totalShares / totalAssets;
        totalShares += shares;

        // Step 2: external call (e.g., callback, token transfer)
        // During this call, totalAssets has NOT been updated yet
        _callback(msg.sender);

        // Step 3: update totalAssets
        totalAssets += msg.value;
    }

    // This view function can be called by other protocols
    function pricePerShare() external view returns (uint256) {
        return totalAssets * 1e18 / totalShares;
    }
}

Between Step 2 and Step 3, pricePerShare() returns a stale value because totalShares has been updated but totalAssets has not. Any protocol that reads pricePerShare() during this window gets a wrong price.

The attack:

  1. Attacker triggers deposit() which updates totalShares and makes a callback
  2. During the callback, the attacker interacts with Protocol B that uses Pool.pricePerShare() as a price oracle
  3. Protocol B sees a deflated price per share and lets the attacker borrow against undervalued collateral
  4. deposit() completes, price normalizes, attacker profits from the mispricing

Defense: This is the hard one. nonReentrant on the view function does nothing because view functions do not write state. The defenses are:

  1. Update all state before any external call, even if the external call seems safe
  2. Use a reentrancy lock that view functions can check:
modifier nonReentrantView() {
    require(!_locked, "Reentrancy detected");
    _;
}

function pricePerShare() external view nonReentrantView returns (uint256) {
    return totalAssets * 1e18 / totalShares;
}
  1. For protocols consuming external price data: never trust a view function's output during a callback. Add staleness or snapshot logic.

Variant 3: Cross-Contract Reentrancy

Two contracts share state (or one reads state from the other). An external call in Contract A allows reentry into Contract B, which reads stale state from Contract A.

contract Lending {
    IOracle public oracle;

    function liquidate(address user) external {
        uint256 collateralValue = oracle.getPrice() * collateral[user];
        require(collateralValue < debt[user], "Not liquidatable");
        // ... execute liquidation, which triggers a token transfer callback
    }
}

contract Oracle {
    IPool public pool;

    function getPrice() external view returns (uint256) {
        return pool.pricePerShare(); // reads from Pool contract
    }
}

If Pool.pricePerShare() can be manipulated via reentrancy (as in Variant 2), the entire Lending protocol's liquidation logic is compromised, even though neither Lending nor Oracle has a reentrancy bug themselves.

Defense: This requires system-level thinking. Individual contract audits miss cross-contract reentrancy because they examine contracts in isolation. The fix is either:

  • Ensure the price source (Pool) is reentrancy-safe, including its view functions
  • Add independent price validation in the consuming contract (sanity bounds, TWAP comparison)

Variant 4: ERC-777 and ERC-1155 Callback Reentrancy

ERC-777 tokens and ERC-1155 tokens include callback hooks that execute code in the recipient's contract during transfers. If a protocol accepts these token standards without reentrancy protection, any transfer can trigger reentry.

// ERC-777 calls tokensReceived() on the recipient
// ERC-1155 calls onERC1155Received() on the recipient
// Both execute during the transfer, before it completes

function deposit(uint256 amount) external {
    // If token is ERC-777, this triggers a callback to msg.sender
    token.transferFrom(msg.sender, address(this), amount);
    // State update happens after the callback
    balances[msg.sender] += amount;
}

Defense: Treat every transferFrom and safeTransferFrom as a potential external call. Apply nonReentrant and follow checks-effects-interactions. If your protocol only needs ERC-20 functionality, reject ERC-777 tokens explicitly.


The Defense Stack

No single defense catches all reentrancy variants. You need layers:

Layer 1: Checks-Effects-Interactions (CEI) Update all state before making any external call. This is the foundation. It prevents classic and cross-function reentrancy on its own.

Layer 2: ReentrancyGuard on all state-touching functions Apply nonReentrant to every function that reads or writes state and could be reached during an external call. Not just the function with the call.

Layer 3: Reentrancy-safe view functions If other protocols read your view functions for pricing or state, ensure those functions return consistent values even during mid-execution states. Either update all state before callbacks, or expose a reentrancy lock that view functions check.

Layer 4: Automated scanning Static analysis catches CEI violations and missing reentrancy guards. Odin Scan flags these patterns across all functions, not just the obvious ones.

Layer 5: Integration testing Write tests with malicious callback contracts that attempt reentrancy through every external call path. If the test does not revert, you have a bug.


Odin Scan detects all four reentrancy variants described in this post: classic, cross-function, read-only, and callback-based. It flags CEI violations, missing nonReentrant modifiers, and view functions that return mid-execution state. Start your free trial and catch reentrancy before it catches you.