Overview

Reentrancy attacks are, without a doubt, the most notorious and financially devastating smart contract vulnerabilities in blockchain history. Over $200 million has been stolen through reentrancy vulnerabilities since 2016. The DAO hack alone accounted for $60 million, and the 2023 Curve Finance exploit drained over $70 million.

Despite this track record, reentrancy remains in active rotation. Reentrancy remains one of the top 5 smart contract vulnerabilities in 2025, despite being well-known for nearly a decade. The reason is not ignorance — it is the fact that reentrancy is not a single, monolithic bug. It is a family of vulnerabilities, and each member of that family has its own detection heuristic, its own exploitability surface, and its own correct fix.

This article maps the entire family:

VariantScopeClassic Guard Works?
Single-functionOne function, one contract✅ Yes
Cross-functionTwo functions, one contract⚠️ Partial
Cross-contractTwo contracts, shared state❌ No
Read-onlyView functions, foreign state❌ No

The Root Cause — One Sentence

A reentrancy attack exploits the vulnerability in smart contracts when a function makes an external call to another contract before updating its own state. This allows the external contract, possibly malicious, to reenter the original function and repeat certain actions, like withdrawals, using the same state.

The EVM provides the mechanism: running a transaction in Ethereum can generate multiple nested frames of execution, each created by CALL (or similar) instructions. Contracts can be re-entered during the same transaction, in which case there are more than one frame belonging to one contract.

Every reentrancy variant exploits this frame-stacking property at a different architectural level.


Variant 1 — Single-Function Reentrancy

What It Is

Reentrancy within a singular function context marked the first occurrence of this vulnerability being discovered and exploited. In such a scenario, an external call within the function triggers the function again, initiating the half-completed execution anew multiple times, leading to a cascade of state changes.

This type of reentrancy occurs when the vulnerable function is the same function that is repeatedly called by the attacker, before the completion of its previous invocations. It is a simpler and more easily detectable form of reentrancy attack compared to the other types.

Vulnerable Pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title VulnerableBank — classic single-function reentrancy
contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

        // ⚠️  INTERACTION before EFFECT
        // The ETH transfer hands control to msg.sender.
        // If msg.sender is a contract, its receive() / fallback()
        // runs here — before balances[msg.sender] is cleared.
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // State update arrives too late.
        balances[msg.sender] = 0;
    }
}

Why It Is Exploitable

When the withdraw() function is called, it sends coins to the investor through msg.sender.call and then resets their balance to zero. However, since the execution of the send transaction waits for the hacker’s fallback function to complete, the hacker’s balance remains unchanged until the fallback function finishes. As a result, the withdraw function can be reentered with the same state as if it were initially called, creating a loop that causes the function to execute actions repeatedly which were meant to be executed only once.

The attacker contract’s receive() re-calls withdraw() before balances[msg.sender] = 0 is ever reached. Every recursive call sees the original, un-zeroed balance.

// Attacker contract
contract Attacker {
    VulnerableBank public target;

    constructor(address _target) {
        target = VulnerableBank(_target);
    }

    function attack() external payable {
        target.deposit{value: msg.value}();
        target.withdraw();
    }

    // Re-enters withdraw() on every ETH receipt
    receive() external payable {
        if (address(target).balance >= msg.value) {
            target.withdraw();
        }
    }
}

The Fix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title SecureBank — Checks-Effects-Interactions applied
contract SecureBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

        // ✅  EFFECT before INTERACTION
        balances[msg.sender] = 0;

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

State is zeroed before control leaves the contract. Even if the attacker re-enters, amount is now 0 and the require reverts.


Variant 2 — Cross-Function Reentrancy

What It Is

A cross-function reentrancy attack occurs when a vulnerable function shares the same contract with another function that has a desirable effect for the attacker. Cross-function reentrancy is another level of reentrancy in terms of complexity. Typically, the root cause of this issue is that there are multiple functions mutually sharing the same state variable, and some of them update that variable insecurely.

The dangerous subtlety here: while the fix for single-function reentrancy effectively mitigates the security vulnerability within that context, the exploit is still viable in more complex scenarios. For instance, if another function were to call withdraw, reentrancy could still be a potential threat. This is known as cross-function reentrancy, arising when multiple functions share the same state.

Vulnerable Pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @notice Vault with a noReentrant guard on withdraw — but NOT on transfer.
///         The guard on withdraw alone is insufficient.
contract InsecureVault {
    mapping(address => uint256) public userBalances;

    modifier noReentrant() {
        // A naive per-function lock
        require(!_locked, "Reentrancy detected");
        _locked = true;
        _;
        _locked = false;
    }

    bool private _locked;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    /// @notice Protected against single-function reentrancy.
    function withdrawAll() external noReentrant {
        uint256 balance = userBalances[msg.sender];
        require(balance > 0, "No balance");

        // ⚠️  ETH sent before balance is cleared.
        // The noReentrant lock only blocks re-entry into withdrawAll.
        // It does NOT block entry into transfer().
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success);

        userBalances[msg.sender] = 0;
    }

    /// @notice Not protected by any lock.
    function transfer(address to, uint256 amount) external {
        require(userBalances[msg.sender] >= amount);
        userBalances[msg.sender] -= amount;
        userBalances[to] += amount;
    }
}

Why It Is Exploitable

Cross-function reentrancy occurs when one function performs an external call before updating the state and the external contract calls another function that depends on this state. This can lead to unexpected interactions between different parts of the contract, allowing an attacker to exploit vulnerabilities in one function to manipulate the state of another.

In concrete terms against InsecureVault:

  1. Attacker calls withdrawAll(). The noReentrant lock is engaged.
  2. msg.sender.call{value: balance}("") fires, triggering the attacker’s receive().
  3. Inside receive(), the attacker calls transfer(accomplice, balance) — a different function with no lock.
  4. Because userBalances[attacker] is still non-zero (it hasn’t been cleared yet), transfer() succeeds and moves the balance to the accomplice.
  5. withdrawAll() finishes, clears userBalances[attacker] (now redundant), and returns.
  6. The accomplice withdraws the double-counted ETH.

When the victim contract makes a function call to the external contract at the wrong time, the attacking contract does not necessarily have to re-enter the same function that called it. In fact, if two functions are re-entrant, the attacker can “trampoline” (also called mutual recursion) between the functions. Some engineers refer to this as cross-function reentrancy.

The Fix

Apply the lock across the entire contract, not per-function, and apply CEI inside every function:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureVault is ReentrancyGuard {
    mapping(address => uint256) public userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    /// @notice nonReentrant blocks ALL re-entry into any nonReentrant function.
    function withdrawAll() external nonReentrant {
        uint256 balance = userBalances[msg.sender];
        require(balance > 0, "No balance");

        // ✅ Effect first
        userBalances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
    }

    function transfer(address to, uint256 amount) external nonReentrant {
        require(userBalances[msg.sender] >= amount, "Insufficient balance");
        // ✅ Effect first
        userBalances[msg.sender] -= amount;
        userBalances[to] += amount;
    }
}
Applying `nonReentrant` to `withdrawAll` but not to `transfer` is **not** a fix. The guard must be consistent across every function that reads or writes the shared state variable.

Variant 3 — Cross-Contract Reentrancy

What It Is

Cross-contract reentrancy involves interactions between functions within multiple contracts where the state is shared. If the shared state in the first contract is not updated before an external call, contracts that depend on the shared state can be re-entered.

In a cross-contract reentrancy attack, it must be the case that two contracts share the same state. The actual vulnerability will occur when the state is not updated to reflect immediate transactional changes before any low-level or high-level cross-contract call.

This variant is the most difficult to catch in audit because the vulnerable interaction spans deployment boundaries — both contracts may look individually correct.

Vulnerable Pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @dev Token contract whose total supply is the authority on balances.
contract MoonToken {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;

    function mint(address to, uint256 amount) external {
        balances[to] += amount;
        totalSupply += amount;
    }

    /// @notice Burns the full balance of an account.
    ///         Called by the Vault, which is trusted.
    function burnAccount(address account) external {
        uint256 bal = balances[account];
        balances[account] = 0;
        totalSupply -= bal;
    }
}

/// @dev Vault that issues MoonToken on deposit and redeems ETH on withdrawal.
contract InsecureMoonVault {
    MoonToken public moonToken;
    mapping(address => uint256) public ethDeposits;

    constructor(address _token) {
        moonToken = MoonToken(_token);
    }

    function deposit() external payable {
        ethDeposits[msg.sender] += msg.value;
        moonToken.mint(msg.sender, msg.value);
    }

    function withdrawAll() external {
        uint256 balance = moonToken.balances(msg.sender);
        require(balance > 0, "No tokens");

        // ⚠️  ETH is sent before the token balance is burned.
        // The attacker's fallback can call deposit() on another vault
        // that still reads the non-burned MoonToken balance as collateral.
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success);

        // Token burn happens too late.
        moonToken.burnAccount(msg.sender);
    }
}

Why It Is Exploitable

The root cause of cross-contract reentrancy attack is typically caused by having multiple contracts mutually sharing the same state variable, and some of them update it insecurely.

Attack flow:

  1. Attacker deposits 1 ETH → MoonToken mints 1 MOON.
  2. Attacker calls InsecureMoonVault.withdrawAll().
  3. The vault sends 1 ETH back before calling moonToken.burnAccount().
  4. In the attacker’s receive(), they call a second DependentVault.borrow() that reads moonToken.balances(attacker) as collateral.
  5. The balance is still 1 MOON (not yet burned). The borrow succeeds.
  6. burnAccount finally fires, but the loan is already out.

The root bug: two contracts share one authoritative state, and the primary contract creates a window where that state is stale.

The Fix

function withdrawAll() external nonReentrant {
    uint256 balance = moonToken.balances(msg.sender);
    require(balance > 0, "No tokens");

    // ✅ Burn tokens FIRST — even though burnAccount is an external call,
    //    MoonToken is a trusted, controlled contract.
    moonToken.burnAccount(msg.sender);

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

The withdrawAll function should be improved by moving the effect (moonToken.burnAccount(msg.sender)) to execute before the interaction (msg.sender.call{value: balance}). This coding pattern guarantees that the withdrawer’s balance would be updated before sending ETH back to the withdrawer, impeding the cross-contract reentrancy attack.


Variant 4 — Read-Only Reentrancy

What It Is

This is the youngest and most underestimated member of the family. Read-Only Reentrancy (ROR), a new type of reentrancy vulnerability first reported in 2022, is a cross-DApp attack that specifically exploits functions in different DApps’ contracts.

A read-only reentrancy reenters view functions which, in contrast to state-altering functions, lack reentrancy guards — enabling the read-only reentrancy. While the reentered contract cannot be affected by its view function, others reading the contract’s state can.

Also known as “read-only external call reentrancy,” this refers to a specific type of reentrancy vulnerability where an external call is made to another contract, but the called contract’s function does not modify its state. Instead, the called function reads data from the calling contract and then reenters the calling contract, potentially causing unexpected behavior. Although the called function does not modify state, it can still influence the control flow or behavior of the calling contract, posing a security risk.

The key difference between ROR and traditional reentrancy is that ROR takes place between the smart contracts of independent DApps, while traditional reentrancy performs within the smart contract(s) of a single DApp.

Real-World Reference: Curve / ChainSecurity Discovery

The canonical read-only reentrancy scenario mirrors the Curve LP oracle finding disclosed by ChainSecurity. Given that ETH will be the first coin transferred out, token balances and total LP token supply will be inconsistent during the execution of the fallback function. Leveraging this inconsistency in the state could work if get_virtual_price somehow depended on its balances and its LP token’s total supply.

In the Curve Finance case, it wasn’t Curve that was exploited. It was contracts that depended on it. The attack flow: the attacker deposits ether and other ERC20 tokens into Curve. Curve mints liquidity tokens to the attacker. The attacker withdraws liquidity by burning the liquidity tokens. Curve sends back ether before sending back the ERC20 tokens. When Curve sends back Ether, the attacker regains control and conducts a trade on another contract. The contract that depends on Curve asks Curve for the price ratio between the liquidity tokens, ether, and the other ERC20 tokens.

Read-only reentrancy exploits view functions that return stale data during an ongoing state transition, causing other protocols to make incorrect decisions based on wrong data.

Vulnerable Pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IPool {
    // View function — no state changes, no reentrancy guard
    function getVirtualPrice() external view returns (uint256);
}

/// @dev A lending protocol that uses a pool's virtual price as a collateral oracle.
///      It has NO reentrancy guard on its own functions because no ETH is sent here.
contract VulnerableLendingProtocol {
    IPool public pool;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;

    constructor(address _pool) {
        pool = IPool(_pool);
    }

    function borrow(uint256 lpTokensDeposited) external {
        collateral[msg.sender] = lpTokensDeposited;

        // ⚠️  This view call is unguarded.
        // If pool is mid-withdrawal (ETH sent, ERC20 not yet returned),
        // getVirtualPrice() returns an inflated value.
        uint256 price = pool.getVirtualPrice();
        uint256 borrowable = (lpTokensDeposited * price) / 1e18;

        debt[msg.sender] += borrowable;
        // Issue the loan — based on a corrupted price.
        payable(msg.sender).transfer(borrowable);
    }
}

Why It Is Exploitable

The exploit hinges on the intermediate state window inside pool.remove_liquidity():

pool.remove_liquidity() begins
  ├─ LP tokens burned          ← totalSupply ↓
  ├─ ETH returned to attacker  ← attacker gains control
  │     └─ fallback() fires
  │          └─ VulnerableLendingProtocol.borrow()
  │               └─ pool.getVirtualPrice()
  │                    returns INFLATED price  ← ERC20s still in pool,
  │                                               LP supply already shrunken
  └─ ERC20s returned           ← state normalizes, but loan is already issued

getVirtualPrice() is a view function — it touches no state. Yet it reads a temporarily inconsistent ratio. The lending protocol trusts this oracle unconditionally, with no guard.

In the recent three years, attack incidents of ROR have already caused around 30M USD losses to the DApp ecosystem.

The Fix

There are two complementary approaches:

Option A — Make the reentrancy lock public so dependents can check it:

// In the pool contract
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 public reentrancyStatus = _NOT_ENTERED; // public!

modifier nonReentrant() {
    require(reentrancyStatus == _NOT_ENTERED, "Reentrancy guard");
    reentrancyStatus = _ENTERED;
    _;
    reentrancyStatus = _NOT_ENTERED;
}

// In the dependent lending protocol
function borrow(uint256 lpTokensDeposited) external {
    // ✅ Check the pool's own lock before trusting its view function
    require(
        pool.reentrancyStatus() == _NOT_ENTERED,
        "Pool is in mid-transaction"
    );
    uint256 price = pool.getVirtualPrice();
    // ... rest of logic
}

Option B — Guard the view functions themselves:

// Apply nonReentrant to the view function in the pool
function getVirtualPrice() external view nonReentrant returns (uint256) {
    // Solidity view + nonReentrant is valid; guard prevents entry
    // while the pool is in an interaction.
    return _computeVirtualPrice();
}

There are two ways to defend against read-only reentrancy or cross-contract reentrancy. One is to make the reentrancy lock public, or make the view functions non-reentrant as well.

Never use `getVirtualPrice()`, `get_dy()`, or any pool oracle function from another protocol without first verifying that the source pool is not mid-execution. A seemingly innocuous `view` call can return price data that is seconds away from correcting itself — after your funds are gone.

Prevention Patterns

Pattern 1 — Checks-Effects-Interactions (CEI)

CEI is the foundational ordering discipline for every Solidity function that makes external calls.

This pattern mandates a strict ordering of operations within a function: Check — validate all prerequisites and conditions (e.g., require statements). Effects — apply all internal state changes to the contract’s variables. Interactions — execute external calls to other contracts or addresses.

By updating all internal state variables before making any external calls, the contract ensures that its internal state is consistent and correct, even if a re-entrant call occurs. Any re-entrant call would then operate on the updated, correct state, preventing illicit withdrawals.

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

    // ── EFFECT ────────────────────────────
    balances[msg.sender] -= amount;

    // ── INTERACTION ───────────────────────
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

CEI is necessary but not always sufficient. Cross-function and cross-contract variants require guards in addition to correct ordering.


Pattern 2 — ReentrancyGuard (Mutex Lock)

OpenZeppelin’s ReentrancyGuard implements a boolean mutex stored in a single storage slot:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract ProtectedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

Use OpenZeppelin’s ReentrancyGuard for complex functions.

ReentrancyGuard only protects functions within the same contract. Cross-contract and read-only reentrancy can bypass it entirely. Always combine it with CEI ordering and public lock exposure for protocols that serve as price oracles.


Pattern 3 — Pull-Over-Push Payments

By giving control to users through the pull-over-push model, we prevent the contract from directly interacting with external fallback functions during fund transfers, which greatly minimizes the attack surface.

Instead of pushing ETH to recipients inside business logic, accumulate owed amounts and let users pull them in a dedicated, minimal withdrawal function:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title PullPaymentVault — no ETH is pushed during business logic
contract PullPaymentVault {
    // Pending withdrawals are credited here, never sent automatically.
    mapping(address => uint256) public pendingWithdrawals;

    /// @notice Business logic only credits; it does not transfer.
    function settleAuction(address winner, uint256 amount) internal {
        pendingWithdrawals[winner] += amount;
    }

    /// @notice Minimal function dedicated to withdrawals.
    ///         Even if a reentrant call occurs here, it finds
    ///         a zeroed balance and does nothing.
    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        // Effect first (CEI still applies inside pull functions)
        pendingWithdrawals[msg.sender] = 0;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

The pull-over-push model remains recommended even when using nonReentrant, because pull-over-push directly addresses the vulnerability by removing external interactions during fund transfers.

The trade-off: interacting with a contract that uses pull instead of push payments requires the users to send one additional transaction, namely the one requesting the withdrawal. This does not only lead to higher gas requirements and therefore higher transaction costs, but also harms the user experience as a whole. For high-value protocols the security gain outweighs the UX cost.


Pattern 4 — Transient Storage Guard (EIP-1153)

Solidity 0.8.24 supports the opcodes included in the Cancun hardfork and, in particular, the transient storage opcodes TSTORE and TLOAD as per EIP-1153. Transient storage is a long-awaited feature on the EVM level that introduces another data location besides memory, storage, calldata, and others. The new data location behaves as a key-value store similar to storage with the main difference being that data in transient storage is not permanent, but is scoped to the current transaction only, after which it will be reset to zero.

The specialty of transient storage is that it persists through call contexts. This is perfect for scenarios like reentrancy guards that can set a flag in transient storage, then check if that flag has already been set throughout the context of an entire transaction. Then, at the end of the entire transaction, the guard will be wiped completely and can be used as normal in future transactions.

Consequently, transient storage is as cheap as warm storage access, with TSTORE and TLOAD priced at 100 gas. This makes it almost 2000 gas units cheaper than OpenZeppelin’s pre-Cancun implementation.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28; // Requires solc >=0.8.28 for `transient` keyword

/// @title TransientGuard — EIP-1153 powered reentrancy lock
contract TransientGuard {
    // As of Solidity 0.8.28, `transient` is a valid storage location.
    // The variable is reset to its zero value after each transaction.
    bool transient private _locked;

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

    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

For environments below 0.8.28, use inline assembly:

modifier nonReentrant() {
    assembly {
        // Slot 0 of transient storage used as lock
        if tload(0) { revert(0, 0) }
        tstore(0, 1)
    }
    _;
    assembly {
        tstore(0, 0)
    }
}

The use of transient storage for reentrancy guards that are cleared at the end of the call is safe.