Reentrancy Attacks in Solidity: How They Work and How to Prevent Them
Reentrancy is one of the oldest and most destructive vulnerability classes in Solidity. The 2016 DAO hack drained roughly 3.6 million ETH by exploiting it, and the pattern has resurfaced in dozens of DeFi protocols since. Understanding exactly how reentrancy works — and the subtle variants that still catch auditors off guard — is essential for any developer deploying value-holding contracts on-chain.
What Is a Reentrancy Attack?
A reentrancy attack occurs when an external contract calls back into the calling contract before the first execution has finished. Because the Ethereum Virtual Machine is single-threaded and re-entrant by design, there is nothing at the protocol level to prevent this. If your contract sends ETH or calls an external address before updating its own state, an attacker can trigger a callback that re-enters your function while the original call's state changes are still pending.
The canonical example is an ETH withdrawal function:
// VULNERABLE
contract Bank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// 1. Send ETH BEFORE updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 2. State update happens too late
balances[msg.sender] = 0;
}
}
An attacker deploys a contract whose receive() function calls Bank.withdraw() again. When Bank sends ETH on line 1, control transfers to the attacker's contract. At that moment balances[attacker] still reflects the original deposit, so the balance check passes and another withdrawal goes out. This loop repeats until the bank is empty or the gas runs out.
Three Variants You Need to Know
1. Single-Function Reentrancy
The example above is single-function reentrancy: the attacker re-enters the same function (withdraw) that initiated the external call. This is the easiest variant to detect with static analysis tools, and it is what most junior developers think of when they hear "reentrancy."
2. Cross-Function Reentrancy
Cross-function reentrancy is harder to spot because the attacker re-enters a different function that shares state with the vulnerable one. Consider:
// VULNERABLE
contract Token {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
function withdrawAll() external {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0; // too late
}
}
During the withdrawAll call, before balances[msg.sender] is zeroed, the attacker's callback invokes transfer to move the stale balance to another address they control. Now the balance is drained and transferred, doubling the damage.
3. Read-Only Reentrancy
Read-only reentrancy is the most subtle variant and the one that has hit several Curve-adjacent protocols. It arises when a contract reads price or state data from another protocol mid-transaction, while that protocol is itself in an inconsistent state due to an in-progress external call. No ETH needs to be sent to the victim contract; the attacker merely reads manipulated values and acts on them — for example, computing a collateral ratio against a temporarily wrong token price.
Defending against read-only reentrancy requires understanding your protocol's trust boundaries: any view function that returns data derived from a mutable state variable can be read while that variable is in a transient, incorrect state if a reentrancy vector exists upstream.
The Checks-Effects-Interactions Pattern
The most fundamental defense is structural: always perform all state changes before any external call. The checks-effects-interactions (CEI) pattern codifies this discipline into three ordered phases:
- Checks: Validate all preconditions (
require,revert). - Effects: Update every relevant state variable.
- Interactions: Make external calls or ETH transfers only after state is final.
Applying CEI to the vulnerable bank example:
// FIXED with CEI
contract Bank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender]; // Check
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effect (state updated FIRST)
(bool success, ) = msg.sender.call{value: amount}(""); // Interaction
require(success, "Transfer failed");
}
}
Now when the attacker's callback fires, balances[attacker] is already zero, so require(amount > 0) reverts the re-entrant call immediately.
Reentrancy Guards
CEI is the right first line of defense, but for complex protocols with many interacting functions a reentrancy guard provides an explicit, auditable lock. OpenZeppelin's ReentrancyGuard is the standard implementation:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Bank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
The nonReentrant modifier uses a storage slot to track whether execution is already inside a guarded function. If a callback attempts to enter any nonReentrant function in the same contract, it reverts immediately. Note two limitations: the guard does not protect against cross-contract reentrancy between two separate contracts, and it costs a small amount of additional gas for the storage read and write.
For cross-function reentrancy, apply nonReentrant consistently to every function that reads or writes the shared state, not just the one that makes the external call.
Pull-Over-Push for ETH Transfers
Another architectural pattern that reduces reentrancy risk is pull-over-push: instead of sending ETH directly to recipients, credit their balance in a mapping and let them claim it themselves in a separate transaction. This means your core business logic never triggers an external call mid-execution.
contract Escrow {
mapping(address => uint256) public pendingWithdrawals;
// Internal: just update accounting, no external call
function _creditRecipient(address recipient, uint256 amount) internal {
pendingWithdrawals[recipient] += amount;
}
// Separate function the recipient calls themselves
function claimPayment() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0);
pendingWithdrawals[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
Pull-over-push also prevents a different class of issue: a malicious recipient contract reverting on receipt to block payments to other parties. For a broader discussion of how ETH transfer methods interact with reentrancy risk, see the guide on Unchecked External Calls and the Risks of call, send and transfer.
How Static Analysis Tools Detect Reentrancy
Automated tools can catch a large proportion of reentrancy patterns before you deploy. Slither, for example, uses taint analysis to trace control flow and flag any function that makes an external call before completing all state updates. If you want to understand exactly how Slither approaches this, the guide What Is Slither? A Practical Guide to the Solidity Static Analyzer covers its detector categories and severity ratings in detail.
Cross-function and read-only variants are harder for static analysis to catch reliably because they require reasoning about state shared across multiple functions and external protocol boundaries. Formal verification tools such as SMTChecker and fuzzing with Echidna can surface these patterns, especially when you write invariant properties like "the sum of all balances must never exceed the contract's ETH balance." You can run an automated scan that combines Slither, Mythril, SMTChecker, and Echidna in parallel to get comprehensive coverage across all these detection strategies simultaneously.
Reentrancy is also a significant concern during ERC-20 token audits because ERC-777 tokens and fee-on-transfer tokens can trigger callbacks in contexts where ERC-20 behavior is assumed. The guide on How to Audit an ERC-20 Token Contract covers those edge cases in depth.
Summary of Defenses
- Follow checks-effects-interactions rigorously in every function that makes an external call.
- Apply
nonReentrantguards to all functions sharing mutable state with any function that calls external addresses. - Prefer pull-over-push for ETH distribution to eliminate outbound calls from core logic.
- Treat any contract you call as potentially adversarial, including token contracts that may implement ERC-777-style callbacks.
- Audit read-only functions in integrated protocols: if they can be called while upstream state is mid-update, your price or collateral calculations may be manipulable.
- Run static analysis and fuzzing on every deployment to catch patterns the human eye misses under time pressure.
No single technique eliminates reentrancy risk entirely. CEI prevents the most common cases; reentrancy guards provide an explicit second layer; pull-over-push removes the attack surface at the architectural level. Using all three together, combined with automated scanning, gives you defense in depth.
Scan your contract before you ship
Run an automated, transparent security scan — seven industry tools in parallel, every finding labeled with its source tool. It is not a substitute for a full manual audit, but it is a fast first line of defense.
Scan a contractFrequently asked questions
What is the simplest way to prevent a reentrancy attack in Solidity?
Follow the checks-effects-interactions pattern: perform all state changes — zeroing balances, updating counters, recording flags — before making any external call or ETH transfer. This means a re-entrant callback will encounter already-updated state and fail its precondition checks. Adding OpenZeppelin's nonReentrant modifier as a second layer is also strongly recommended for any function that interacts with external addresses.
Does using transfer() or send() instead of call() protect against reentrancy?
Historically, transfer() and send() were considered safe because they forward only 2300 gas, which is insufficient for storage writes in a callback. However, EIP-1884 repriced storage opcodes, making the 2300 gas stipend an unreliable defense. The Solidity community now recommends using call() paired with proper CEI and a reentrancy guard rather than relying on gas limits as a security mechanism.
What is cross-function reentrancy and why is it harder to detect?
Cross-function reentrancy occurs when an attacker's callback re-enters a different function in the same contract, rather than the one that initiated the external call. Both functions share state variables that are not yet updated when the callback fires. Static analysis tools can miss this pattern because they must track state dependencies across multiple function paths simultaneously. Applying nonReentrant guards to every function that reads or writes shared state, not just the one making the external call, is the most reliable defense.
What is read-only reentrancy?
Read-only reentrancy is a variant where the attacker does not steal funds from the vulnerable contract directly. Instead, they exploit the fact that during a reentrancy callback, another contract's state is temporarily inconsistent. A victim protocol that reads token prices or collateral ratios from the re-entered contract during this window will compute incorrect values and can be manipulated into under-collateralized loans or mispriced trades. Defending against it requires ensuring that any external data source your protocol reads cannot be in a transient, incorrect state when your code queries it.
Can automated tools reliably detect all reentrancy vulnerabilities?
Static analysis tools like Slither catch a large proportion of single-function reentrancy patterns reliably and at low cost. Cross-function and read-only reentrancy are harder because they require reasoning about shared state across multiple functions and external protocol boundaries. Fuzzing tools like Echidna and formal verification via SMTChecker improve coverage when you write explicit invariant properties. In practice, automated scanning is a strong first filter, but complex protocol interactions involving multiple contracts still benefit from manual review in addition to automated analysis.