Solidity Reentrancy Attacks: How They Work and How to Prevent Them
A deep-dive into Solidity reentrancy attacks — how the exploit works at the EVM level, real patterns that introduce it, and the defenses that reliably stop it.
Reentrancy is the most documented vulnerability in Ethereum's history. It drained the DAO in 2016 — a multi-million dollar incident that led to a network fork. Fifteen years later, variants of the same bug still appear in audited codebases. Understanding exactly how it works is the fastest path to not writing it.
How Solidity Reentrancy Attacks Work
A reentrancy attack exploits the gap between when your contract makes an external call and when it updates its own state.
Consider a simple withdrawal function:
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// External call BEFORE state update — dangerous
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // State updated too late
}
When msg.sender.call{value: amount}("") executes, control passes to the recipient. If the recipient is a contract, its receive() or fallback() function runs. A malicious contract can call withdraw() again from inside that function — before balances[msg.sender] -= amount has executed. The balance check passes every time because the state has not been updated.
The attacker's contract looks like this:
contract Attacker {
VulnerableVault target;
receive() external payable {
// Re-enter while the victim's state is still stale
if (address(target).balance >= 1 ether) {
target.withdraw(1 ether);
}
}
function attack() external {
target.withdraw(1 ether);
}
}
Each nested call to withdraw() passes the balance check and sends another 1 ETH. The loop continues until the vault is drained or gas runs out.
Three Defenses That Reliably Prevent Reentrancy
Defense 1: Checks-Effects-Interactions (CEI) Pattern
This is the primary defense. Restructure every function so that:
- All checks happen first
- All state updates happen second
- External calls happen last
The fixed version of the withdrawal:
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount; // State updated BEFORE external call
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Now when the attacker's receive() re-enters withdraw(), the balance check fails because the state was already decremented.
Defense 2: ReentrancyGuard
OpenZeppelin's ReentrancyGuard implements a mutex at the contract level. A function marked nonReentrant cannot be entered again while it is already executing:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
The guard stores a lock value in a storage slot. It costs a small amount of extra gas on entry and exit, but the protection is unconditional.
Using both CEI and ReentrancyGuard together is common in production systems — defense in depth.
Defense 3: Pull Over Push
Instead of sending ETH to users inside your function (push), let users withdraw it themselves (pull). This moves the external call risk from your logic to the withdrawal step:
mapping(address => uint256) public pendingWithdrawals;
function claimReward() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Withdrawal failed");
}
The state is cleared before the external call, and the function is isolated to just the withdrawal concern.
Cross-Function Reentrancy
A subtler variant occurs across multiple functions in the same contract. If functionA changes state and calls an external address, an attacker can re-enter functionB — which reads the same state that functionA has not finished updating.
The nonReentrant modifier blocks this automatically when applied to both functions. CEI alone does not prevent cross-function reentrancy unless both functions update the same state before their external calls.
Read-Only Reentrancy
An advanced variant targets protocols that read state from other contracts. If your contract calls into a contract that is mid-execution (and therefore in an inconsistent state), your price calculation or balance check might return wrong values. This cannot be prevented by ReentrancyGuard on the attacker's side — you must be careful about which external contracts you read from and when.
Common Patterns That Introduce Reentrancy
- Sending ETH to
msg.senderbefore updating balances - Calling user-supplied addresses without guards
- ERC-777 token callbacks (which fire on transfer)
- ERC-721
safeTransferwhich callsonERC721Receivedon the recipient
Any time your contract hands execution to an external address — through a raw call, a token transfer, or a callback — reentrancy is a consideration.
Preventing Reentrancy in Production
Apply these checks to every contract you ship:
- Run Slither:
slither . --detect reentrancy-eth,reentrancy-no-eth - Review every external call for CEI compliance
- Apply
nonReentrantto all value-moving functions - Have a second engineer review reentrancy-sensitive code paths specifically
Reentrancy is not exotic. It is a mechanical property of the EVM's execution model. Understanding the model makes the prevention mechanical too.
If you are building a protocol or product that handles user funds on-chain, Clixo ships contract systems with reentrancy prevention and full security analysis built into the engineering process.