Reentrancy
How the attack works and how to prevent it
Smart Contract Reentrancy Attacks: Detection, Exploitation, and Prevention
Reentrancy is the classic smart contract vulnerability for a reason: it teaches one of the most important audit lessons in Web3 security.
Every external call is a control-flow handoff. If a contract sends execution to an untrusted address before finalizing balances, shares, debt, rewards, or other accounting state, the attacker may be able to re-enter the system while it still believes the old state is true.
This guide breaks down how reentrancy works, why the DAO hack changed Ethereum, how modern variants such as cross-function and read-only reentrancy appear in DeFi, and how auditors should test for them in real code.
What is a reentrancy attack in smart contracts?
A reentrancy attack happens when a smart contract makes an external call to an untrusted address before updating relevant internal state, such as a user's balance.
If the recipient is a malicious contract, it can call back into the vulnerable contract while the old state is still visible. Repeated calls may pass the same checks and withdraw more than the attacker's recorded balance.
call is not the problem by itself.Reentrancy Attack vs Reentrancy Vulnerability
A reentrancy vulnerability is the unsafe design: the contract exposes stale state during an external call. A reentrancy attack is the exploit path that uses that stale state to withdraw, mint, borrow, vote, liquidate, or otherwise change protocol accounting more than once.
This distinction matters in audits. A function can look safe in isolation but become vulnerable when a token hook, callback, router, vault, bridge adapter, or another protocol contract can call back into the system before all effects are finalized.
The Bank Teller Analogy (Explained Simply)
Imagine walking into a bank. You want to withdraw $1,000.
The bank teller hands you $1,000 in cash before updating the account ledger. If you could request another withdrawal before the ledger changed, the old balance would pass the same check again.
Repeated withdrawals could empty the bank before the first ledger update. A reentrancy attack exploits the equivalent ordering error in contract code.
{
"title": "🎬 Reentrancy: watch the vault drain",
"stage": {
"width": 920,
"height": 440
},
"nodes": [
{
"id": "attacker",
"label": "Attacker EOA",
"role": "deploys + triggers",
"emoji": "🧑💻",
"x": 50,
"y": 50,
"color": "red"
},
{
"id": "vault",
"label": "The Vault",
"role": "holds pooled ETH",
"emoji": "🏦",
"x": 620,
"y": 50,
"color": "cyan"
},
{
"id": "evil",
"label": "Attacker Contract",
"role": "receive() re-enters",
"emoji": "📜",
"x": 335,
"y": 320,
"color": "purple"
}
],
"links": [
{
"from": "attacker",
"to": "vault"
},
{
"from": "vault",
"to": "evil"
},
{
"from": "evil",
"to": "vault"
},
{
"from": "attacker",
"to": "evil"
}
],
"nets": [
{
"id": "vault",
"label": "Vault Net"
},
{
"id": "atk",
"label": "Attacker Net"
}
],
"legend": [
{
"cls": "call",
"label": "contract call"
},
{
"cls": "token",
"label": "ETH transfer"
},
{
"cls": "sig",
"label": "state write"
},
{
"cls": "fail",
"label": "reverted / blocked"
}
],
"scenarios": {
"Vulnerable": [
{
"note": "Attacker deposits 1 ETH. The vault pools <b>3 ETH</b> across its users; the attacker's contract holds nothing yet.",
"hi": [
"vault"
],
"bal": {
"vault": "3 ETH",
"evil": "0 ETH",
"attacker": "-"
},
"net": {
"vault": "$0",
"atk": "$0"
}
},
{
"note": "The attacker's contract calls <b>withdraw()</b> on the vault.",
"hi": [
"evil",
"vault"
],
"chip": {
"from": "evil",
"to": "vault",
"label": "📞 withdraw()",
"cls": "call"
}
},
{
"note": "The vault sends <b>1 ETH</b> to the contract <b>before</b> zeroing its balance. The re-entry window is now open.",
"tone": "bad",
"hi": [
"vault",
"evil"
],
"chip": {
"from": "vault",
"to": "evil",
"label": "💸 1 ETH",
"cls": "token"
},
"bal": {
"vault": "2 ETH",
"evil": "1 ETH"
},
"net": {
"vault": "-1 ETH",
"atk": "+1 ETH"
}
},
{
"note": "The contract's <b>receive()</b> fires mid-transfer and calls <b>withdraw() again</b> - the stale balance still shows funds.",
"tone": "bad",
"hi": [
"evil",
"vault"
],
"chip": {
"from": "evil",
"to": "vault",
"label": "🔄 withdraw()",
"cls": "call"
}
},
{
"note": "The vault pays <b>again</b>. The loop repeats, draining the entire pool a slice at a time.",
"tone": "bad",
"hi": [
"vault",
"evil"
],
"chip": {
"from": "vault",
"to": "evil",
"label": "💸 1 ETH",
"cls": "token"
},
"bal": {
"vault": "0 ETH",
"evil": "3 ETH"
},
"net": {
"vault": "-3 ETH",
"atk": "+3 ETH"
}
},
{
"note": "State finally updates - too late. The vault is <b>empty</b>; the attacker drained <b>3 ETH</b> on a 1 ETH balance.",
"tone": "bad",
"hi": [
"vault",
"evil"
],
"bal": {
"vault": "0 ETH",
"evil": "3 ETH"
},
"net": {
"vault": "-3 ETH",
"atk": "+3 ETH"
}
}
],
"Fixed (CEI)": [
{
"note": "Same start: the vault pools <b>3 ETH</b> and the attacker holds a 1 ETH balance.",
"hi": [
"vault"
],
"bal": {
"vault": "3 ETH",
"evil": "0 ETH",
"attacker": "-"
},
"net": {
"vault": "$0",
"atk": "$0"
}
},
{
"note": "The attacker's contract calls <b>withdraw()</b>.",
"hi": [
"evil",
"vault"
],
"chip": {
"from": "evil",
"to": "vault",
"label": "📞 withdraw()",
"cls": "call"
}
},
{
"note": "The vault zeroes the balance <b>first</b> (Checks-Effects-Interactions), then sends.",
"tone": "ok",
"hi": [
"vault"
],
"chip": {
"from": "vault",
"to": "vault",
"label": "✍️ balance = 0",
"cls": "sig"
},
"bal": {
"vault": "3 ETH"
}
},
{
"note": "The vault sends the legitimate <b>1 ETH</b> only after its state is already correct.",
"tone": "ok",
"hi": [
"vault",
"evil"
],
"chip": {
"from": "vault",
"to": "evil",
"label": "💸 1 ETH",
"cls": "token"
},
"bal": {
"vault": "2 ETH",
"evil": "1 ETH"
},
"net": {
"vault": "-1 ETH",
"atk": "+1 ETH"
}
},
{
"note": "<b>receive()</b> re-enters - but the balance is already 0, so the call <b>reverts</b>. The loop dies.",
"tone": "ok",
"hi": [
"evil",
"vault"
],
"chip": {
"from": "evil",
"to": "vault",
"label": "⛔ withdraw() ✗",
"cls": "fail"
},
"bal": {
"vault": "2 ETH",
"evil": "1 ETH"
},
"net": {
"vault": "-1 ETH",
"atk": "+1 ETH"
}
}
]
}
}
Watch: Reentrancy Attack Explained
Why Reentrancy Still Matters
Reentrancy vulnerabilities have caused significant losses and protocol failures:
| Attack | Year | Impact |
|---|---|---|
| The DAO Hack | 2016 | $60M stolen, Ethereum hard fork |
| Curve Finance | 2023 | $70M+ drained |
| Grim Finance | 2021 | $30M lost |
Nearly a decade after The DAO, reentrancy still ranks in the top 5 smart contract vulnerability classes reported in 2025.
Types of Reentrancy
Beyond these three types, read-only reentrancy matters in DeFi protocols because a view function can return stale data during an ongoing state transition. Token standards with callback hooks, including ERC-777 (tokensReceived) and ERC-1155 (onERC1155Received), also introduce reentrancy paths during transfers.
What Auditors Should Review for Reentrancy Risk
Before marking a protocol safe from reentrancy, do not stop at "it has nonReentrant". Work through the full call graph:
-
Map every external interaction: ETH transfers, ERC20 transfers, ERC721/ERC1155 safe transfers, ERC777 hooks, callbacks, low-level calls, and arbitrary target calls.
-
Check state-update order. Balances, shares, debt, reward indexes, collateral flags, and nonce/accounting fields should be updated before control leaves the contract.
-
Look for shared state across functions. A guard on
withdraw()does not protectclaimRewards(),borrow(), orliquidate()if they read the same stale accounting. -
Trace cross-contract flows. Reentrancy can enter through another contract in the same protocol, especially routers, vaults, strategies, hooks, or token adapters.
-
Test read-only reentrancy. View functions used by other protocols should not expose temporarily inconsistent prices, share values, or reserves during state transitions.
-
Review token standards with callbacks. ERC777, ERC721 safe transfers, ERC1155 safe transfers, and custom hooks can all hand execution back to attacker-controlled code.
-
Verify emergency controls. Pausing, withdrawal caps, and circuit breakers should still work if an attacker reaches an unexpected callback path.
If a finding depends on temporarily stale balances or share prices, also review the related oracle manipulation and flash loan attack patterns because DeFi exploits often chain these assumptions together. For privileged pause, upgrade, and emergency-response paths, review access control attacks as well.
The DAO Hack
The DAO hack exposed a reentrancy flaw that led to the diversion of roughly $60 million in ETH and contributed to Ethereum's 2016 hard fork. It remains an important case because the technical exploit and the response both shaped smart contract security practice.
The DAO: what it was
Launched in April 2016, The DAO was a blockchain-based investment fund governed by token holders.
At the time:
-
Over $150 million raised in ETH
-
Held 14% of all ether in circulation
-
Backed by 11,000+ investors
-
One of the largest crowdfunding campaigns at the time
Investors received DAO tokens, voted on funding proposals, and shared returns. The system relied on contract code and token-holder governance rather than centralized management.
The Attack: June 17, 2016
On June 17, 2016, an attacker exploited the withdrawal logic.
The attacker used a reentrancy vulnerability in The DAO's withdrawal function to divert 3.6 million ETH (roughly $50-60 million at the time) into a child DAO under their control.
Several security researchers warned about recursive-call vulnerabilities before the exploit, but the required fixes were not deployed in time.
The attacker did not bypass the EVM's rules. The vulnerable contract executed its withdrawal logic as written, which intensified the debate about how the community should respond.
In an ecosystem where "code is law" was a common principle, the incident raised a difficult question: should the network preserve the original execution or intervene to return funds?
The Great Ethereum Split
The Ethereum community considered two broad options:
Option A: preserve the original chain history and accept the loss as a consequence of immutability.
Option B: change the protocol state through a hard fork so affected users could recover funds.
After extensive debate, the community adopted a hard fork that moved the diverted funds into a recovery contract. The network officially forked on July 20, 2016, at block 1,920,000.
Participants who rejected the fork continued operating the original chain, now known as Ethereum Classic (ETC).
The incident divided the community and made protocol governance, immutability, and emergency response central security concerns.
How Reentrancy Attacks Actually Work (Step-by-Step)
The sequence below shows where the vulnerable contract exposes stale state.
The Attack Flow
| Step | What Happens | State |
|---|---|---|
| 1 | Attacker deposits 1 ETH | balances[attacker] = 1 ETH |
| 2 | Attacker calls withdraw() |
Checking balance... |
| 3 | Vault sends 1 ETH to attacker | Balance not yet updated |
| 4 | Attacker's receive() triggers |
Still shows 1 ETH balance! |
| 5 | receive() calls withdraw() again |
Check passes (still 1 ETH) |
| 6 | Vault sends 1 ETH again | Still not updated |
| 7 | Loop continues... | Draining continues |
| 8 | State finally updates | Too late - vault is empty |
The Attack Phases
The attacker creates a contract with a receive() or fallback() function that calls back into the vulnerable contract's withdraw function.
The attacker deposits funds normally, then calls withdraw(). The contract sends ETH before updating the balance.
The attacker's receive() function triggers automatically and immediately calls withdraw() again. The balance check still passes because state hasn't updated.
The cycle repeats until gas runs out or the vault is empty. State finally updates - but it's too late. The vault has been completely drained.
Reentrancy Vulnerable Code Example
Let's examine the classic vulnerability that enabled The DAO hack.
This contract is intentionally vulnerable. Never use this pattern in production.
The Vulnerable Withdraw Function
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds to withdraw");
// VULNERABILITY: External call BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// State update happens too late!
balances[msg.sender] = 0;
}
}
Why is this vulnerable?
The problem is the order of operations:
-
External call happens first (
.call{value: amount}("")) -
Balance still shows funds available during the call
-
No reentrancy guard
-
Violates Checks-Effects-Interactions pattern
The attacker can call withdraw() again before line 15 (balances[msg.sender] = 0) ever executes.

Reentrancy Attacker Contract Example
Here's how an attacker exploits the vulnerable vault.
// ATTACKER CONTRACT - Educational purposes only
contract ReentrancyAttacker {
VulnerableVault public vault;
uint256 public attackIterations;
constructor(address _vaultAddress) {
vault = VulnerableVault(_vaultAddress);
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ETH");
// Step 1: Deposit to establish a balance
vault.deposit{value: 1 ether}();
// Step 2: Trigger the first withdrawal
vault.withdraw();
}
// This is called automatically when receiving ETH
receive() external payable {
attackIterations++;
// If vault still has funds, call withdraw again
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // Re-enter!
}
}
function collectStolenFunds() public {
payable(msg.sender).transfer(address(this).balance);
}
}
Attack Execution Summary
-
Deposit 1 ETH to the vulnerable vault
-
Call
withdraw()to start the attack -
Receive ETH, triggering
receive()function -
Re-enter by calling
withdraw()again immediately -
Repeat until vault is drained
In a real attack the loop keeps running past the attacker's own deposit and takes every ETH the contract holds.
How to Prevent Reentrancy Attacks: Best Practices
Preventing reentrancy requires correct state-update order, explicit guards, and review of the full call graph.
1. The Checks-Effects-Interactions Pattern
This pattern places state changes before external interactions.
Always follow this order:
| Step | Action | Example |
|---|---|---|
| Checks | Validate conditions | require(balance > 0) |
| Effects | Update state | balances[user] = 0 |
| Interactions | External calls | .call{value: amount}("") |
The Checks-Effects-Interactions pattern blocks the common case where a callback observes an unchanged balance. It should be combined with call-graph review for variants that cross functions or contracts.
By updating state before making external calls, even if an attacker re-enters, the state already reflects the completed transaction.

2. Reentrancy Guard (Mutex Lock)
Implement a mutex lock that prevents recursive calls. OpenZeppelin's ReentrancyGuard is the industry standard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
function withdraw() public nonReentrant {
// Your withdrawal logic here
}
}
How it works:
-
Sets
locked = trueat function entry -
Reverts if called while already locked
-
Sets
locked = falseafter completion
Simple but highly effective.
3. Use Safe Transfer Functions
Instead of low-level .call():
-
OpenZeppelin's
Address.sendValue()- Safer ETH transfers -
SafeERC20- Prevents reentrancy in token transfers -
Pull over push - Let users withdraw rather than pushing funds
4. Limit Gas Forwarded
When using .call(), limit the gas:
// Only 2300 gas - not enough for reentrancy
(bool success, ) = msg.sender.call{value: amount, gas: 2300}("");
Warning: This is a weak defense - gas costs can change in network upgrades. Always combine with other protections.
5. Security Audits
-
Professional audits from firms like Trail of Bits, OpenZeppelin
-
Bug bounties to incentivize white-hat hackers
-
Formal verification for mathematical proofs of safety
-
Continuous monitoring post-launch
Prevention Effectiveness Comparison
What it does: Ensures all state changes (effects) happen before any external calls (interactions).
When to use: Apply this pattern to contracts that send ETH or hand control to external contracts.
Limitation: Requires developer discipline - easy to accidentally violate when refactoring.
What it does: Sets a lock flag at function entry and reverts if called while locked.
When to use: All payable and state-changing functions in contracts that interact externally.
Limitation: Only protects the contract it's deployed on - cross-contract reentrancy bypasses it.
What it does: Uses transfer() or send() which forward only 2300 gas - not enough for a reentrant call.
When to use: Legacy contracts only. Avoid in new development.
Limitation: Gas costs change with EIPs. This defense broke with the Istanbul fork and is now considered unreliable.
Reentrancy Secure Code Example
The following example combines Checks-Effects-Interactions with OpenZeppelin's ReentrancyGuard.
// SECURE CONTRACT - Example with CEI and ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
event Deposit(address indexed user, uint256 amount);
event Withdrawal(address indexed user, uint256 amount);
function deposit() public payable {
require(msg.value > 0, "Must deposit more than 0");
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
// STEP 1: CHECKS
require(amount > 0, "Insufficient balance");
require(address(this).balance >= amount, "Contract insufficient funds");
// STEP 2: EFFECTS - Update state BEFORE external call
balances[msg.sender] = 0;
// STEP 3: INTERACTIONS - External call last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
emit Withdrawal(msg.sender, amount);
}
}
Security Features at a Glance
| Feature | Protection |
|---|---|
nonReentrant modifier |
Prevents recursive calls |
| State update before call | CEI pattern |
require checks |
Input validation |
| Events | Transparency & monitoring |
Zeroing the balance before the external call is exactly the line The DAO's withdrawal function was missing in 2016.
Types of Reentrancy Attacks Beyond the Basics
As DeFi protocols become more complex, so do attack vectors.
1. Cross-Function Reentrancy
An attacker calls a different function during the reentrant call:
-
Initial call to
withdraw() -
Re-enters through
transfer()before state updates -
Both functions share the same vulnerable state
2. Cross-Contract Reentrancy
The attack spans multiple interconnected contracts:
-
Contract A calls Contract B
-
Contract B calls back into Contract A
-
Shared state across contracts creates vulnerability
3. Read-Only Reentrancy
Even view functions can be exploited:
-
Attacker re-enters a view function during state transition
-
View function returns stale/inconsistent data
-
Other contracts make decisions based on wrong information
4. ERC-777 and ERC-1155 Reentrancy
Token standards with callback hooks create new vectors:
-
tokensReceivedhook in ERC-777 -
onERC1155Receivedhook in ERC-1155 -
Both can trigger reentrant calls during transfers
Comparing Defense Mechanisms
transfer() / send()
Forwards only 2300 gas but is a deprecated defense. Gas costs change with EIPs - the Istanbul fork broke this approach. Never rely on gas limits alone.
ReentrancyGuard Only
Mutex lock prevents single-contract reentrancy but doesn't protect against cross-contract or read-only variants. Necessary but insufficient alone.
CEI + Guard + Audits
Checks-Effects-Interactions and ReentrancyGuard address common callback paths. Full protocol review is still required for cross-function, cross-contract, and read-only variants.
Common Misconceptions
"Reentrancy only affects withdraw functions."
Tap to revealAny function that makes an external call before updating state is vulnerable - including token transfers, flash loan callbacks, and cross-contract interactions.
"Using ReentrancyGuard prevents all reentrancy."
Tap to revealReentrancyGuard only protects functions within the same contract. Cross-contract and read-only reentrancy can bypass it entirely.
"Solidity 0.8+ prevents reentrancy attacks."
Tap to revealSolidity 0.8+ only adds overflow protection. Reentrancy is a logic flaw - not an arithmetic one. You still need CEI pattern and ReentrancyGuard.
"View functions can be exploited in reentrancy attacks."
Tap to revealRead-only reentrancy exploits view functions that read stale state during an ongoing transaction, causing other protocols to make incorrect decisions based on wrong data.
Related Vulnerabilities
Reentrancy can be combined with other exploit techniques. Flash loan attacks can provide temporary capital for an exploit path that depends on protocol accounting or collateral state. The bZx incidents combined borrowed liquidity with callback and accounting weaknesses.
Reentrancy is also related to call attacks and delegatecall vulnerabilities. Reentrancy uses callbacks to observe stale state, while delegatecall vulnerabilities arise from executing external code in the caller's context. Both require careful review of where control leaves the contract.
Test your reentrancy knowledge
5 questions on call ordering, guards, and read-only variants
Frequently Asked Questions About Reentrancy Attacks
A reentrancy attack is when a malicious contract repeatedly calls a vulnerable function before it finishes executing - like making multiple withdrawals before the bank updates your balance. The attacker drains funds by exploiting the gap between sending ETH and updating state.
Over $200 million has been stolen through reentrancy vulnerabilities since 2016. The DAO hack alone accounted for $60 million, and the 2023 Curve Finance exploit drained over $70 million.
No single mechanism covers every variant. Use Checks-Effects-Interactions, apply a reentrancy guard where appropriate, and review cross-function, cross-contract, callback, and read-only paths. These controls provide defense-in-depth but still require protocol-specific testing.
No. ReentrancyGuard protects the functions where it is applied, but cross-contract reentrancy, unguarded sibling functions, read-only reentrancy, and callback-based token flows can still expose stale protocol state. Auditors still need to trace the full call graph.
Read-only reentrancy happens when a callback reads a protocol's temporary, inconsistent state through a view function and another contract trusts that value. The view function does not modify state, but the stale price, share value, or reserve value can still cause bad downstream decisions.
No. Any blockchain platform allowing external calls during execution can be vulnerable - including Vyper, Rust (Solana via CPI), CosmWasm, and Move-based chains. The concept applies wherever contracts can call each other mid-execution.
If still in development, fix it immediately. If the contract is live: pause it, contact a security firm for emergency response, prepare an upgrade or migration plan, and inform users transparently. Time is critical - attackers scan for known vulnerabilities continuously.
Quick Reference: Reentrancy Prevention Checklist
Before deploying any contract that handles value transfers:
-
Follow Checks-Effects-Interactions in every function with external calls
-
Use ReentrancyGuard on all payable and state-changing functions
-
Update state BEFORE external calls - this single rule prevents most attacks
-
Prefer pull over push - let users withdraw rather than pushing funds
-
Get professional audits before mainnet deployment
-
Monitor deployed contracts for suspicious activity
-
Implement emergency pause to respond to vulnerabilities
-
Test reentrancy scenarios in your test suite
Practice the Exploit Path
Reading about reentrancy gives you the pattern. Exploiting it in a lab gives you the instinct.
A good auditor should be able to:
-
identify the external call,
-
find the stale state assumption,
-
write the attacker contract,
-
prove the drain or invariant break,
-
verify that the fix blocks the same path.
Common reentrancy variants are preventable with disciplined design, but complex DeFi variants require full call-graph review. Checks-Effects-Interactions and ReentrancyGuard are starting points, not substitutes for understanding the protocol's state model.
The Smart Contract Hacking course includes hands-on reentrancy exercises along with flash loans, oracle manipulation, access control, and other audit-critical vulnerability classes.
If you want to see how the training works first, start with the free lesson or browse the course curriculum.
Sources and editorial notes
Reviewed by JohnnyTime. Last updated .
Real Reentrancy hacks to study
A stable selection of high-signal incidents linked to this attack class, ordered by reported loss and recency.
Master Reentrancy in a safe lab
Practice the exploit path, debug the vulnerable code, and learn the prevention workflow auditors use in real reviews.