A reentrancy attack is when a contract makes an external call to another address, and that address calls back into the original function before the first call has finished updating its state. The attacker re-enters the same logic repeatedly, draining funds that the contract still thinks it holds.
It remains one of the most expensive bug classes in Ethereum’s history. The 2016 DAO hack drained about 3.6 million ETH through this exact flaw, and the pattern is still breaking live protocols today. This guide explains how reentrancy works, walks through the main variants, and shows the patterns that actually stop it.
What Is a Reentrancy Attack?

A reentrancy attack exploits the order in which a smart contract sends money and records that it sent money. If the contract pays out before it marks the balance as spent, an attacker can force the payout to run again mid-execution.
The mechanism depends on a quirk of the Ethereum Virtual Machine (EVM). When a contract sends ETH to another contract, the receiving contract runs code automatically through its receive or fallback function. That handoff gives the receiver a chance to call back into the sender while the sender is still partway through its own function.
Picture a vault with a withdrawal function that does three things in this order: check your balance, send you the ETH, then set your balance to zero. The send step hands control to your contract. Instead of simply accepting the ETH, your contract calls withdraw again. The balance is still non-zero because the third step never ran, so the vault pays out a second time. Loop that until the vault is empty.
The flaw is not the external call itself. It is that state the contract relies on gets updated after the call instead of before.
A minimal vulnerable example
solidity
// Vulnerable: state update happens after the external call
mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // too late
}
The call{value: amount}("") line forwards all remaining gas to the recipient and triggers its fallback code. Because balances[msg.sender] is zeroed only on the last line, a malicious fallback can re-enter withdraw before that line executes.
Why Reentrancy Is So Dangerous
The danger comes from how much money sits inside decentralized finance (DeFi) contracts and how final on-chain transactions are. Once ETH leaves a contract, there is no chargeback and no bank to call.
Reentrancy also tends to appear in exactly the functions that move value: withdrawals, reward claims, liquidity removal, and token transfers. Those are the high-stakes paths, so a single reentrant bug can empty a protocol in one transaction.
The DAO hack is the clearest illustration. In June 2016, an attacker used a recursive withdrawal flaw to drain roughly 3.6 million ETH, worth around $60 million at the time. The fallout was severe enough that the Ethereum community executed a contentious hard fork to reverse it, which is why Ethereum Classic exists as the un-forked chain.
The problem did not end in 2016. In July 2025, attackers exploited a reentrancy vulnerability in the GMX V1 GLP pool on Arbitrum and took about $42 million. According to a post-mortem by security firm Halborn, the exploit let the attacker manipulate internal accounting during a reentrant call and mint GLP tokens at a distorted price. Nearly a decade after the DAO, the same root cause was still live in production code.
Who Needs to Worry About Reentrancy?
Anyone writing, auditing, or deploying Solidity contracts that handle value needs to understand reentrancy. It is a core topic for smart contract developers, security auditors, and the protocol teams who own the code in production.
The audiences break down roughly like this:
- Protocol developers building lending markets, DEXs, staking contracts, or vaults, where external calls and state updates mix constantly.
- Auditors and security researchers, for whom reentrancy is a first-pass check on any function that sends ETH or calls untrusted contracts.
- Integrators who compose with other protocols. A contract can be safe on its own but still read manipulated state from a protocol mid-reentrancy, which is the basis of read-only reentrancy.
If your contract never makes an external call and never sends ETH, reentrancy is not your concern. Most contracts that hold user funds do both.
The Main Types of Reentrancy Attacks
Reentrancy is not a single bug. The term covers several variants that differ by where the re-entry happens. Knowing the difference matters because a guard that blocks one type does not always block another.
| Type | What re-enters | Typical guard that stops it |
|---|---|---|
| Single-function | The same function, called again | Checks-Effects-Interactions, nonReentrant |
| Cross-function | A different function sharing the same state | A guard that locks across all affected functions |
| Cross-contract | A second contract sharing state with the first | Shared lock or careful state design |
| Read-only | A view function returning stale state | Guard the view, or avoid reading mid-call |
Single-function reentrancy
This is the classic DAO-style case shown above. The attacker re-enters the exact same function that made the external call. It is the easiest variant to understand and the easiest to prevent once you order operations correctly.
Cross-function reentrancy
Here the attacker re-enters a different function that shares state with the vulnerable one. Suppose withdraw sends ETH before updating a balance, and a separate transfer function reads that same balance. During the external call, the attacker calls transfer instead of withdraw, moving a balance that is about to be zeroed. A guard placed only on withdraw misses this entirely.
Cross-contract reentrancy
State is sometimes spread across two contracts that trust each other. If contract A updates a value in contract B after an external call, an attacker can re-enter through a function in B that depends on the not-yet-updated value. These are harder to spot because the vulnerable logic is split across files.
Read-only reentrancy
This one is subtle and often missed. A view function cannot change state, so it feels harmless. But if another protocol calls that view during a reentrant call, it can read inconsistent, mid-transaction state and act on it.
A common pattern: a lending protocol reads a DEX pool’s price through a view function to value collateral. An attacker triggers reentrancy inside the pool so that the view returns a temporarily distorted price, then borrows against inflated collateral. The pool itself loses nothing, but the integrating protocol does. Security teams at Cyfrin and Ackee document this as a distinct, frequently exploited class.
How to Prevent a Reentrancy Attack
Two defenses handle almost every case: the Checks-Effects-Interactions pattern and a reentrancy guard. Used together, they close the common attack paths.
1. Follow the Checks-Effects-Interactions pattern
Checks-Effects-Interactions (CEI) is the ordering rule that prevents most reentrancy. You structure every function the same way: validate inputs first, update your contract’s state second, and make external calls last.
Applied to the earlier vault, the fix is to move the balance update before the send:
solidity
// Safer: state is updated before the external call
function withdraw() public {
uint256 amount = balances[msg.sender]; // check
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // effect
(bool success, ) = msg.sender.call{value: amount}(""); // interaction
require(success, "Transfer failed");
}
Now if the attacker re-enters withdraw, their balance already reads zero and the require stops them. The external call is the last thing the function does, so there is no stale state left to exploit. CEI costs nothing and should be the default mental model for any function with an external call.
2. Add a reentrancy guard
A reentrancy guard, or reentrancy mutex, is a lock that stops a function from being entered a second time while it is still running. It catches the cases CEI alone can miss, especially cross-function reentrancy.
The standard implementation is OpenZeppelin’s ReentrancyGuard, which exposes a nonReentrant modifier. Applying it sets a flag on entry and clears it on exit; any re-entrant call hits the flag and reverts.
solidity
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
To block cross-function reentrancy, put nonReentrant on every function that touches the shared balance, not just the one that sends ETH. A lock on a single function leaves the others open.
One trade-off worth knowing: the original guard writes to permanent storage, which costs gas on every protected call. OpenZeppelin now ships ReentrancyGuardTransient, which stores the lock in transient storage introduced by EIP-1153 in Ethereum’s Cancun upgrade. Transient storage automatically clears at the end of each transaction, which fits a reentrancy lock perfectly and is cheaper than the storage-based version.
3. Be careful with how you send ETH
For years, the advice was to send ETH with transfer or send because they forward only a 2,300 gas stipend, too little for the recipient to re-enter. That advice is now outdated. ConsenSys Diligence recommended dropping transfer after gas costs for some operations changed and the fixed 2,300 stipend started breaking legitimate recipients like multisig wallets.
Current practice is to use call{value: amount}(""), which forwards all gas, and to rely on CEI plus a guard for safety rather than a gas limit. Do not depend on the stipend as your only defense.
Other Common Solidity Vulnerabilities
Reentrancy gets the headlines, but it shares a shortlist of recurring bug classes that auditors check first. These show up often enough that any serious review covers them alongside reentrancy.
- Integer overflow and underflow. Arithmetic that exceeds a type’s range used to wrap silently. Solidity 0.8.0 and later revert on overflow by default, so this is mostly a concern in older code or inside
uncheckedblocks. - Access control errors. Sensitive functions left without an
onlyOwneror role check, letting anyone call them. Missing or misapplied modifiers are a frequent root cause of large losses. - Unchecked external call return values. Low-level calls return a success boolean that is easy to ignore. If you do not check it, a failed transfer can pass silently.
- Oracle and price manipulation. Reading a price from a source an attacker can move within one transaction, often combined with a flash loan. Read-only reentrancy is one path into this.
tx.originfor authorization. Usingtx.origininstead ofmsg.senderfor auth lets a malicious intermediary contract impersonate a user.- Front-running and transaction ordering. Because pending transactions are public, attackers can observe and insert their own transaction ahead of yours. This is tied to maximal extractable value (MEV).
The newest compiler closes some of these gaps automatically. As of this writing, the latest release is Solidity 0.8.37, published in September 2026, and staying on a recent 0.8.x version gives you built-in overflow checks for free.
Testing Contracts for Reentrancy

The reliable way to catch reentrancy before deployment is to write an attacker contract and try the exploit against a fork of real network state. Static analysis tools like Slither flag suspicious call-then-update patterns, but a working proof-of-concept is what confirms whether a bug is real.
A practical workflow looks like this:
- Fork mainnet or a testnet locally so the target contract runs against realistic state and balances.
- Deploy a malicious contract whose
fallbackorreceivefunction calls back into the target. - Run the exploit and assert whether funds moved more than once.
- Apply CEI and a guard, then confirm the same test now reverts.
Forking needs a dependable archive-capable RPC endpoint that can serve historical state at the block you pin to. A provider such as NOWNodes gives developers Ethereum RPC access without running their own node, which is convenient when you are spinning up short-lived fork environments for exploit tests. The same endpoints work for deploying and probing contracts on a testnet before mainnet. If you are still getting comfortable with contract basics, our walkthrough on creating a first Solidity smart contract is a gentler starting point.
Beyond your own testing, an independent review matters for anything holding real value. Our overview of top smart contract auditing firms covers who to consider and what a thorough audit should include.
Conclusion
Reentrancy comes down to one discipline: update your contract’s state before you hand control to anyone else. Follow Checks-Effects-Interactions as the default, add OpenZeppelin’s nonReentrant (or its transient-storage variant) to functions that move value, and place the guard on every function that shares the affected state, not just the obvious one.
The DAO in 2016 and GMX in 2025 show the same lesson a decade apart: the bug is simple, the losses are not. Combine CEI, a guard, careful ETH-sending, and a proof-of-concept exploit test against forked state, and you close off the attack paths that have cost DeFi hundreds of millions of dollars. Then have the code audited before it touches real funds.
FAQ
Does the Checks-Effects-Interactions pattern stop every reentrancy attack?
CEI stops most single-function reentrancy, but not every variant. Cross-function and cross-contract cases can still slip through if separate functions share state, which is why a reentrancy guard on all affected functions is recommended alongside CEI.
Is reentrancy still possible in the latest Solidity version?
Yes. The compiler added default overflow protection in 0.8.0, but it does not prevent reentrancy. Reentrancy depends on your code’s logic and ordering, so the fix has to come from how you write functions, not from the compiler version.
What is the difference between ReentrancyGuard and ReentrancyGuardTransient?
Both block re-entrant calls with a lock. The original ReentrancyGuard keeps that lock in regular contract storage, while ReentrancyGuardTransient uses transient storage from EIP-1153, which clears automatically at the end of the transaction and costs less gas per protected call.
Can a view function be exploited through reentrancy?
Yes, through read-only reentrancy. A view function returns no profit on its own, but if an external protocol reads it during a reentrant call, it can receive mid-transaction state that is temporarily wrong and act on that stale data.
Why not just use transfer() to block reentrancy?
The 2,300 gas stipend from transfer once made re-entry impractical, but it is no longer reliable. Gas cost changes can break legitimate recipients, and the stipend is not a real security boundary. Use call for sending ETH and rely on CEI plus a guard instead.



