SolutionsProductsAuditsBlogContactRequest an Audit
BlogReentrancy Is Dead. Long Live Reentrancy: The Bug That Won't Stop Killing Protocols
Reentrancy Is Dead. Long Live Reentrancy: The Bug That Won't Stop Killing Protocols
explain9 min readJune 3, 2026
0xTeam Author
Share

Reentrancy Is Dead. Long Live Reentrancy: The Bug That Won't Stop Killing Protocols

Reentrancy burned The DAO in 2016 and Solidity warns about it on page 10 — yet it still drains millions. An autopsy of why the bug mutates: cross-function, read-only, ERC-777 callbacks, and upgrade-diff blind spots.

Every few months, another protocol bleeds millions. The post-mortem drops. The community sighs. And buried somewhere in the technical breakdown is a phrase that shouldn't still be appearing in 2024:

"...due to a reentrancy vulnerability in the withdraw function..."

Reentrancy. The bug that Ethereum practically invented. The bug that burned The DAO in 2016 for $60M and rewrote blockchain history. The bug that Solidity's own documentation warns you about in the first 10 pages.

And yet, here we are.

This isn't a beginner's guide to reentrancy. You've read those. This is an autopsy — of why the bug mutates, why it survives every "lesson learned," and why some of the smartest teams in Web3 keep shipping it into production.

The Original Sin: What Reentrancy Actually Is

Before we go deep, one clean definition.

Reentrancy happens when an external call is made to an untrusted contract before the internal state is updated. The called contract can then re-enter the original function — mid-execution — and exploit the stale state.

The textbook version:

// VULNERABLE
function withdraw(uint amount) external {
    require(balances[msg.sender] >= amount);
    (bool success, ) = msg.sender.call{value: amount}(""); // external call FIRST
    require(success);
    balances[msg.sender] -= amount; // state update AFTER — too late
}

The attacker deploys a contract with a receive() function that calls withdraw() again. Each recursive call passes the require check because balances[msg.sender] hasn't been decremented yet. Funds drain until the contract is empty.

This is the pattern that killed The DAO. And it was patched with a two-word fix: checks-effects-interactions. Update state before making external calls. Done.

Except it's never been done.

Why It Still Kills Protocols in 2024

1. Cross-Function Reentrancy: The Mutated Variant

The simple version — reenter the same function — is easy to spot and easy to guard. ReentrancyGuard from OpenZeppelin, a locked boolean, CEI pattern. Developers have been trained on this.

What developers miss is cross-function reentrancy: where the attacker reenters a different function that shares the same stale state.

Real example of the pattern:

mapping(address => uint) public balances;

function withdraw(uint amount) external {
    require(balances[msg.sender] >= amount);
    (bool success, ) = msg.sender.call{value: amount}(""); // external call
    require(success);
    balances[msg.sender] -= amount;
}

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

The attacker calls withdraw(). During the external call callback, before balances is updated, they call transfer(). The balance check passes — the state still shows the full amount. They transfer the balance to another address they control, then the original withdraw() completes, decrementing from the now-transferred balance.

The withdraw() function has a reentrancy guard? Doesn't matter. The guard only locks that function. transfer() is wide open.

This is not theoretical. This exact pattern — or close variants of it — has been found in live production code by our team multiple times.

2. Read-Only Reentrancy: The Invisible Attack

This one is subtle enough that even experienced auditors underestimate it.

Read-only reentrancy doesn't steal funds directly from the reentered contract. Instead, it exploits the fact that other protocols rely on the state of the reentered contract during the callback window — when that state is temporarily inconsistent.

The canonical example: Curve Finance. In 2023, multiple protocols that used Curve LP token prices as collateral were exploited via read-only reentrancy. Here's the mechanism:

  1. Attacker calls remove_liquidity() on a Curve pool
  2. Curve sends ETH to the attacker (triggering a callback) before updating the LP token virtual price
  3. During the callback, LP token price is stale — artificially inflated
  4. Attacker's malicious contract calls a lending protocol that reads Curve's get_virtual_price() as part of its collateral valuation
  5. Lending protocol sees inflated collateral value, issues a large loan
  6. Callback ends, Curve updates its price — but the loan is already out

The reentrancy happened entirely in a view function. The attacker never "reentered" the vulnerable contract in the classic sense. They reentered the price oracle that other contracts trusted.

Protocols exploited via this vector in 2023 — those built on top of Curve — lost tens of millions collectively. The root wasn't in their own code. It was in trusting an external state that could be transiently manipulated.

If your protocol reads state from any external contract during execution, that state can be manipulated via reentrancy. This includes oracle reads, LP price checks, and vault share calculations.

3. The THORChain Case: When the Fix Introduces the Bug

In July 2021, THORChain was exploited for $8M. In the same month, it was exploited again for $8M via a different vector. Then, in a separate incident, for another $10.8M.

What makes THORChain's reentrancy history instructive isn't that the team was careless. THORChain is staffed by serious engineers. What it illustrates is a systemic failure mode: patching one reentrancy while introducing another in the fix.

The protocol's router contract handled ETH deposits from external contracts. The first exploits revealed issues with how the router validated and processed incoming calls. In the remediation efforts, the team tightened certain checks — but the complexity of the multi-asset swap logic meant that fixing one callback path left another partially exposed.

The $10.8M exploit specifically targeted the router's handling of a custom token that implemented a malicious transferFrom(). When THORChain's deposit function called transferFrom() on this token, the attacker's contract reentered the router with a fake deposit, inflating their pool shares before the real state was settled.

The attack surface wasn't one function. It was the entire surface of any external contract that THORChain touched — which, by design, was enormous. This is the fundamental tension in DeFi security: composability requires external calls, and every external call is a potential reentrancy vector.

4. The ERC-777 / ERC-1155 Trap

A common false assumption: "we don't use .call(), so we're safe from reentrancy." Wrong.

ERC-777 tokens include hooks — tokensToSend() and tokensReceived() — that fire automatically during transfers. These hooks call external contracts. If your protocol transfers ERC-777 tokens and you haven't accounted for these hooks, you have a reentrancy vector hiding inside a standard transfer call.

In 2020, Uniswap V1 and Lendf.me were exploited via ERC-777 reentrancy. The attacker used imBTC (an ERC-777 token) to trigger tokensReceived() during a deposit, reentering Uniswap's liquidity functions before the state was settled. Lendf.me lost $25M in this attack.

ERC-1155 has a similar pattern with onERC1155Received() callbacks on transfers to contracts. The lesson is blunt: any token standard that fires callbacks on transfer is a reentrancy risk. Your code doesn't need call() to be vulnerable. The token itself carries the vector.

5. Upgradeable Proxies and the Ghost of State Past

Here's a scenario we've seen in the wild. Protocol V1 ships. It's audited. Reentrancy guards are in place. Clean.

Protocol V2 ships as an upgrade via a transparent or UUPS proxy. New functions are added. Some interact with external contracts. The developer assumes the existing reentrancy locks cover everything. They don't.

Reentrancy guards use storage slots. In the original implementation, _status is stored at slot X. If the upgrade changes the storage layout — even subtly, even accidentally — the guard variable might be at a different slot, or the guard itself might not be initialized in the new implementation context.

We've found cases where:

  • The guard was initialized in the constructor of V1, but the proxy's initialize() function was never updated in V2 to reinitialize the guard
  • New functions were added that made external calls but weren't decorated with the nonReentrant modifier because the developer assumed the global lock covered them
  • The upgrade introduced a new token integration that used ERC-777, bypassing the existing guards entirely

Upgradeable architecture doesn't make reentrancy harder to introduce — it makes it easier to miss, because the security surface changes with every upgrade and the audit scope is often limited to the "delta."

What Actually Works: The Defensive Stack

Layer 1: CEI, Always, Without Exception

Checks → Effects → Interactions. Update all state before making any external call. This is not optional. It is not situational. It is the baseline.

function withdraw(uint amount) external {
    require(balances[msg.sender] >= amount); // CHECK
    balances[msg.sender] -= amount;          // EFFECT
    (bool success, ) = msg.sender.call{value: amount}(""); // INTERACTION
    require(success);
}

Layer 2: ReentrancyGuard on Every External-Facing Function

Use OpenZeppelin's ReentrancyGuard or equivalent. Apply nonReentrant to every function that makes external calls OR shares state with functions that make external calls. Not just the "obvious" ones. All of them.

Layer 3: Treat External State as Hostile

If your protocol reads price data, LP token values, or any state from an external contract during execution, assume that state can be manipulated. Use TWAPs. Cache values before execution begins. Do not read oracle values mid-function after an external call has been made.

Layer 4: Token Whitelist or ERC-777 / ERC-1155 Awareness

If your protocol accepts arbitrary tokens, you're accepting arbitrary callback behavior. Either whitelist token standards or explicitly handle ERC-777/ERC-1155 callback patterns. Check for ERC1820Registry registrations. Treat any incoming transfer to a contract address as a potential callback trigger.

Layer 5: Reentrancy Testing in Your Test Suite

It's not enough to audit for reentrancy. You need to test for it. Use Foundry's fuzzer to simulate attacker contracts. Write explicit reentrancy attack contracts as test fixtures. If your withdraw function can't survive a malicious receive() function, your test suite should tell you before your users find out.

The Real Problem: Complexity Hides Attack Surface

Every time we've found a reentrancy bug in a protocol that "knew about reentrancy," the root cause wasn't ignorance. It was complexity.

  • A function that was added late in development and never got the full security review
  • An integration with a new token that nobody realized had callbacks
  • An upgrade that changed storage layout in a way that quietly broke an existing guard
  • A cross-contract interaction where both teams assumed the other had handled the reentrancy case

Reentrancy doesn't survive because developers are careless. It survives because protocols are complex, timelines are compressed, and the attack surface is not always visible to the people writing the code.

This is why audits exist — not to rubber-stamp code that developers already believe is safe, but to bring a second set of eyes trained specifically to find what complexity hides.

The 0xTeam Verdict

Reentrancy is not an unsolved problem. The defenses are well-understood. The patterns are documented. The tools exist.

What doesn't scale is assuming your team caught everything — especially in a codebase that's grown over multiple versions, integrated multiple external protocols, and shipped under launch pressure.

We've found reentrancy in protocols that passed other audits. We've found it in code written by teams with strong security cultures. We've found it in places nobody was looking: cross-function paths, read-only vectors, upgrade diffs, token callback chains.

The bug doesn't die because the bug is not the code. The bug is the gap between the code you think you shipped and the code you actually shipped.
++
Worried? Get your security audit done today.

Don't launch vulnerable code. Our team will review your smart contracts and deliver a full audit report within 48 hours.

Request Audit
© 0xTeam space 2026. All rights reserved.