Reentrancy Attack
A reentrancy attack exploits a smart contract by repeatedly calling back into it before the first execution completes, draining funds.
Key Takeaways
- A reentrancy attack exploits smart contracts that make external calls before updating internal state, allowing an attacker to repeatedly re-enter the vulnerable function and drain funds.
- The 2016 DAO hack is the canonical example: an attacker drained 3.6 million ETH (worth roughly $60M) using a reentrancy exploit, leading to a controversial hard fork that split Ethereum and Ethereum Classic.
- Bitcoin Script is immune to reentrancy by design: its lack of loops, external calls, and mutable state makes this class of vulnerability structurally impossible.
What Is a Reentrancy Attack?
A reentrancy attack is a smart contract exploit where an attacker's contract repeatedly calls back into a vulnerable function before the original execution finishes. Because the victim contract has not yet updated its state (such as the attacker's balance), each re-entry passes the same checks and repeats the same action: typically withdrawing funds. The attack continues recursively until the contract is drained or gas runs out.
The name comes from the concept of "reentrant" code in computer science: code that can be safely interrupted and called again before the first invocation completes. In the context of smart contracts, the re-entry is anything but safe. It exploits a timing gap between when a contract sends funds and when it records that those funds have been sent.
Reentrancy is consistently ranked among the most critical smart contract vulnerabilities. It appears in the OWASP Smart Contract Top 10 (SC05) and remains the root cause of hundreds of millions of dollars in losses across decentralized applications. In 2024 alone, reentrancy attacks caused approximately $47 million in losses across 22 separate incidents.
How It Works
A reentrancy attack targets a specific code pattern: a contract that sends funds (or makes any external call) before updating its internal accounting. Here is the step-by-step attack flow:
- The attacker deploys a malicious contract with a specially crafted fallback function, then deposits funds into the vulnerable contract
- The attacker calls the vulnerable contract's withdraw function
- The vulnerable contract checks the attacker's balance: the check passes because the balance has not been zeroed yet
- The vulnerable contract sends ETH to the attacker's contract via an external call
- The ETH transfer triggers the attacker's fallback function, which immediately calls withdraw again
- Because the balance update has not executed yet, the check passes again and more funds are sent
- This cycle repeats recursively until the contract is drained or the transaction runs out of gas
Vulnerable Code Pattern
The vulnerability stems from performing an external call (the "interaction") before updating state (the "effect"). Here is a simplified example of a vulnerable withdrawal function:
// VULNERABLE: interaction before effect
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0);
// Interaction: sends ETH, triggers attacker's fallback
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
// Effect: balance update happens AFTER the call
balances[msg.sender] = 0;
}The attacker's contract exploits this ordering with a fallback function that re-enters the withdraw function:
// Attacker's malicious contract
receive() external payable {
if (address(target).balance >= amount) {
target.withdraw(); // Re-enter before balance is zeroed
}
}Types of Reentrancy
Reentrancy is not limited to a single function calling itself. Several variants exist:
- Single-function reentrancy: the attacker re-enters the same function that made the external call, as in the classic DAO hack
- Cross-function reentrancy: two functions in the same contract share state variables, and the attacker re-enters through a different function (such as calling transfer instead of withdraw) that reads the same stale balance
- Cross-contract reentrancy: multiple contracts depend on shared state, and during an external call from Contract A the attacker re-enters Contract B which reads Contract A's outdated data
- Read-only reentrancy: a third-party contract queries public view functions on the victim contract during the re-entry window, receiving stale data that leads to incorrect calculations (several 2023 exploits used this pattern against DeFi protocols)
The DAO Hack: the Canonical Example
The DAO (Decentralized Autonomous Organization) was an investor-directed venture fund built on Ethereum, created by the Slock.it team in 2016. Its token sale ran from April 30 to May 28, 2016, raising approximately 12 million ETH: over $150 million and roughly 14% of all Ether in circulation at the time. More than 11,000 investors participated.
On June 17, 2016, starting at 3:34 UTC, an attacker exploited a reentrancy vulnerability in The DAO's splitDAO() function. The function calculated and transferred rewards to callers before updating their token balances. By recursively calling back into the function through a malicious contract's fallback, the attacker drained 3,641,694 ETH (approximately $60–77 million, depending on the rapidly falling ETH price during the attack). The funds moved into a "child DAO" with a built-in 28-day withdrawal lock, giving the community time to respond.
After a failed soft fork proposal (abandoned due to a DoS vulnerability), the Ethereum community executed a hard fork on July 20, 2016, at block 1,920,000. The fork rolled back the chain to before the attack, allowing investors to reclaim their ETH. Roughly 85% of the community voted in favor. Those who rejected the fork on "code is law" principles continued the original chain as Ethereum Classic (ETC).
The DAO hack transformed smart contract security. It popularized the checks-effects-interactions pattern, inspired the creation of formal verification tools, and established smart contract auditing as a standard practice before deploying to mainnet.
Prevention Patterns
Three well-established patterns defend against reentrancy. Production contracts typically combine all three for defense in depth.
Checks-Effects-Interactions (CEI)
The most fundamental defense: restructure code so that all state changes happen before any external calls. The pattern has three phases:
- Checks: validate all preconditions (require statements, access control)
- Effects: update all internal state (zero balances, flip flags)
- Interactions: perform external calls only after state is finalized
// SAFE: checks-effects-interactions pattern
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0); // Check
balances[msg.sender] = 0; // Effect (before interaction)
(bool success, ) = msg.sender.call{value: balance}("");
require(success); // Interaction (after effect)
}Even if the attacker re-enters, the balance is already zero, so the require check fails and the re-entry reverts. This approach uses "optimistic accounting": state is updated assuming the interaction will succeed, and if the external call fails, the entire transaction reverts.
Reentrancy Guards (Mutex Locks)
A reentrancy guard uses a storage variable as a mutex lock. If a function is already executing, any attempt to re-enter it reverts. OpenZeppelin's ReentrancyGuard is the standard implementation:
// OpenZeppelin ReentrancyGuard (simplified)
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private _status = NOT_ENTERED;
modifier nonReentrant() {
require(_status != ENTERED);
_status = ENTERED;
_;
_status = NOT_ENTERED;
}The guard uses values 1 and 2 instead of 0 and 1 because writing a non-zero value to a non-zero storage slot costs less gas than writing from zero. The guard adds approximately 2,300 gas per call. A newer variant, ReentrancyGuardTransient, uses EIP-1153 transient storage opcodes (introduced in the Dencun upgrade) to reduce this cost to roughly 100 gas.
Pull-over-Push (Withdrawal Pattern)
Instead of pushing funds to recipients during execution, the contract records what is owed and lets recipients withdraw separately:
// Pull pattern: record debt, let users withdraw
mapping(address => uint) public pendingWithdrawals;
function release(address payee, uint amount) internal {
pendingWithdrawals[payee] += amount; // Record only
}
function withdraw() public {
uint amount = pendingWithdrawals[msg.sender];
require(amount > 0);
pendingWithdrawals[msg.sender] = 0; // Effect first
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}This pattern isolates each withdrawal into its own transaction, limiting the blast radius. Even if reentrancy occurs, it only affects the withdrawer's own pending balance. The tradeoff is worse user experience: recipients must execute an additional transaction.
Why Bitcoin Is Immune to Reentrancy
Reentrancy is structurally impossible on Bitcoin due to four fundamental design choices in Bitcoin Script:
- UTXO model with no shared mutable state: each transaction consumes specific previous outputs and creates new ones. There is no global account balance or contract storage that could be read in a stale state. A UTXO can only be spent once.
- No loops or recursion: Bitcoin Script is intentionally non-Turing complete. Every script executes a bounded, linear sequence of opcodes with no way to loop back.
- No external calls during execution: there is no opcode for calling another contract. Script execution is entirely self-contained within the transaction being validated.
- Stack-based, stateless execution: the script interpreter operates on a LIFO stack with no persistent state between executions, eliminating the timing gaps that reentrancy exploits.
This stands in direct contrast to Ethereum's account model, where contracts have persistent storage and can call each other during execution. For a deeper comparison, see the research on UTXO vs. account models and Bitcoin Script programmability. Bitcoin's Layer 2 solutions like Spark inherit this security model: by building on Bitcoin's UTXO foundation, they avoid entire classes of smart contract vulnerabilities.
Notable Recent Attacks
Reentrancy is not a solved problem. Despite well-known prevention patterns, attacks continue as DeFi composability creates new interaction surfaces:
| Protocol | Date | Loss | Type |
|---|---|---|---|
| Curve Finance | Jul 2023 | ~$61M | Vyper compiler bug broke reentrancy locks |
| Penpie Finance | Sep 2024 | $27M | Cross-contract reentrancy |
| Conic Finance | Jul 2023 | $4.2M | Read-only reentrancy via oracle |
| EraLend | Jul 2023 | $3.4M | Read-only reentrancy on zkSync Era |
The Curve Finance attack is particularly notable because the vulnerability was not in the contract logic itself but in the Vyper compiler: versions 0.2.15, 0.2.16, and 0.3.0 contained a bug that silently broke the @nonreentrant decorator, making reentrancy guards ineffective. This highlights that even well-audited contracts are vulnerable if the underlying toolchain has bugs.
Solidity Transfer Methods and Risk
The choice of how a Solidity contract sends ETH directly impacts reentrancy risk. Three methods exist, each with different gas forwarding behavior:
| Method | Gas Forwarded | On Failure | Reentrancy Risk |
|---|---|---|---|
transfer() | 2,300 gas (fixed) | Reverts | Historically low, now unreliable |
send() | 2,300 gas (fixed) | Returns false | Historically low, now unreliable |
call{value}("") | All remaining gas | Returns (bool, bytes) | High without additional guards |
The transfer() and send() methods originally limited reentrancy risk by forwarding only 2,300 gas: not enough for a storage write. However, EIP-1884 (Istanbul hard fork, December 2019) increased the gas cost of SLOAD from 200 to 800 gas, causing some legitimate fallback functions to exceed the 2,300 gas stipend and fail. The current best practice is to use call{value}("") combined with the CEI pattern and a reentrancy guard.
Risks and Considerations
- Reentrancy auditing is not foolproof: read-only reentrancy and cross-contract variants can evade standard detection tools and manual review. Even formal verification may miss vulnerabilities introduced by compiler bugs, as the Curve/Vyper incident demonstrated.
- Composability amplifies risk: as DeFi protocols integrate with each other, the attack surface for cross-contract reentrancy grows. A contract that is individually secure may become vulnerable when composed with other protocols that share state or oracle dependencies.
- Gas cost changes can break assumptions: Ethereum's evolving gas schedule means that transfer methods once considered safe (like
transfer()) can become unreliable. Smart contracts must be designed to withstand future EVM changes. - Defense in depth is essential: no single mitigation is sufficient. The audit process should verify CEI ordering, reentrancy guards, and safe transfer patterns together.
This glossary entry is for informational purposes only and does not constitute financial or investment advice. Always do your own research before using any protocol or technology.