📋 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
viewgetter returns stale state mid-callback - Token Hooks: ERC777
tokensReceived/ ERC721onERC721Received - Delegatecall: Delegated code re-enters shared storage
- Gas Assumption: Relying on
transfer/sendstipend 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!
↓
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
nonReentrantas 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
viewgetters against read-only reentrancy - Settle all state before
delegatecallexternal calls - Do NOT rely on
transfer/sendgas 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 analyzeto 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
viewgetter (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
nonReentrantto functions sharing state - DO use pull-over-push withdrawals
- DO protect view getters and test with a re-entering harness