Reentrancy in Solidity — The Bug That Cost DeFi $400M+

Reentrancy isn't dead. It killed The DAO in 2016, drained Cream Finance for $130M in 2021, and bit Curve in 2023 via a Vyper compiler bug. Same root cause every time: state updated after the external call. Here's how to actually fix it.

Reentrancy (SWC-107) occurs when a contract makes an external call before updating its own state, allowing the callee to re-enter the original function and exploit stale state — typically to drain funds. The fix is the checks-effects-interactions pattern: validate inputs, mutate storage, then call external contracts last. For defense in depth, add OpenZeppelin's ReentrancyGuard nonReentrant modifier. Cross-function and read-only reentrancy variants exist too, so guard all functions that share mutable state, not just the obvious withdraw.
··8 min read

The Bug in One Sentence

If your contract sends ETH or calls another contract **before** it updates its own balances, the receiver can call you back and spend the same money twice. That's it. That's reentrancy.

The Ethereum community has known about this since June 2016, when [The DAO](https://en.wikipedia.org/wiki/The_DAO) was drained of 3.6M ETH (~$60M at the time, north of $10B today) and forced the hard fork that created ETH and ETC. Nine years later, auditors still find it in production code every week.

The Classic Vulnerable Pattern

```solidity
// VULNERABLE — do not deploy
mapping(address => uint256) public balances;

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

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

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

The attacker deploys a contract with a `receive()` that calls `withdraw()` again. Because `balances[msg.sender]` is still non-zero when execution re-enters, the check passes and ETH flies out on every recursive call until gas runs out or the pool is empty.

```solidity
contract Attacker {
Vulnerable target;
constructor(address t) { target = Vulnerable(t); }

function pwn() external payable {
target.deposit{value: msg.value}();
target.withdraw();
}

receive() external payable {
if (address(target).balance >= msg.value) {
target.withdraw();
}
}
}
```

The Fix: Checks-Effects-Interactions

Reorder the function so all storage mutations happen **before** any external call. Solidity has had this as official guidance since the language was a few months old.

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

balances[msg.sender] = 0; // effects

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

For defense in depth — especially in protocols with dozens of entry points that share state — add OpenZeppelin's `ReentrancyGuard`:

```solidity
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
function withdraw() external nonReentrant {
// ...
}
}
```

`nonReentrant` flips a storage slot from 1→2 on entry and back on exit. Cheap, idiot-proof, and worth the ~2,300 gas every time.

The Variants Auditors Catch and Devs Miss

The textbook example is the easy one. The expensive bugs come from the cousins.

Cross-Function Reentrancy

Two functions touch the same balance mapping. Only one has `nonReentrant`. The attacker re-enters via the unguarded sibling — `transfer()` while you're inside `withdraw()`, for example. Lesson: guard the **state**, not the function. Either every function that touches shared mutable state gets the same lock, or none of them do anything dangerous after external calls.

Read-Only Reentrancy

This one ate **Curve Finance for ~$73M in July 2023** — though that root cause was a Vyper 0.2.15/0.2.16 compiler bug that disabled reentrancy locks on `@nonreentrant('lock')` decorators. Pools that *thought* they were guarded weren't. Downstream protocols reading Curve's `get_virtual_price()` mid-callback got poisoned price data. If your protocol consumes another contract's view functions as an oracle, that contract must be reentrancy-safe **for reads**, not just writes.

ERC-777 and Token Hooks

**Cream Finance lost $130M in October 2021** through a reentrancy via ERC-777's `tokensReceived` hook combined with price manipulation. **Fei Protocol / Rari lost ~$80M in April 2022** the same way — Rari's `cTokens` called external contracts before updating internal accounting. Any token with transfer hooks (ERC-777, ERC-1155, ERC-721 `safeTransferFrom`) is an external call. Treat it like one.

Practical Checklist

1. **Reorder every function**: checks → effects → interactions. No exceptions.
2. **Apply `nonReentrant`** to all external functions that move value or mutate shared state.
3. **Treat token transfers as external calls.** They are.
4. **Assume view functions can be called mid-state-transition.** If a view reads halfway-updated storage, your oracle consumers are exposed to read-only reentrancy.
5. **Don't trust your imports.** Curve's pools were "protected" — the protection just didn't compile correctly. Pin compiler versions and audit them.
6. **Run [a free AI scan](https://www.cryptohawking.com/audit)** before every deployment as a cheap first pass.
7. For anything holding real TVL, get [a manual audit](https://www.cryptohawking.com/audit/manual) — humans catch the cross-function and economic-logic variants that pattern-matchers don't.

Why It Still Happens in 2025

Three reasons. First, devs copy code from tutorials that predate hooks-bearing tokens. Second, "I'm calling a trusted contract" is famous last words — Cream trusted ERC-777, Rari trusted its own cTokens, Curve trusted its compiler. Third, modifiers create false confidence: `nonReentrant` on one function doesn't help if the same storage is mutated in three others.

The pattern is 9 years old. The fix is 12 lines of OpenZeppelin. The cumulative losses are still climbing past **$400M**. Use the lock.

FAQ

Does the checks-effects-interactions pattern make ReentrancyGuard unnecessary?

In theory yes — if every function is perfectly ordered and you never call external code before settling state, you're safe. In practice, codebases grow, contributors change, and one PR that adds a fee transfer in the wrong place silently reintroduces the bug. `nonReentrant` is ~2,300 gas of insurance against your future self. Use both. The pattern is your primary defense; the guard is the seatbelt. Cream, Rari, and Curve all believed they were following best practices and still got drained.

What is read-only reentrancy and how do I prevent it?

Read-only reentrancy happens when a view function returns stale or partially-updated state during another contract's execution. Attacker triggers a callback mid-transaction, your view function reports a price/balance that's temporarily wrong, and a downstream protocol that uses it as an oracle gets exploited. Prevention: ensure view functions either reflect fully-settled state or revert when called during a reentrant context. OpenZeppelin's newer `ReentrancyGuard` exposes a `_reentrancyGuardEntered()` helper exactly for this. Curve's $73M loss in July 2023 was the canonical example.

Are `transfer()` and `send()` safe from reentrancy?

Historically yes, because both forward only 2,300 gas — enough to log an event but not to make another call. But this is fragile. EIP-1884 (Istanbul) repriced opcodes and broke contracts that relied on 2,300 gas being enough for legitimate fallback logic. The modern guidance is to use `.call{value: x}("")` with proper checks-effects-interactions plus `nonReentrant`, not to depend on a gas stipend that may change again. Treat the stipend as deprecated.

Can ERC-20 tokens trigger reentrancy?

Plain ERC-20 transfers don't have hooks, so no. But ERC-777 (which is ERC-20-compatible) has `tokensReceived` and `tokensToSend` hooks that call the recipient. ERC-1155 and ERC-721 `safeTransferFrom` invoke `onERC*Received`. Many real tokens — including some that look like vanilla ERC-20 on Etherscan — wrap ERC-777 underneath. The imBTC token, an ERC-777, is what enabled the Cream Finance and Lendf.Me attacks. Always treat token transfers as untrusted external calls.

How do I test for reentrancy in my own contracts?

Three layers. (1) Static analysis: Slither's `reentrancy-eth`, `reentrancy-no-eth`, and `reentrancy-benign` detectors catch most basic cases in seconds. (2) Fuzzing: Foundry invariant tests with a malicious-receiver mock that recurses on every `receive()` will surface cross-function variants. (3) Manual review of every external call — for each one, ask: what state is read after this call returns, and would mutating that state mid-call break an invariant? Tooling catches the obvious; humans catch the economic logic.

One Solidity tip + 1 case study per month