What is a Reentrancy Attack?
A reentrancy attack is a smart contract vulnerability where an attacker causes a contract to call itself repeatedly during a single transaction, draining funds before the contract can update its internal balance. It’s the most famous smart contract exploit and caused The DAO hack in 2016 — the event that led to Ethereum’s contentious hard fork creating ETH and ETC.
The name comes from “re-entering” a function: the attacker’s contract calls the vulnerable function, and during the external call, the vulnerable function is called again (re-entered) before the first call completes.
Reentrancy has caused over $700 million in total losses across all DeFi protocols. Despite being well-understood, new reentrancy vulnerabilities are still discovered regularly.
How Reentrancy Works
The Vulnerable Pattern
// VULNERABLE CONTRACT
contract VulnerableBank {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender];
// 1. Send ETH to caller FIRST (external call)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
// 2. Update balance AFTER sending (too late!)
balances[msg.sender] = 0;
}
}
The Attack
Attacker Contract:
function attack() public {
bank.deposit{value: 1 ether}(); // Deposit 1 ETH
bank.withdraw(); // Call withdraw()
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // Re-enter withdraw() before balance = 0
}
}
Execution flow:
1. Attacker deposits 1 ETH
2. Attacker calls withdraw()
3. Bank sends 1 ETH to attacker (step 1 of withdraw)
4. Attacker's receive() triggers → calls withdraw() again
5. Bank sends 1 ETH again (balance not yet updated!)
6. Repeat until bank is empty
7. Eventually: balances[attacker] = 0 (but funds already gone)
Why It Works
The vulnerability is in the ordering of operations:
- External call (send ETH) happens first
- State update (set balance to 0) happens second
Between steps 1 and 2, the attacker can re-enter the function. The balance hasn’t been updated yet, so the contract thinks the attacker still has funds to withdraw.
Types of Reentrancy
1. Single-Function Reentrancy
The attacker re-enters the same function (classic example above). This is the simplest form.
2. Cross-Function Reentrancy
The attacker re-enters a different function that shares state:
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
// Attacker calls deposit() via receive() to add balance
// Then withdraw() sees increased balance
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
balances[msg.sender] -= amount; // Vulnerable to cross-function reentrancy
}
3. Cross-Contract Reentrancy
The attacker exploits state shared between multiple contracts. For example, a lending protocol’s supply and borrow functions share a single balance mapping.
4. Read-Only Reentrancy
The attacker re-enters a view function that reads outdated state. This doesn’t drain funds directly but can manipulate prices or oracle values.
How to Prevent Reentrancy
1. Checks-Effects-Interactions Pattern (Most Important)
Always update state BEFORE making external calls:
// SECURE
function withdraw() public {
uint256 amount = balances[msg.sender];
// EFFECT: Update state FIRST
balances[msg.sender] = 0;
// INTERACTION: External call LAST
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
This is the #1 rule of smart contract development: Effects before Interactions.
2. ReentrancyGuard (OpenZeppelin)
Use a mutex lock that prevents re-entry:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract MyContract is ReentrancyGuard {
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0;
}
}
The nonReentrant modifier sets a lock before execution and clears it after. If the function is re-entered, the lock prevents execution.
3. Pull Over Push Pattern
Instead of sending ETH, let users withdraw themselves:
// Instead of sending ETH:
contract Vendor {
mapping(address => uint256) public pendingWithdrawals;
function purchase() external payable {
pendingWithdrawals[msg.sender] += msg.value;
}
// User calls this to claim their ETH
function claim() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0; // State update first
payable(msg.sender).transfer(amount);
}
}
4. Static Calls for View Operations
When reading from other contracts during a transaction, use staticcall (read-only) instead of call to prevent the called contract from modifying state.
Famous Reentrancy Attacks
| Attack | Year | Loss | Vector |
|---|---|---|---|
| The DAO | 2016 | $60M (3.6M ETH) | Classic single-function reentrancy |
| Fei Protocol | 2022 | $80M (exploited by DEFI Saver) | Cross-function reentrancy |
| Curve Pool | 2023 | $40M+ | Read-only reentrancy via Vyper compiler bug |
| WEMIX | 2023 | $1.4M | Reentrancy in bridge contract |
| Paraswap | 2023 | $900K | Read-only reentrancy in router |
The DAO hack was so significant it resulted in Ethereum’s only contentious hard fork — the creation of Ethereum (ETH) and Ethereum Classic (ETC).
Frequently Asked Questions
Q: Is reentrancy still a problem in 2025? A: Yes. While most developers know about it, new reentrancy vectors are still discovered. The Curve reentrancy in 2023 was caused by a Vyper compiler bug that affected how the reentrancy guard worked. Always audit your contracts.
Q: does ReentrancyGuard completely prevent reentrancy? A: It prevents re-entry into the same function. Cross-function and cross-contract reentrancy require different mitigations (proper state management across functions).
Q: How do auditors find reentrancy? A: Automated tools (Slither, Mythril) detect basic patterns. Manual code review catches cross-function variants. The most dangerous reentrancy bugs are those that hide in complex protocol interactions.