Back to Cheat Sheets

🔄 Reentrancy Attacks

OWASP Smart Contract Top 10 (2025) - SC05

CRITICAL RISK

📋 What Is It?

Reentrancy is an ordering bug: a contract makes an external call - sending ETH or invoking another contract - before it finishes updating its own state. The called party can call back ("re-enter") into the original function mid-execution, observing stale state that has not yet been written. Repeated re-entry against that stale state lets an attacker perform an action - most famously a withdrawal - many times when the contract intended it once. The fix is to finalise all state before control ever leaves the contract.

SC05 OWASP Rank
Critical Severity
CEI Primary Fix

⚠️ Common Exploits

  • Single-Function: Re-enter the same withdraw() loop (DAO class)
  • Cross-Function: Re-enter a sibling function sharing the same state
  • Cross-Contract: Re-enter through another contract holding shared state
  • Read-Only: A view getter returns stale state mid-callback
  • Token Hooks: ERC777 tokensReceived / ERC721 onERC721Received
  • Delegatecall: Delegated code re-enters shared storage
  • Gas Assumption: Relying on transfer/send stipend as "safe"

🔴 Attack Flow

1. Attacker deposits, then calls withdraw()
↓
2. Contract sends ETH BEFORE zeroing the balance
↓
3. Attacker's receive() re-enters withdraw()
↓
4. Balance still non-zero → paid again, and again
↓
5. IMPACT: Entire contract balance drained in one tx!

❌ Vulnerable Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract VulnerableBank { mapping(address => uint256) public balance; function deposit() external payable { balance[msg.sender] += msg.value; } // BUG: external call happens BEFORE the balance is zeroed function withdraw() external { uint256 amount = balance[msg.sender]; require(amount > 0, "nothing to withdraw"); // Interaction first: hands control to msg.sender's receive() (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); balance[msg.sender] = 0; ← too late: attacker already re-entered } } // Attacker's receive() re-enters withdraw() while balance is still // non-zero, looping until the bank is drained: // receive() external payable { // if (address(bank).balance >= 1 ether) bank.withdraw(); // }

✅ Secure Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ReentrancyGuard} from "@openzeppelin/.../ReentrancyGuard.sol"; contract SecureBank is ReentrancyGuard { mapping(address => uint256) public balance; function deposit() external payable { balance[msg.sender] += msg.value; } // Checks-Effects-Interactions + nonReentrant = defence in depth function withdraw() external nonReentrant { // 1. Checks uint256 amount = balance[msg.sender]; require(amount > 0, "nothing to withdraw"); // 2. Effects - state finalised BEFORE the external call balance[msg.sender] = 0; // 3. Interactions (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } // Apply nonReentrant to EVERY function that shares balance[] to // close cross-function re-entry. For ERC777/ERC721, update flags // BEFORE safeTransfer/safeMint. Prefer pull-over-push refunds. }

✓ Prevention Checklist

  • Follow Checks-Effects-Interactions on every value-moving function
  • Write all state BEFORE any external call
  • Add OpenZeppelin nonReentrant as defence-in-depth
  • Guard every function that shares the same state
  • Use pull-over-push withdrawals (record, then let users pull)
  • Update accounting before ERC777/ERC721 hooked transfers
  • Protect view getters against read-only reentrancy
  • Settle all state before delegatecall external calls
  • Do NOT rely on transfer/send gas stipend
  • Minimise and isolate external calls; make them last
  • Write an attacker harness that re-enters; assert invariants

🔍 Detection & Tools

Analysis Tools:

Slither Mythril Echidna Foundry Manticore Securify

Libraries:

OpenZeppelin ReentrancyGuard SafeERC20 Tenderly

How to Test:

  • Run slither . - reentrancy-eth / reentrancy-no-eth detectors
  • Run mythril analyze to explore reentrant paths
  • Write an attacker contract whose receive()/hook re-enters
  • Assert invariant: total paid out <= total deposited
  • Fuzz with Echidna/Foundry against those invariants

🌍 Real-World

  • The DAO (2016): The withdrawal ("split") path sent ETH before zeroing the member's balance; an attacker's fallback re-entered it repeatedly, draining a large fraction of funds. The fallout led the community to hard-fork the chain into Ethereum and Ethereum Classic - the archetype the whole category is named for.
  • Token-callback reentrancy (ERC777 class): Protocols treated a token transfer as a non-reentrant step, but the token ran a recipient hook (tokensReceived); attackers re-entered against accounting the transfer had not yet finalised.
  • Read-only reentrancy in DeFi integrations: A pool's view getter (virtual price / share value) returned inconsistent state during a withdrawal callback; integrating lending markets priced collateral on the stale value - without the getter ever writing state.
  • Why it persists: The classic loop is now widely caught, so surviving bugs are the subtler cross-function, cross-contract, and read-only variants as composability grows.

📌 Quick Tips

  • DO NOT make an external call before writing state
  • DO NOT assume a token transfer can't call back
  • DO NOT rely on gas stipends for safety
  • DO apply Checks-Effects-Interactions everywhere
  • DO add nonReentrant to functions sharing state
  • DO use pull-over-push withdrawals
  • DO protect view getters and test with a re-entering harness