$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

Reentrancy Attacks Explained: Every Type and How to Find It

Updated 7 min read

Quick answer. A reentrancy attack happens when a contract hands control to an external address while some of its state is outdated. The attacker calls back in, through any function, contract or view, and exploits that stale state.

The reentrancy attack is probably the most infamous vulnerability in web3. It is also one of the most common ones we still find in audits.

The canonical example is well understood. Yet reentrancy keeps appearing in more complex forms that do not fit it: cross-function, cross-contract, read-only, ERC-721 and ERC-777 reentrancy.

This guide covers each type, from simple to subtle. It ends with a system for finding every reentrancy vector and the maximum impact of any external call.

1. Classic Reentrancy

Start with a canonical vault. A user deposits a balance, then calls withdraw. The vault sends the ether with .call before it updates the user's balance.

function withdraw() external {
    uint256 bal = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: bal}(""); // attacker takes control here
    require(ok, "send failed");
    delete balances[msg.sender]; // too late
}

The call runs the sender's receive or fallback function. That hands the sender control of the transaction.

The attack contract first deposits to get a balance. When it withdraws, its receive function calls withdraw again, and again, until the vault has nothing left.

receive() external payable {
    if (address(vault).balance >= vault.balances(address(this))) {
        vault.withdraw(); // re-enter before the balance is cleared
    }
}

Only at the very end does the vault delete the balance. By then the attacker has withdrawn a multiple of it and drained the vault.

This shows the fundamental problem behind every reentrancy bug. A piece of state is outdated at the moment the contract hands execution to an arbitrary external address.

Checks, effects, interactions

One fix is a nonReentrant modifier on withdraw. As the examples below show, the better fix is to update everything before any external call. This is the checks-effects-interactions pattern:

  1. Checks. All require statements and validation: inputs are correct and the state is what you expect.
  2. Effects. All state updates and event emissions.
  3. Interactions. Only then, the external calls.
function withdraw() external {
    uint256 bal = balances[msg.sender];
    require(bal > 0, "nothing to withdraw");       // checks
    delete balances[msg.sender];                    // effects
    (bool ok, ) = msg.sender.call{value: bal}("");  // interactions
    require(ok, "send failed");
}

With the effect done before the interaction, the sender can no longer exploit an outdated balance.

2. Cross-Function Reentrancy

Now add a nonReentrant guard to withdraw, but keep the call before the balance update. The attacker cannot re-enter withdraw: a second entry in the same transaction reverts.

The contract also has a transfer function, and that is the opening.

function withdraw(uint256 amount) external nonReentrant {
    uint256 bal = balances[msg.sender];
    require(bal >= amount, "insufficient");
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "send failed");
    balances[msg.sender] = bal - amount; // overwrites changes made during the call
}

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "insufficient");
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

When withdraw hands over control, the balance is still outdated. The attack contract deposits, then withdraws.

In its receive function, it reads its balance and transfers all of it to another address it controls.

Back in withdraw, the balance is set to the cached balance minus the amount. That overwrites the subtraction transfer made, so the tokens are duplicated.

When you think about reentrancy, think beyond single functions. Every function in the contract is a possible way back in.

3. Cross-Contract Reentrancy

Once a system spans multiple contracts with several entry points, the attack surface grows dramatically.

Consider a simplified two-step swap system. It is inspired by an issue the GMX V2 codebase had very early on, though the real issue was different and more complex.

  • The swap manager. A user creates a swap request. The user can also cancel it and get the swap amount back.
  • The swap executor. A permissioned keeper calls executeSwap. The swap sends the resulting ether to the user, and only afterwards is the pending swap deleted.

Every function in both contracts is nonReentrant. So how can anyone re-enter?

function executeSwap(uint256 id) external onlyKeeper nonReentrant {
    swapper.swap(id);       // sends ETH to the user: the user takes control here
    manager.deleteSwap(id); // the effect lands after the interaction
}

Each contract inherits its own reentrancy guard, stored in its own storage. When the keeper calls executeSwap, only the executor's guard is flipped.

The manager's guard still reads "not entered". So while receiving the ether, the user can call cancelSwap on the manager.

The swap has already executed but has not been deleted yet. The user gets back the ERC-20 tokens they put in, and keeps the ether they got out. It is close to risk free.

This is again a failure of checks-effects-interactions. The swap is an interaction and the delete is an effect. Delete first, and everything is up to date before any external address gets control.

4. Read-Only Reentrancy

Read-only reentrancy is best known from exploits, recent at the time of recording (2023), that involved Curve pools and their callbacks. It sounds complex, but it is the same bug you have already seen.

Some state has not been updated before an external call. Third-party systems that read that state while the attacker controls the transaction see inaccurate, outdated values, and can be exploited because of them.

In effect, it is cross-contract reentrancy. Instead of re-entering the same system, the attacker goes from one system into a third-party system that depends on it.

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

function isAllowedToWithdraw(address user, uint256 amount) external view returns (bool) {
    return balances[user] >= amount; // stale while the call above is in flight
}

This vault has no direct reentrancy. The subtraction reverts if it goes negative, so the attacker cannot withdraw twice.

The harmless-looking view function is the problem. While the sender holds control, they already have their ether, but the balance does not show it yet.

isAllowedToWithdraw still returns true. Any third-party contract, or even a frontend, that reads it mid-transaction sees a false value. Depending on how it integrates, that can be very bad for it.

The core of the vulnerability still lies in the vault, which should follow checks-effects-interactions.

Applied to Curve pools, the idea is the same. A pool's price did not yet reflect an action when a callback handed control to a possibly malicious address. Any system reading that price at that moment could be exploited.

ERC-721 and ERC-777 Reentrancy

ERC-721. safeTransferFrom calls onERC721Received on the recipient when it is a contract. That is another external call that can hand control to an arbitrary address while state is outdated.

ERC-777. These tokens expose callbacks that can hand control to the sender, the recipient or both, depending on the implementation.

Treat every token transfer of these standards as an external call. Our weird ERC-20 tokens checklist covers tokens with callbacks in more detail.

A System for Finding Reentrancy

Every reentrancy bug starts with an external call to an arbitrary address, made while state is not yet updated. That is why checks-effects-interactions works so well.

It also gives you a closed, repeatable way to hunt for these bugs.

  1. Enumerate every external call. Focus on calls to untrusted accounts or arbitrary addresses, especially those made before state updates. A static analysis tool like Slither makes this list fast.
  2. List the knobs for each risky call. For each one, write down every piece of state that is outdated at the moment control passes. For example: "this call is risky because X and Y have not been updated yet."
  3. List every entry point. Write down every external function a re-enterer can call on the system. This is the surface they can come back through.
  4. Walk every pair. For each risky call and each entry point, consider the impact of the outdated state. Ask: what advantage can I gain by re-entering this function, knowing X is about to be updated to Y?

This turns reentrancy review into a deterministic, manual process where you consider each scenario in turn.

For read-only reentrancy, treat any deviation from checks-effects-interactions as potentially risky. Outdated state read by a third-party system during an external call can damage every system built on top.

For the wider family of risks around calling other contracts, see our guide to external call attacks. For other bugs to catch before you deploy, see our list of sneaky Solidity bugs.

How Guardian Helps

Guardian's smart contract audits trace protocol logic, integrations and privileged flows with exploit-driven review and proof-of-concept depth.

With Vanguard, two independent teams review the same scope and cross-check each other, backed by an invariant suite around your safety properties.

Frequently Asked Questions

What is the root cause of every reentrancy attack?

A piece of state is outdated when the contract hands execution to an arbitrary external address. The attacker acts on that stale state through any function or system that reads it.

Does a nonReentrant modifier prevent reentrancy?

Not on its own. An unguarded function can still be re-entered, and each contract's guard lives in its own storage. Following checks-effects-interactions removes the outdated state the attack needs.

What is read-only reentrancy?

A third-party system reads a contract's view function mid-transaction, while its state is outdated. The view returns a false value, and the third party is exploited.

Can NFT transfers cause reentrancy?

Yes. ERC-721 safeTransferFrom calls onERC721Received on a contract recipient, which hands it control. ERC-777 tokens similarly call hooks on the sender or recipient.

Sources

Find every way back in with Guardian

Guardian's researchers map every external call and every entry point before an attacker does.