Blockchain
Reentrancy and Classic Attacks
On June 17, 2016, a single function in a smart contract allowed $60 million to be stolen in a matter of hours. The attacker didn't break cryptography or brute-force keys. They simply called the withdrawal function, and when the contract sent them ETH, their code called the same function again, and again, and again until the money ran out. The balance wasn't zeroed because the zeroing line came after the transfer. One line of code out of place split Ethereum into two blockchains and forever changed the approach to smart contract security.
- **The DAO (2016, $60M)** - the first and most famous reentrancy hack. The splitDAO() function sent ETH before zeroing the balance. The consequences were so far-reaching that they led to a hard fork and the split of Ethereum into ETH and Ethereum Classic
- **Curve Finance (July 2023, $70M)** - reentrancy through a Vyper compiler vulnerability. A bug in versions 0.2.15–0.3.0 broke the reentrancy lock, allowing re-entry despite protection code being present
- **Rari Capital / Fei Protocol (2022, $80M)** - cross-function reentrancy through a CToken Compound fork. The attacker exploited interactions between borrow and other functions that were not protected by a shared reentrancy guard
Предварительные знания
Reentrancy: Re-entering a Contract
Imagine a bank teller who hands out money from an account but updates the balance **after** the payout. While they are recording the new balance, you walk to the next window and withdraw the same amount again - the old balance hasn't changed yet. That's exactly how **reentrancy** works in smart contracts: an external call passes control to another contract, which **re-enters** the calling contract before its state has been updated.
The EVM has three ways to send ETH: `transfer()` (2300 gas limit, deprecated), `send()` (2300 gas limit, deprecated), and `call{value: ...}("")` (recommended, **no gas limit**). It's `call` that makes reentrancy possible: a receiving contract gets enough gas to execute arbitrary code in its `receive()` or `fallback()` function - including calling back the sender.
Reentrancy is not a theoretical threat. According to the **Rekt Leaderboard**, reentrancy attacks are among the top 5 causes of DeFi losses. The vulnerability works beyond just ETH: any external call to an untrusted address is a potential reentrancy vector, including `safeTransferFrom()` in ERC-721 and ERC-1155, callback invocations, and even calls to oracle contracts.
A contract has a withdraw() function that first sends ETH via call{value}("") and then zeroes the user's balance. Why can an attacker contract call withdraw() again before the balance is zeroed?
The DAO Hack: $60M and the Ethereum Split
June 17, 2016. **The DAO** - the first major decentralized investment fund on Ethereum - controls $150M (about 14% of all ETH in circulation). At 3:34 UTC, an unknown actor begins a series of transactions that drain **$60 million** over the course of hours. The attacker didn't break cryptography, didn't brute-force keys, didn't exploit a bug in the EVM. They used reentrancy in the `splitDAO()` function - the mechanism for leaving the fund and withdrawing your share.
The response was unprecedented: the Ethereum Foundation executed a **hard fork** - a protocol change that returned the stolen funds to their owners. But part of the community flatly rejected this: if a blockchain can be "rolled back" by a group of people's decision, what's the point? The dissenters continued on the original chain - and **Ethereum Classic (ETC)** was born, while the fork became **Ethereum (ETH)** as we know it. One bug in a smart contract split the second-largest blockchain project in two.
An interesting detail: the attacker's funds were locked in a "child DAO" for 27 days (a built-in delay in The DAO's protocol). This gave the community time to decide on the hard fork. Without this delay, $60M would have been irretrievably lost. Today, protocols use **Timelocks** for the same purpose - to allow time to react when an attack is discovered.
The DAO Hack was a turning point for smart contract security. Before it, code auditing was not standard practice. Afterward, firms like Trail of Bits and OpenZeppelin (safe contract libraries), formal verification, and bug bounty programs emerged. Every dollar invested in an audit potentially prevents losses thousands of times larger.
What was the root cause of The DAO vulnerability that allowed $60M to be drained?
Checks-Effects-Interactions: Safe Ordering Pattern
After The DAO Hack, the Solidity community formalized the main rule for preventing reentrancy - **Checks-Effects-Interactions (CEI)**. The idea is simple: every function must execute operations in a strict order. First **Checks** - all condition validations (`require`, `if...revert`). Then **Effects** - all state changes (`balances[user] = 0`). Only at the very end, **Interactions** - external calls (`call`, `transfer`, calls to other contracts). If The DAO had followed CEI, the hack would never have happened.
CEI addresses single-function reentrancy, but more sophisticated variants exist. **Cross-function reentrancy** - the attacker calls not the same function from the callback but a different function of the same contract that depends on state not yet updated. **Read-only reentrancy** - the attacker calls a `view` function from the callback that returns stale data, and that data is used by another contract (e.g., a price oracle).
**Read-only reentrancy** is a relatively new attack vector (2022-2023). If contract A computes an asset's price by calling a `view` function of contract B, and contract B is in the middle of a transaction with an incomplete state, contract A receives a **stale price**. Curve pool + Balancer fell victim to this vector. CEI does not protect against read-only reentrancy - you need a ReentrancyGuard on `view` functions or a lock-status check.
A contract has two functions: withdraw() (follows CEI - zeroes balance BEFORE external call) and transfer() (moves balance to another user). Can an attacker exploit cross-function reentrancy?
ReentrancyGuard: Blocking Re-Entry
CEI is a necessary discipline, but in complex contracts with dozens of functions and interrelated state variables, relying solely on operation ordering is risky. **ReentrancyGuard** is a mutex (mutual exclusion lock) for smart contracts: it physically blocks any re-entry into a protected function while the current call hasn't completed. OpenZeppelin provides a ready implementation used by Aave, Compound, Uniswap, and hundreds of other protocols.
With the Dencun upgrade (March 2024), Ethereum supports **transient storage** (EIP-1153) - storage that is automatically cleared at the end of a transaction. For ReentrancyGuard this is ideal: a lock is only needed for the duration of a single transaction, and transient storage costs just **100 gas** per write instead of 5,000. OpenZeppelin 5.1+ provides `ReentrancyGuardTransient` - a drop-in replacement saving ~4,900 gas per call.
It's important to understand when ReentrancyGuard is **not sufficient**. It protects against re-entry into the **same contract**, but **cross-contract reentrancy** - where the attacker calls a **different contract** from a callback that then accesses the first contract's data - is not prevented by ReentrancyGuard. For this, a **defense in depth** strategy is needed: CEI + ReentrancyGuard + Pausable + proper architecture.
**Security checklist for external calls:** 1. CEI - update state before the call. 2. nonReentrant on all functions with external calls. 3. Pausable on critical functions. 4. Minimal gas forwarding where possible. 5. Pull Payment instead of Push where architecture allows. 6. Watch for read-only reentrancy if your contract exposes `view` functions used by other protocols as a price oracle.
ReentrancyGuard fully protects a contract against all forms of reentrancy attacks, so the CEI pattern is not required when using it
ReentrancyGuard protects against re-entry into the same contract, but is powerless against cross-contract reentrancy: an attacker can call a different contract from a callback that interacts with the first contract's incomplete state. CEI remains a fundamental requirement, and ReentrancyGuard is an additional protective layer
In the DeFi ecosystem, contracts constantly interact with each other: a lending protocol calls an oracle, the oracle calls a DEX, the DEX calls a pool. One contract's ReentrancyGuard cannot control what calls happen between other contracts. That's why production protocols apply defense in depth: CEI + ReentrancyGuard + Pausable + rate limiting + anomaly monitoring. No single mechanism is sufficient on its own.
OpenZeppelin ReentrancyGuard uses uint256 (values 1 and 2) instead of bool (false/true) for storing the lock status. What is the main reason?
Key Takeaways
- **Reentrancy** occurs when an external call passes control to an untrusted contract before state is updated. Through `receive()`/`fallback()`, the attacker re-calls the vulnerable function - state hasn't been updated yet, checks pass, and funds are drained again
- **The DAO Hack (2016)** - $60M stolen through reentrancy in splitDAO(). The consequences went beyond a single contract: a hard fork, a split into ETH and ETC, and the birth of the smart contract auditing industry
- **Checks-Effects-Interactions (CEI)** is the fundamental pattern: all checks, then all state changes, and only then external calls. Protects against single-function reentrancy, but cross-function and read-only reentrancy require additional measures
- **ReentrancyGuard** - a mutex via uint256 (1↔2) that blocks re-entry into the contract. With EIP-1153 (transient storage), the cost drops from ~10,000 to ~200 gas
- No single measure is an absolute defense - one misplaced line in The DAO split Ethereum in two. **Defense in depth**: CEI + ReentrancyGuard + Pausable + monitoring - is the only reliable approach
Related Topics
Reentrancy is a fundamental security topic that connects Solidity patterns with more advanced attacks:
- Smart Contract Patterns — Pull Payment, CEI, and ReentrancyGuard from this lesson are first introduced as patterns. Here we examine them from an attacker's perspective
- Security: Overflow and Underflow — Another class of arithmetic vulnerabilities - integer overflow/underflow. Before Solidity 0.8 they were just as devastating as reentrancy
- Smart Contract Fuzzing — Automated discovery of reentrancy through fuzzing: Echidna and Foundry generate call sequences to find unexpected reentrancy vectors
- Security Auditing — Reentrancy is the first thing auditors check. Audit methodology, checklists, and static analysis tools (Slither, Mythril)
Вопросы для размышления
- The DAO Hack led to a hard fork that returned $60M to users. On one hand, justice was restored. On the other, the 'code is law' principle was violated. Where is the line beyond which intervention is justified? Should blockchain rules be changed to recover stolen funds?
- ReentrancyGuard costs ~5,000–10,000 gas per call to a protected function. In a protocol with millions of transactions, this is a significant overhead. How do you balance security and gas efficiency? Can transient storage (EIP-1153) fully resolve this dilemma?
- Read-only reentrancy is a vector where view functions return stale data during an incomplete transaction. How would you protect a price oracle from this attack, given that view functions cannot use a standard ReentrancyGuard (they don't change state)?