In June 2016, an attacker drained 3.6 million ETH — roughly $50 million at the time — from a smart contract called The DAO. The exploit was devastating enough that Ethereum itself was split in two (Ethereum and Ethereum Classic) to undo the damage. The vulnerability that made it possible was not complex. It was a single line of code, executed in the wrong order.
That vulnerability was reentrancy. Nine years later, it remains one of the most common and damaging smart contract attack vectors. Despite being well-understood and easily preventable, new reentrancy exploits continue to drain millions from DeFi protocols every year.
BLUF: A reentrancy attack occurs when a smart contract sends ether or tokens to an external address before updating its internal state. The external address — controlled by the attacker — uses the
receive()orfallback()function to re-enter the original contract before the state update happens. Because the balance has not been reduced yet, the contract thinks the attacker still has funds, and sends them again. This loop repeats until the contract is drained. The fix is simple: update state before sending funds — the checks-effects-interactions pattern.
How Reentrancy Works
To understand reentrancy, you need to understand how smart contracts handle external calls.
When Contract A sends ether to Contract B, Contract B’s receive() or fallback() function is automatically triggered. This is a feature, not a bug — it allows contracts to respond to incoming payments. But it also creates a vulnerability: Contract B’s code executes before Contract A finishes its own function.
The Vulnerable Pattern
Here is what a vulnerable withdrawal function looks like (simplified):
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0);
// Send ether to the caller
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
// Update balance AFTER sending
balances[msg.sender] = 0;
}
See the problem? The contract checks the balance, sends the ether, and then sets the balance to zero. Between sending and updating, the attacker’s contract gets control via its receive() function. At that point, balances[msg.sender] still holds the original value — the update has not happened yet.
The Attack in Action
The attacker’s contract does this:
- Deposit: The attacker deposits 1 ETH into the vulnerable contract.
balances[attacker] = 1 ETH - Withdraw: The attacker calls
withdraw(). The contract readsbalance = 1 ETH - Send: The contract sends 1 ETH to the attacker’s contract
- Re-enter: The attacker’s
receive()function triggers and callswithdraw()again - Repeat: The contract has not yet set
balances[attacker] = 0, so it readsbalance = 1 ETHagain and sends another 1 ETH - Loop: Steps 3–5 repeat until the contract runs out of ether
The attacker deposited 1 ETH but withdrew far more. The contract never knew what hit it because, from its perspective, every withdrawal was “valid” — the balance check passed every time, since the deduction had not been recorded.
Why It Still Happens
You might assume that after The DAO hack, everyone learned to prevent reentrancy. They did — and yet it still happens. Why?
New developers, old mistakes. Blockchain development attracts new developers constantly. Many learn by building, not by studying attack history. A developer who has never heard of The DAO can write the exact same vulnerable pattern today.
Complex interactions hide reentrancy. The simple example above is easy to spot. But in production DeFi protocols with dozens of interacting contracts, cross-contract reentrancy can hide in unexpected places — a token transfer that triggers a hook, a reward distribution that calls an external price feed, a liquidation that interacts with multiple pools.
Composability cuts both ways. DeFi’s strength — protocols composing with each other — is also its attack surface. Every external call is a potential reentrancy entry point. The more protocols a contract interacts with, the more reentrancy vectors it exposes.
Types of Reentrancy Attacks
1. Single-Function Reentrancy
The classic form, described above. The attacker re-enters the same function before it completes. This is the easiest to prevent and detect.
2. Cross-Function Reentrancy
Two functions in the same contract share a state variable. Function A updates the state, but Function B reads it before A’s update is committed. The attacker triggers B from within A’s external call, exploiting the stale state.
Example: a contract has deposit() and withdraw(). Both read and write balances[msg.sender]. If withdraw() makes an external call before updating the balance, the attacker can call deposit() during the re-entry — manipulating the balance in a way the contract does not expect.
3. Cross-Contract Reentrancy
The vulnerability spans multiple contracts. Contract A makes an external call to Contract B before updating its state. The attacker controls Contract B and uses it to re-enter Contract A. This is harder to detect because the vulnerability is not visible within any single contract’s code — you have to understand how the contracts interact.
4. Read-Only Reentrancy
A newer and subtler variant. The attacker re-enters a contract during a state update, but instead of stealing funds directly, they read a stale state value and use it to manipulate another protocol. Example: a lending protocol reads a price from an AMM during a reentrant call, when the AMM’s state is temporarily inconsistent. The attacker borrows against the manipulated price, then lets the transaction complete. No funds are stolen from the AMM itself — the damage happens in the protocol that trusted the AMM’s stale data.
Read-only reentrancy is particularly dangerous because the vulnerable contract may have reentrancy guards in place for its own functions, but fails to protect view functions that other protocols rely on.
The Checks-Effects-Interactions Pattern
The most reliable defense against reentrancy is the checks-effects-interactions (CEI) pattern. It is not a tool or library — it is a coding discipline:
- Checks: Validate all conditions first (balances, permissions, limits)
- Effects: Update all state variables
- Interactions: Make external calls last — after everything is updated
Applied to our vulnerable withdraw() function:
function withdraw() public {
// CHECKS
uint balance = balances[msg.sender];
require(balance > 0);
// EFFECTS — update state BEFORE sending
balances[msg.sender] = 0;
// INTERACTIONS — send ether last
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
}
Now even if the attacker re-enters withdraw(), the balance is already zero. The check fails, and the attack stops.
The CEI pattern is free, simple, and prevents the vast majority of reentrancy attacks. It should be the default mental model for every Solidity developer.
Additional Defenses
Reentrancy Guards (Mutex)
A reentrancy guard uses a state variable as a lock. When a function starts, it checks if the lock is free; if so, it sets the lock. When the function ends, it releases the lock. If the function is re-entered, the lock is already set, and the call reverts.
OpenZeppelin’s ReentrancyGuard is the standard implementation:
contract SafeContract is ReentrancyGuard {
function withdraw() public nonReentrant {
// ...
}
}
The nonReentrant modifier prevents re-entry for the decorated function. Note: reentrancy guards protect individual functions, but cross-function and cross-contract reentrancy may still be possible if multiple functions share state without coordination.
Pull Over Push
Instead of pushing payments to users (which triggers their receive() function), let users pull their own funds. The contract records what each user is owed, and the user calls a separate function to claim it. This eliminates external calls during state updates entirely — there is nothing to re-enter.
Audit and Formal Verification
Professional audits from firms like Trail of Bits, OpenZeppelin, and CertiK specifically test for reentrancy. Formal verification tools (like Certora or SMTChecker) can mathematically prove that certain properties hold — including the absence of reentrancy.
How to Identify Contracts at Risk
You do not need to read Solidity to assess reentrancy risk. Several public indicators help:
- Check audit reports: Reputable protocols publish their audit reports. Look for reentrancy-related findings and whether they were addressed
- Check for OpenZeppelin imports: Protocols that import
ReentrancyGuardare taking the basic precaution. Its absence is a red flag - Check the protocol’s age and track record: New, unaudited protocols are the most common reentrancy victims. Protocols that have operated safely for years with large TVL have likely been battle-tested
- Check for verified source code: If the contract is not verified on Etherscan, you cannot assess its safety. Treat unverified contracts as high-risk by default
- Watch for complex integrations: Protocols that interact with many external contracts (oracles, AMMs, lending pools) have a larger reentrancy surface. Each integration is a potential entry point
The Legacy of The DAO
The 2016 DAO hack was reentrancy’s introduction to the world. It nearly destroyed Ethereum, triggered the network’s most controversial hard fork, and created the Ethereum / Ethereum Classic split. But it also taught the ecosystem a critical lesson: the order of operations in a smart contract is not a style preference — it is a security boundary.
Modern Solidity includes built-in protections. The language now warns developers about state changes after external calls. OpenZeppelin’s ReentrancyGuard is used by virtually every major DeFi protocol. The CEI pattern is taught in every Solidity tutorial.
And yet reentrancy persists — not because the defense is unknown, but because the attack surface keeps growing. Every new protocol, every new integration, every new contract that touches external code is a potential entry point. The defense is simple. Applying it consistently across millions of lines of code is not.
For more on related attack vectors, read about flash loan attacks, oracle manipulation, and how to spot DeFi protocol red flags.
On-chain analysis helps you understand risk, not eliminate it. Always do your own research before interacting with any smart contract or DeFi protocol.