Skip to main content

This site is for educational purposes only. Nothing here constitutes financial advice.

Lesson 4 — Reentrancy: the bug class that drained the DAO

Reentrancy is the bug class that triggered Ethereum's first hard fork and still drains DeFi protocols. Today: the precise mechanism, why checks-effects-interactions eliminates the class, and the modern variants.

Advanced
Evergreen
24 min readUpdated 2026-06-16Block Clarity Hub Editorial Team

If you only learn one EVM bug class deeply, learn reentrancy. It triggered Ethereum's only contentious hard fork in 2016 (The DAO), has cost protocols including Cream Finance, Fei, Lendf.Me, and many others a combined billion-plus dollars, and despite being the most-warned-about bug class in the entire ecosystem, it continues to drain protocols every year. This lesson goes deep on why.

**The mechanism.** Reentrancy happens when contract A calls into contract B (typically by transferring ETH or invoking a callback hook), and contract B — controlled by the attacker — calls *back into* contract A before A's first invocation has finished. If A's state hasn't been updated yet at the moment of the callback, A's second invocation sees the same stale state as the first, and the attacker can extract more value than they should be entitled to. The classic example is a withdrawal function that sends ETH *before* zeroing the balance — the attacker's fallback receives the ETH, calls withdraw again, the contract checks the still-unchanged balance and sends ETH again, repeating until the contract is drained.

**The checks-effects-interactions (CEI) pattern.** The eliminator is structural: every function should perform (1) checks (require statements validating inputs and state), (2) effects (all state mutations), (3) interactions (external calls and ETH transfers). If you always do effects before interactions, an attacker's callback sees the post-effects state and can't re-enter into the stale-state code path. This pattern is so well-established that any audit report finding a non-CEI function will flag it as a high-severity finding regardless of whether the specific scenario is exploitable.

**ReentrancyGuard as defence in depth.** OpenZeppelin's `ReentrancyGuard` provides a `nonReentrant` modifier that maintains a 'currently executing' boolean: if a function with `nonReentrant` is called while another `nonReentrant` function in the same contract is executing, the call reverts. This is a belt-and-suspenders defence on top of CEI, useful for functions where CEI is hard to apply (e.g., complex interactions across many internal calls). The cost is one storage write per call, but the safety guarantee is meaningful.

**Cross-function reentrancy.** A subtler variant: function `withdraw` is CEI-compliant in isolation. But function `transferShares` reads the same `balances` mapping and doesn't have `nonReentrant`. An attacker calls `withdraw`, in the callback calls `transferShares` (which reads the stale balance), and exfiltrates value through a different state path. The fix is to mark *all* functions that touch the same state as `nonReentrant` against each other (a single mutex covers a 'reentrancy domain' of related functions). The Cream Finance October 2021 exploit was a cross-function reentrancy of this shape.

**Cross-contract reentrancy.** Even subtler: contract A calls contract B; B is the attacker's. B calls contract C (a different contract), and C reads stale state from A. This is harder to defend against because the reentrancy crosses contract boundaries — the `nonReentrant` mutex in A doesn't help. The fix is back to CEI on A (do all state changes before the external call) and being suspicious of any cross-contract callback hook.

**Read-only reentrancy.** A 2022-era discovery: even contracts where the *write* paths are CEI-correct can leak value through *view* functions that report stale state during a callback. Curve's pool `get_virtual_price` was a famous example — during a remove_liquidity callback, it would briefly report inflated price; a protocol that priced its assets off this view function (without using `nonReentrant` on the view) could be exploited. Mitigation: mark price-reading functions with `nonReentrant` even though they appear non-mutating, or use Curve's `claim_admin_fees`-aware patterns.

**ERC-777 / ERC-1155 / ERC-721 callback hooks.** Multiple modern token standards have callbacks that fire during transfer (ERC-777's `tokensReceived`, ERC-1155's `onERC1155Received`, ERC-721's `onERC721Received`). These callbacks were intended for legitimate use (receipt notification) but they turn every transfer into a potential reentrancy vector. Contracts that interact with tokens of these types need to apply CEI to *every* transfer, not just ETH sends — `safeTransferFrom` is unsafe in this sense.

Example

The canonical exploit, distilled. Vulnerable contract: `function withdraw() external { uint256 bal = balances[msg.sender]; (bool ok,) = msg.sender.call{value: bal}(""); require(ok); balances[msg.sender] = 0; }`. The attacker deploys a contract with a fallback `receive() external payable { if (target.balance > 0) target.withdraw(); }` and calls `target.withdraw()`. Trace: (1) target reads bal = 1 ETH; (2) target sends 1 ETH to attacker, triggering attacker's fallback; (3) attacker's fallback checks target balance > 0 (yes) and calls `target.withdraw()` again; (4) target reads bal — still 1 ETH because the first call hasn't reached `balances[msg.sender] = 0` yet — and sends another 1 ETH; (5) recursion continues until target is drained; (6) finally the outermost frame returns and sets `balances[attacker] = 0`, but by then the contract is empty. The CEI fix: move `balances[msg.sender] = 0` *before* the `call`. After the fix, step 4 reads bal = 0 and the recursion does nothing. This is the entire mechanism — for two decades of EVM contracts, billions of dollars of total loss.

Common mistakes

  • Believing reentrancy is fully solved by 'always use call instead of transfer.' The transfer/send/call distinction is about gas stipend, not reentrancy.
  • Treating ReentrancyGuard as a substitute for CEI. ReentrancyGuard is defence-in-depth; CEI is the architecture.
  • Forgetting that ERC-777 / 1155 / 721 transfers can trigger callbacks. ETH sends aren't the only reentrancy vector.
  • Missing cross-function reentrancy. A single `nonReentrant` on `withdraw` doesn't help if `transferShares` reads the same state without the modifier.
  • Ignoring read-only reentrancy. View functions can leak stale state during a callback even when no writes are happening.

Check your understanding

A contract's `withdraw` function is correctly checks-effects-interactions: it updates `balances[msg.sender] = 0` before sending ETH. Another function `lend(uint256 amount)` reads `balances[msg.sender]` to determine how much the caller can lend without modifying it. Is the contract reentrancy-safe?

Key terms covered

Sources & further reading

We prioritise primary sources. Where a topic moves quickly (regulation, security incidents), we re-check sources on the cadence shown by the page's "Next review" date.

Go deeper