All articles
Security FundamentalsJune 11, 20268 min read

Reentrancy Explained, 2026 Edition (After Curve, Cream, and DAO)

Reentrancy in 2026 is not just the DAO bug. Three flavors, three defenses, and why the Curve Vyper bug expanded the attack surface beyond what most auditors model.

By Carlos (Bloqarl)

TL;DR

  • Reentrancy is what happens when an external call gives an attacker a chance to call back into your contract before your state is updated. The classic flavor is the DAO hack of 2016, but the bug class has evolved.
  • Three modern flavors: classic single-function (DAO, $60M, 2016), cross-function (state shared between two functions, attacker exploits the gap), cross-contract (Cream, $130M, 2021, exploit spans multiple contracts via callbacks or ERC777 hooks).
  • Three defenses: checks-effects-interactions (CEI) pattern, reentrancy guards (mutex variable), transient storage (EIP-1153) for gas-efficient mutex.
  • The Curve Vyper bug ($62M, 2023) showed that reentrancy guards at the source-language level don't help if the compiler emits broken bytecode. The defense was correctly written; the compiler dropped it.
  • Modern auditing has to model all three flavors plus compiler-level failures. CEI alone is insufficient.

Why this matters

Reentrancy is the most documented vulnerability class in smart contract history. Every Solidity course covers it. Every auditor knows the pattern. And it keeps producing exploits worth tens to hundreds of millions of dollars.

The reason it keeps producing exploits: the attack surface has expanded over time. The 2016 DAO hack is no longer the canonical case. Modern reentrancy attacks span multiple contracts, exploit token-standard hooks (ERC777, ERC-1155 callbacks), and in the Curve case, exploit compiler-level failures that defeated correctly-written defenses.

If you're a Solidity developer or auditor, "I understand reentrancy because I read about the DAO" is no longer enough. You need the full taxonomy and the modern defenses.

The three flavors

Flavor 1: classic single-function reentrancy

The pattern that destroyed The DAO in 2016.

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

The bug: the external call happens before the balance is zeroed. If msg.sender is a malicious contract, its receive/fallback function can call withdraw() again recursively. Each recursive call sees balances[msg.sender] still nonzero, and another withdrawal goes through.

The DAO attacker drained $60M (then ~$60M, now closer to $300M+ at modern ETH prices) over multiple recursive withdrawals before anyone could intervene. Ethereum eventually hard-forked to recover the funds, creating Ethereum and Ethereum Classic as separate chains.

The fix: update state BEFORE the external call.

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

This is the Checks-Effects-Interactions pattern: validate inputs (Checks), update state (Effects), make external calls (Interactions). In that order. Most reentrancy bugs are CEI violations.

Flavor 2: cross-function reentrancy

State shared between two functions. The attacker enters one function during the external call from another.

// VULNERABLE
mapping(address => uint) public balances;

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

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

The bug: during withdraw()'s external call, the attacker's contract calls transfer(). At that moment, balances[msg.sender] is still nonzero (state not yet updated). The attacker transfers their balance to a second address they control. Now balances[msg.sender] is zero, and after the external call returns, balances[msg.sender] = 0 is a no-op. But the second address has the balance, AND the original withdrawal still receives the ETH.

CEI on withdraw() alone doesn't fix this; the attack uses transfer() while withdraw() is mid-execution. You need a reentrancy guard.

// CORRECT (with reentrancy guard)
modifier nonReentrant() {
    require(!locked, "REENTRANT");
    locked = true;
    _;
    locked = false;
}

function withdraw() external nonReentrant { ... }
function transfer(...) external nonReentrant { ... }

The guard is a mutex: only one function can hold it at a time. Even if withdraw() is mid-execution, transfer() reverts because the lock is held.

OpenZeppelin's ReentrancyGuard implements this pattern. Most production contracts use it.

Flavor 3: cross-contract reentrancy

The attack spans multiple contracts. Often involves token-standard callbacks.

ERC777 tokens have a tokensReceived hook: when a contract receives ERC777 tokens, the token contract calls back into the recipient. ERC1155 has onERC1155Received. ERC721 has onERC721Received. All of these are reentrancy vectors.

// VULNERABLE
contract LendingPool {
    mapping(address => uint) public deposited;
    
    function deposit(uint amount) external {
        token.transferFrom(msg.sender, address(this), amount);  // ERC777 hook fires here
        deposited[msg.sender] += amount;
    }
}

The bug: during transferFrom, the ERC777 token's tokensReceived hook is called on msg.sender's contract. That contract can call withdraw() (or some other function) on the LendingPool while deposit() is mid-execution. State is split: deposited[msg.sender] not yet incremented, but tokens have already moved.

The Cream Finance hack of 2021 was this pattern. Cream had used a non-standard ERC777-like token (AMP) without realizing the callback would let attackers reenter via cross-contract paths. Loss: $130M.

The fix: nonReentrant guards across the contract boundary. If deposit() and withdraw() are both guarded, and the guard is contract-scoped (one variable shared across both), cross-function reentrancy is prevented.

But: the guard has to live on the FIRST contract entered. If the attack starts on Contract A, calls Contract B (which has guards), and B's call back to A is the reentrancy, A's guards matter. Most modern protocols put guards on every external function as a defensive default.

The defenses

Checks-Effects-Interactions

Cheap, simple, fixes most classic reentrancy. Always use it. But not sufficient on its own for cross-function or cross-contract.

ReentrancyGuard (mutex)

OpenZeppelin's ReentrancyGuard is a standard import. Costs ~5,000 gas per call (one SLOAD + one SSTORE for the lock variable). Necessary defense for cross-function. Standard in production code.

Transient storage (EIP-1153)

Released with Cancun in 2024. Provides storage slots that exist only for the duration of a transaction (auto-cleared at the end). Replaces ReentrancyGuard's SSTORE with TSTORE (much cheaper, ~100 gas).

modifier nonReentrant() {
    bytes32 slot = TRANSIENT_LOCK_SLOT;
    assembly {
        if tload(slot) { revert(0, 0) }
        tstore(slot, 1)
    }
    _;
    assembly { tstore(slot, 0) }
}

OpenZeppelin's ReentrancyGuardTransient provides this in a standard import.

The gas savings are real: roughly 4,900 gas per protected call. For high-throughput protocols (DEXes, lending protocols), this saves tens of dollars per transaction at high gas prices.

The catch: transient storage is recent. Some chains may not support EIP-1153 yet. Check your deployment target.

The Curve Vyper case

In July 2023, Curve Finance lost approximately $62M across multiple stable pools. The vulnerability: Vyper (Curve's smart contract language, an alternative to Solidity) versions 0.2.15, 0.2.16, and 0.3.0 had a compiler bug that broke reentrancy guards.

The Vyper code looked correct. It used @nonreentrant('lock') decorators, the language's standard reentrancy defense. But the compiler emitted bytecode that didn't actually enforce the guard. The same lock variable was used for unrelated functions, allowing reentrancy to bypass it.

Implications:

  1. Compiler-level vulnerabilities exist. Defenses you write at the source level depend on the compiler emitting correct bytecode. If the compiler is buggy, your defense is paper.

  2. Source-code audits don't catch this. Auditors review the Vyper code, see the @nonreentrant, conclude the function is safe. The bug is invisible at the source level.

  3. Bytecode review or formal verification is required. To catch a Curve-class vulnerability, you have to either review the actual emitted bytecode or use formal verification tools that check the compiled output against the spec.

  4. Trust in the compiler matters. Solidity has a long history of compiler bugs (each release announces fixes). Vyper is younger and has fewer downstream users. Both can have bugs.

What changed after Curve: many DeFi protocols added bytecode-level audits to their security stack. Some added formal verification on the highest-value invariants. Reentrancy is no longer "we used the guard" as a sufficient defense; you need to prove the guard is actually present in the deployed bytecode.

Related questions

What's the difference between view reentrancy and write reentrancy? View reentrancy reads stale state during a callback (e.g., reading a price that's mid-update). Write reentrancy mutates state during a callback. Both are real attack classes. Modern guards typically protect both, but legacy nonReentrant decorators sometimes only blocked write reentrancy.

Can I write reentrancy-safe code without any guard? In principle, yes. Strict CEI-pattern code that NEVER makes external calls in the middle of state updates is reentrancy-safe by construction. In practice, complex protocols always have edge cases. Use guards.

Are external calls always dangerous? No. Calls to known, trusted contracts (Chainlink oracles, Uniswap pairs you operate) are typically safe. Calls to user-controlled addresses (msg.sender, arbitrary to parameters) are the dangerous ones.

Does using transfer() instead of call() prevent reentrancy? transfer() forwards 2300 gas, which used to be enough to receive ETH but not enough to do anything malicious. After EIP-2929 (Berlin), gas costs changed and transfer() started failing on legitimate recipients. Modern best practice: use call() with proper guards, not transfer().

What about delegatecall reentrancy? delegatecall runs code in the caller's storage context. If the called contract's logic itself is reentrant, the storage is the caller's, which can corrupt the caller. Treat delegatecall targets as part of your contract for security purposes.

How do I test for reentrancy? Write a malicious contract that calls back into the target during expected callback points. Run unit tests where the malicious contract is the user. If your tests don't include this pattern, you're not testing for reentrancy.

Where to see this in Academy

Reentrancy patterns are covered across multiple Academy modules. The Uniswap V2 reconstruction implements the unlocked mutex pattern, and its test suite verifies the lock under simulated reentrant calls. The AI Auditor Builder uses DAO- and Cream-style exploits as calibration anchors for security-analysis primers.

The Shadow Arena targets include several real protocol forks where reentrancy-adjacent bugs were documented. Auditing these gives you hands-on exposure to the modern attack surface, including cross-contract and ERC777-hook variants.

When you rebuild Uniswap V2 from scratch, the reentrancy guard implementation is part of the test suite. You can't pass the swap test until the mutex is in place. That makes the defense viscerally clear in a way that reading about it doesn't.

Tagged

Smart Contract SecurityReentrancySolidity