How Reentrancy Attacks Work in Solidity — and How to Prevent Them
Why I'm Writing This
I've seen reentrancy bugs in production code exactly twice. Both times, the developer knew about it in theory but brushed it off as "unlikely." One cost a company $40k in lost funds before we patched it. The other was caught in audit, thank God. The frustrating part? These aren't subtle bugs hidden in complex math. They're straightforward to understand and straightforward to prevent. You just have to actually do it.
Reentrancy isn't going away. It's one of the oldest classes of vulnerabilities in Ethereum (remember the DAO hack in 2016?), and I still see it in production contracts. So here's how it works, where it bites you, and the fixes that actually work.
What Is Reentrancy?
Reentrancy happens when a function calls another contract before it finishes updating its own state. That external contract can then call back into the original function before state is settled. Think of it like a recursive loop you didn't authorize.
Here's the classic scenario: a contract sends ETH to an attacker-controlled address via .call() or .transfer(). Before the transaction completes, the attacker's fallback function executes code that calls back into the victim contract. The victim's state hasn't updated yet, so the attacker can withdraw more than they should.
It's sneaky because the code looks fine at first glance.
The Vulnerable Pattern
Let me show you what I mean:
// VULNERABLE - Don't use this
pragma solidity ^0.8.0;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
// DANGER: We send money before updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// State update happens AFTER external call
balances[msg.sender] -= amount;
}
}
Now here's the attacker's contract:
// ATTACK CONTRACT
pragma solidity ^0.8.0;
contract Attacker {
VulnerableBank public bank;
uint256 public withdrawalAmount;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() public payable {
withdrawalAmount = 1 ether;
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}
// This runs when the bank sends ETH to us
receive() external payable {
// The bank's state hasn't updated yet!
// balances[attacker] still shows 1 ether
// So we can call withdraw again
if (address(bank).balance >= withdrawalAmount) {
bank.withdraw(withdrawalAmount);
}
}
}
Here's how the attack plays out:
- Attacker deposits 1 ETH (balance = 1 ETH)
- Attacker calls
withdraw(1 ETH) - Bank checks: balance is 1 ETH ✓
- Bank sends 1 ETH via
.call()→ attacker'sreceive()triggers -
While the first withdrawal is still executing, attacker's fallback calls
withdraw(1 ETH)again - Bank checks: balance is still 1 ETH (because the state update hasn't happened yet!) ✓
- Bank sends another 1 ETH
- Attacker walks away with 2 ETH, but only paid 1 ETH in
This is a recursive re-entry into the same function. The bank's balance sheet is completely wrong.
Why .call() Made This Worse
I want to point out something specific. Before Istanbul, everyone used .transfer() because it had a 2,300 gas stipend that prevented reentrancy. Then .call() became the standard because .transfer() wasn't reliable with smart contract wallets. Good intention, new problem.
// Old way (safer, but limited)
msg.sender.transfer(amount);
// New way (flexible, but dangerous if you're careless)
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
The .call() approach is correct and necessary for modern contracts. But it requires you to handle state properly. There's no safety net.
Fix #1: Check-Effects-Interactions (CEI)
This is the simplest fix and honestly the one I use 95% of the time. The pattern is straightforward: validate preconditions, update your state, then make external calls.
// SAFE - CEI Pattern
pragma solidity ^0.8.0;
contract SafeBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Checks
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effects - Update state BEFORE calling external code
balances[msg.sender] -= amount;
// Interactions - Only then send the money
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Now if the attacker's receive() tries to call withdraw() again, the check fails because their balance is already zero. The function reverts before any reentrancy happens.
Simple. Effective. This alone prevents 99% of the reentrancy I see in audits.
Fix #2: Reentrancy Guard (Mutex Lock)
Sometimes your logic is complex enough that CEI isn't clean. Or you want defense-in-depth. A reentrancy guard uses a state variable to prevent recursive calls:
// SAFE - Reentrancy Guard
pragma solidity ^0.8.0;
contract ProtectedBank {
mapping(address => uint256) public balances;
uint256 private locked = 1; // 1 = unlocked, 2 = locked
modifier nonReentrant() {
require(locked == 1, "No reentrancy");
locked = 2;
_;
locked = 1;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
On the first call, locked flips to 2. Any reentrancy attempt hits require(locked == 1) and fails immediately. Only when the outermost call finishes does locked flip back to 1.
Pro tip: OpenZeppelin's ReentrancyGuard does exactly this (uses 1 and 2 to save gas vs true/false). Just import and use it:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SaferBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Gotchas I've Seen in the Wild
Cross-Function Reentrancy
Most people think reentrancy only means a function calling itself. Wrong. It can be different functions in the same contract. Say you have withdraw() and deposit(). An attacker can call withdraw(), get reentered through receive(), then call deposit() to inflate their balance before the original withdraw() completes and deducts from it. Both CEI and guards work against this too, but you have to be consistent across all state-changing functions.
Forgetting Intermediate Contracts
I once audited a contract that called swapExactTokensForETH() on Uniswap v2, which eventually sent ETH back. The developer thought "but I'm not directly calling user code" so they didn't worry about reentrancy. Wrong. The intermediate contract can have any logic it wants. Even if you're using CEI, indirect external calls are still external calls. Audit your dependencies.
State Changes Aren't Atomic
Even CEI doesn't protect you if multiple state updates happen separately. Say you decrement balances[msg.sender] and increment totalWithdrawn in separate lines. If the call() reverts, balances updated but totalWithdrawn didn't. Now your accounting is broken. Use CEI strictly, or wrap everything in a try-catch that rolls back all state together.
Forgetting to Apply Guards Everywhere
If you use a reentrancy guard on withdraw() but not on deposit(), an attacker can still loop through both. Apply guards consistently to all state-changing functions if you go this route.
Testing for Reentrancy
Here's a Hardhat test that would catch the vulnerable contract:
// test/reentrancy.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy", function () {
let bank, attacker;
beforeEach(async function () {
const Bank = await ethers.getContractFactory("VulnerableBank");
bank = await Bank.deploy();
const Attacker = await ethers.getContractFactory("Attacker");
attacker = await Attacker.deploy(bank.address);
});
it("should prevent reentrancy attack", async function () {
// Fund the attacker with 1 ETH
const [signer] = await ethers.getSigners();
await signer.sendTransaction({
to: attacker.address,
value: ethers.utils.parseEther("1"),
});
// Try the attack — should fail on safe version
await expect(attacker.attack()).to.be.revertedWith(
"Insufficient balance"
);
});
});
The Bottom Line
Reentrancy is old, it's well-understood, and it's preventable. Use Check-Effects-Interactions as your default. Add a guard if your logic is too complicated for CEI. Test against it. Don't be the person who says "it's unlikely"—because when it happens, it's expensive.
Top comments (0)