Reentrancy is the vulnerability that launched a thousand audits. It's the bug that drained $60 million from The DAO in 2016 — an event so significant it caused Ethereum to hard-fork. Nearly a decade later, reentrancy attacks still appear in production contracts every month.
This guide explains exactly how reentrancy works, shows you the vulnerable code patterns, and walks through every prevention technique available in Solidity today.
Check Your Contract for Reentrancy
Get an instant scan that detects reentrancy and 100+ other vulnerability patterns — free in under 60 seconds.
What Is a Reentrancy Attack?
A reentrancy attack occurs when a malicious contract calls back into the victim contract before the first execution has finished. The attacker exploits the fact that Ethereum allows contracts to call each other mid-execution, and that a poorly designed victim contract sends ETH (triggering the attacker's fallback) before updating its own state.
The name comes from "re-entering" a function — specifically a withdrawal function — multiple times within a single transaction, draining funds with each re-entry while the contract's balance records haven't been updated yet.
The DAO Hack: A Brief History
In June 2016, an attacker exploited a reentrancy bug in The DAO — a decentralized autonomous organization holding approximately $150 million in ETH at the time. The attack drained roughly 3.6 million ETH (~$60M at 2016 prices).
The simplified attack flow:
- Attacker deposits 1 ETH into The DAO
- Attacker calls
withdraw() - The DAO sends ETH back to attacker's contract via
.call() - This triggers the attacker contract's
receive()/ fallback function - The fallback immediately calls
withdraw()again on The DAO - The DAO checks attacker's balance — it's still 1 ETH (state not updated yet!)
- The DAO sends another 1 ETH — and so on, recursively
- The loop continues until the attacker's gas runs out or The DAO is empty
The controversy over how to respond — patch the blockchain retroactively, or let code-is-law stand — led to the Ethereum Classic fork. The lesson it left: always update state before making external calls.
Anatomy of a Reentrancy Vulnerability
Here is the classic vulnerable pattern, annotated:
// VULNERABLE CONTRACT
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint amount) external {
// ✅ Check: balance is verified
require(balances[msg.sender] >= amount, "Insufficient balance");
// ❌ Interaction BEFORE Effect: ETH is sent first
// If msg.sender is a contract, its receive() executes HERE
// At this point, balances[msg.sender] is STILL the old value
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// ❌ Effect AFTER Interaction: too late — attacker has already re-entered
balances[msg.sender] -= amount;
}
}
And here is the attacker's contract that exploits it:
// ATTACKER CONTRACT
contract Attacker {
VulnerableBank public target;
uint public attackAmount = 1 ether;
constructor(address _target) {
target = VulnerableBank(_target);
}
function attack() external payable {
require(msg.value >= attackAmount);
target.deposit{value: attackAmount}();
target.withdraw(attackAmount); // starts the loop
}
// Called automatically every time VulnerableBank sends ETH
receive() external payable {
if (address(target).balance >= attackAmount) {
target.withdraw(attackAmount); // re-enters withdraw()
}
}
}
Each re-entry into withdraw() passes the balance check (state not updated) and sends another 1 ETH until the bank is drained or gas runs out. A single initial deposit of 1 ETH can extract the entire contract balance.
Types of Reentrancy Attacks
1. Single-Function Reentrancy
The classic form — re-entering the same function that initiated the call. The example above demonstrates this. Most easy to detect, most commonly caught by automated scanners.
2. Cross-Function Reentrancy
More subtle. The attacker re-enters a different function in the same contract that shares the same state variable being manipulated.
contract CrossFunctionVulnerable {
mapping(address => uint) public balances;
function transfer(address to, uint amount) external {
// balances[msg.sender] is not updated until after the external call
require(balances[msg.sender] >= amount);
balances[to] += amount;
balances[msg.sender] -= amount;
}
function withdraw(uint amount) external {
require(balances[msg.sender] >= amount);
(bool ok,) = msg.sender.call{value: amount}(""); // ← re-enter transfer() here
require(ok);
balances[msg.sender] -= amount;
}
}
An attacker's receive() can call transfer() while withdraw() hasn't updated balances yet — transferring funds they don't own to another address.
3. Cross-Contract Reentrancy
The most complex form. Contract A calls Contract B, which calls back into Contract A, but through a completely different entry point. Occurs in DeFi protocols with complex contract interactions (e.g., a vault calling a router calling a pool). Very difficult to catch without tracing the full call graph.
4. Read-Only Reentrancy
A newer variant that became significant in 2022–2023. During the execution of a function (while state is inconsistent), an attacker re-enters a view function that reads the inconsistent state. This view function's result is then used by a third contract to make decisions.
Prevention: 3 Layers of Defense
Layer 1: Checks-Effects-Interactions (CEI) Pattern
The foundational rule. Always structure functions in this order:
- Checks — all
require/revertconditions - Effects — all state variable updates
- Interactions — external calls, ETH transfers
// SAFE: CEI pattern applied
function withdraw(uint amount) external {
// 1. CHECK
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. EFFECT — state updated BEFORE external call
balances[msg.sender] -= amount;
// 3. INTERACTION — external call last
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
With CEI, even if the attacker re-enters withdraw(), the balance is already 0 — the require will fail and the loop stops immediately.
Layer 2: ReentrancyGuard Mutex
A mutex (mutual exclusion lock) blocks any re-entry attempt at the function level. OpenZeppelin provides a production-ready implementation:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}
The nonReentrant modifier sets a lock at the start of the function and clears it at the end. Any re-entry attempt while the lock is set causes an immediate revert. This works even when CEI is not strictly followed — though using both together is best practice.
Layer 3: Pull Payment Pattern
Instead of pushing ETH to users (which requires calling their address), let users pull their funds themselves in a separate transaction. This eliminates the external call from the withdrawal function entirely.
contract PullPaymentSafe {
mapping(address => uint) public pendingWithdrawals;
// Called internally when user earns ETH
function _creditUser(address user, uint amount) internal {
pendingWithdrawals[user] += amount;
}
// User initiates their own withdrawal — no external call from our side
function withdrawFunds() external {
uint amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0; // effect before interaction
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}
This pattern eliminates reentrancy at the architectural level. The contract never initiates a call to an untrusted address — it only responds to calls from user addresses.
Detecting Reentrancy in Your Contract
Reentrancy detection requires tracing every execution path where:
- An external call occurs (
.call(),.transfer(),.send(), or any function call to an external contract) - State variables are modified after that call
- The state modified affects the function's pre-conditions
For complex contracts with many functions and cross-contract interactions, manual tracing is error-prone. Quantum Audit's automated scanner traces all execution paths, identifies CEI violations, missing nonReentrant modifiers, and cross-function reentrancy patterns — and flags them with precise line references in the PDF report.
Common Misconceptions
"transfer() is safe from reentrancy"
This was true until the Istanbul hardfork in 2019, which changed gas costs. transfer() forwards only 2,300 gas — historically not enough for a re-entry. After Istanbul, certain opcodes became cheaper, making 2,300 gas potentially sufficient for re-entry in specific scenarios. Do not rely on gas limits as a security mechanism.
"My contract doesn't hold ETH, so it's not vulnerable"
Reentrancy can also occur through ERC-20 token callbacks (ERC-777 tokens have a transfer hook), NFT safeTransfer callbacks (ERC-721), and cross-contract state manipulation. ETH is not required for a reentrancy exploit.
"My audit missed reentrancy before, so I'm fine"
Cross-function and read-only reentrancy are commonly missed even in paid audits if the auditor only runs basic static analysis. Ensure your audit specifically covers all reentrancy variants, not just single-function cases.