# Reentrancy Attack Explained — How It Works and How to Prevent It
Reentrancy is one of the most devastating vulnerabilities in smart contract security. The infamous DAO hack in 2016, which drained over $60 million worth of Ether, was caused by a reentrancy attack. Despite being well-known, reentrancy vulnerabilities continue to appear in modern contracts.
In this article, we'll explore how reentrancy attacks work, examine different types of reentrancy, and learn proven prevention techniques.
What Is a Reentrancy Attack?
A reentrancy attack occurs when an external contract call is made before the state is updated, allowing the called contract to re-enter the original function and exploit inconsistent state.
Classic Example: Vulnerable Withdrawal
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// VULNERABLE: External call before state update
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// External call BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// State update happens AFTER external call
balances[msg.sender] -= amount;
}
}
The Attack
An attacker can create a malicious contract with a receive() or fallback() function that calls withdraw() again:
contract Attacker {
VulnerableBank public bank;
uint256 public constant AMOUNT = 1 ether;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
// Attack function
function attack() external payable {
require(msg.value >= AMOUNT);
bank.deposit{value: AMOUNT}();
bank.withdraw(AMOUNT);
}
// Reentry point
receive() external payable {
if (address(bank).balance >= AMOUNT) {
bank.withdraw(AMOUNT);
}
}
}
Attack flow:
withdraw(1 ETH)receive() is triggeredwithdraw(1 ETH) again before balance is updatedTypes of Reentrancy Attacks
1. Single-Function Reentrancy
The classic case shown above, where the same function is called recursively.
2. Cross-Function Reentrancy
Reentering through a different function that shares state:
contract CrossFunction {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
// Different function, same state variable
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
An attacker can call transfer() during the withdraw() callback, exploiting the stale balance.
3. Cross-Contract Reentrancy
When two contracts share state (common in upgradeable contracts or proxy patterns):
contract TokenSale {
mapping(address => uint256) public contributions;
Token public token;
function buyTokens() external payable {
contributions[msg.sender] += msg.value;
// External call to another contract
token.mint(msg.sender, msg.value * 100);
}
}
contract Token {
TokenSale public sale;
function mint(address to, uint256 amount) external {
require(msg.sender == address(sale));
// If to is malicious contract, it can reenter TokenSale
_mint(to, amount);
}
}
Prevention Techniques
1. Checks-Effects-Interactions (CEI) Pattern
Always update state before making external calls.
function withdraw(uint256 amount) external {
// Checks
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effects (update state FIRST)
balances[msg.sender] -= amount;
// Interactions (external call LAST)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
This simple reordering prevents reentrancy because the balance is updated before the external call.
2. ReentrancyGuard Modifier
OpenZeppelin provides a battle-tested ReentrancyGuard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] -= amount;
}
}
How it works:
- Sets a lock flag before function execution
- Reverts if function is called while lock is active
- Releases lock after execution
3. Pull Over Push Pattern
Instead of pushing funds to users, let them pull:
contract SafeWithdrawal {
mapping(address => uint256) public pendingWithdrawals;
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Each user manages their own withdrawal, eliminating complex state interactions.
4. Mutex Locks
For cross-contract reentrancy, use a shared mutex:
contract MutexProtected {
bool private locked;
modifier noReentrancy() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
function criticalFunction() external noReentrancy {
// Protected code
}
}
Real-World Examples
The DAO Hack (2016)
- Impact: 3.6M ETH stolen (~$60M at the time)
- Cause: Reentrancy in
splitDAO()function
- Result: Ethereum hard fork to ETH and ETC
Uniswap V1 (Mitigated)
Early Uniswap versions were vulnerable to reentrancy via ERC-777 tokens (which have hooks). V2+ implemented proper reentrancy guards.
Cream Finance (2021)
- Impact: $130M stolen
- Cause: Flash loan + reentrancy in money market protocol
- Lesson: Reentrancy + flash loans = devastating combination
Detection and Testing
Static Analysis
Use tools like:
- Slither: Detects reentrancy patterns
- Mythril: Symbolic execution
- Securify: Formal verification
Manual Audit Checklist
✅ All external calls happen after state updates
✅ ReentrancyGuard on all functions with external calls
✅ No shared state between reentrant paths
✅ Pull pattern used where possible
✅ Cross-contract reentrancy considered
Fuzzing
Test with reentrancy scenarios:
// Foundry test
function testReentrancy() public {
Attacker attacker = new Attacker(address(bank));
vm.expectRevert("ReentrancyGuard: reentrant call");
attacker.attack{value: 1 ether}();
}
Best Practices Summary
nonReentrant to all functions with external calls — defense in depthConclusion
Reentrancy remains one of the most critical smart contract vulnerabilities. The good news? It's entirely preventable with disciplined coding practices.
The golden rule: External calls are untrusted. Always update your state before making them.
By combining the CEI pattern, ReentrancyGuard, and careful architecture, you can build contracts that are secure against reentrancy attacks.
Practice reentrancy prevention on Solingo — our interactive exercises include real-world scenarios and automated security checks to help you master secure coding patterns.