Guide

Reentrancy: the bug that keeps working

It emptied The DAO in 2016 and it is still being deployed today. Here is exactly how it works, the four variants, and the defences that actually hold.

Last reviewed

The mechanism

In most languages, calling a function you did not write is unremarkable — it runs, it returns, your code continues. On the EVM, calling another address hands that address the CPU. It can do anything, including calling straight back into you, before your original function has finished.

That is the whole bug. If your contract is in an inconsistent state at the moment it makes an external call — money sent but not yet debited, a flag not yet set — the callee can re-enter and act on state you were halfway through updating.

Reentrancy is not a flaw in Solidity. It is a consequence of synchronous calls to untrusted code, and the defence is to never be mid-update when you make one.

An attack, step by step

Here is a vault with the classic ordering mistake — the balance is cleared after the transfer.

contract Vault {
    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");

        // Control leaves this contract here.
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

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

withdraw reads the balance, sends the ether, and only then zeroes the record. Everything in between is exploitable, because msg.sender.call runs the attacker's code.

contract Drainer {
    Vault immutable vault;
    constructor(Vault v) { vault = v; }

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

    // Called by the vault's transfer. balances[this] is still
    // the full amount, so withdraw() passes its require again.
    receive() external payable {
        if (address(vault).balance >= msg.value) {
            vault.withdraw();
        }
    }
}

The sequence:

  1. Drainer.attack() deposits 1 ETH. balances[Drainer] is 1 ETH.
  2. It calls withdraw(). The vault reads amount = 1 ETH and sends it.
  3. Sending triggers Drainer.receive(), which calls withdraw() again.
  4. balances[Drainer] has not been zeroed yet, so the require passes and another 1 ETH is sent.
  5. Repeat until the vault is empty. Then the stack unwinds and every frame sets balances[Drainer] = 0 — a balance that no longer means anything.

The attacker withdrew the whole vault against a 1 ETH deposit, and every individual step passed the contract's own checks.

Four variants

1. Single-function

The case above: the re-entered function is the same one that made the call. This is the version everybody knows, and the one a reentrancy guard on that function stops dead.

2. Cross-function

The attacker re-enters a different function that shares the same state. Your withdraw is guarded, but transfer reads the same balances mapping and is not. Mid-withdraw, the balance is still there, so it can be transferred away before it is zeroed. This is why guards belong on every function touching shared state, not just the one that sends money.

3. Cross-contract

Two of your contracts share state through a third. A guard on one contract does not span the other, so the attacker re-enters through the sibling. Common in systems where a vault and a strategy both read a shared accounting contract.

4. Read-only

The subtle one, and the one that has cost the most money recently. Your contract is safe. But a view function of yours is readable mid-update, while your reserves are inconsistent — and some other protocol prices its collateral against it.

// Vulnerable: a view function others price against,
// readable mid-update while the pool is inconsistent.
function getPrice() external view returns (uint256) {
    return (reserveB * 1e18) / reserveA;
}

No state of yours is corrupted. Someone else's liquidation engine reads a price that was true for one instant in the middle of your update, and lends against it. Nothing in your code is wrong in isolation, which is precisely why static analysis struggles here and why composability review is human work.

Three defences

Checks-effects-interactions

Validate inputs, then update your state, then call out. In that order, always. It costs nothing and it eliminates the entire single-function and most of the cross-function class.

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

    balances[msg.sender] = 0;            // effect before interaction

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

A reentrancy guard

A mutex — OpenZeppelin's ReentrancyGuard and its nonReentrant modifier is the standard. Apply it to every externally callable function that touches shared state, not only the ones that transfer. It costs gas, and it is worth it on anything holding value.

Pull over push

Structurally, the best answer: do not send money inside your business logic at all. Record what each address is owed, and let them call a separate claim() that does nothing but zero their credit and transfer. One function makes external calls, it holds no other state, and the surface collapses.

Why it still ships in 2026

Every Solidity developer learns about reentrancy in their first week, and it is still in the top findings every year. Three reasons, none of them ignorance.

The call is not obvious. Nobody writes msg.sender.call and forgets what it does. But _safeMint calls onERC721Received. ERC-777 tokens call hooks on transfer. A token you do not control can call you back on a perfectly ordinary transfer. The external call is often three libraries deep and does not look like one.

The guard is on the wrong function. Teams guard the withdraw and leave the sibling that touches the same mapping unguarded. Cross-function reentrancy exists precisely because partial defence feels like defence.

The refactor moved the line. The ordering was right when it was written. Then a feature added a hook, or a transfer moved above a state update during a cleanup, and nobody re-checked. This is the strongest argument for an audit you can re-run on every change rather than once a year.

Finding it in your code

Mechanically: for every external call, ask what state is not yet final at that line, and whether any function anywhere in your system reads or writes it. Then ask the same question about your view functions, from the perspective of a protocol that prices against them.

Our reentrancy detector flags state writes that follow external calls within a function, which catches the single-function and much of the cross-function class deterministically. The read-only variant needs someone who understands who else reads you — see the Solidity audit page for what the detector suite covers, and the checklist for the other nineteen checks worth doing at the same time.

Keep reading

Ready to see what's hiding in your contract?

Paste an address or your Solidity and get a graphical report. Testnet scans are free and need no account.

Start your auditSee pricing