Reentrancy
Reentrancy is the bug that drained The DAO in 2016 and reshaped Solidity defaults. An external call lets the callee re-enter the caller before the callers state is updated; a malicious contract uses this to withdraw twice. The fix is the Checks-Effects-Interactions pattern + a reentrancy guard.
Vulnerable code, the fix, ReentrancyGuard
EXAMPLE
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// ============================================================
// 1) The vulnerable shape
// ============================================================
contract VulnerableVault {
mapping(address => uint256) public balanceOf;
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
// CLASSIC RE-ENTRANCY: external call BEFORE state update
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, 'insufficient');
(bool ok, ) = msg.sender.call{ value: amount }('');
require(ok, 'send failed');
balanceOf[msg.sender] -= amount; // updates AFTER external call
}
}
// ============================================================
// 2) The attacker
// ============================================================
contract Attacker {
VulnerableVault public vault;
constructor(VulnerableVault v) { vault = v; }
function attack() external payable {
vault.deposit{ value: msg.value }();
vault.withdraw(msg.value); // triggers receive() below
}
// ETH arrives here as part of the withdraw. The vault has NOT yet
// decreased balanceOf, so we re-enter and withdraw again.
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(msg.value);
}
}
}
// ============================================================
// 3) Fix #1 — Checks-Effects-Interactions
// ============================================================
contract SafeVault {
mapping(address => uint256) public balanceOf;
function withdraw(uint256 amount) external {
// CHECKS
require(balanceOf[msg.sender] >= amount, 'insufficient');
// EFFECTS (update state BEFORE external call)
balanceOf[msg.sender] -= amount;
// INTERACTIONS
(bool ok, ) = msg.sender.call{ value: amount }('');
require(ok, 'send failed');
}
}
// ============================================================
// 4) Fix #2 — ReentrancyGuard (defence in depth)
// ============================================================
import { ReentrancyGuard } from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
contract HardVault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
function withdraw(uint256 amount) external nonReentrant {
require(balanceOf[msg.sender] >= amount, 'insufficient');
balanceOf[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{ value: amount }('');
require(ok, 'send failed');
}
}
// ============================================================
// 5) Pull payments — a stronger pattern for batch payouts
// ============================================================
contract PullVault {
mapping(address => uint256) public pending;
function recordWin(address winner, uint256 amount) external {
pending[winner] += amount;
}
function withdraw() external {
uint256 amount = pending[msg.sender];
require(amount > 0, 'nothing');
pending[msg.sender] = 0; // effects first
(bool ok, ) = msg.sender.call{ value: amount }('');
require(ok, 'send failed');
}
}
// ============================================================
// 6) Cross-contract reentrancy
// ============================================================
// Reentrancy is not just about ETH calls. ANY external call can re-enter.
// Examples:
// - ERC-777 / ERC-1155 transfers with hooks
// - Calls to oracles, ERC-20 .transfer hooks
// - ABI-decoded callbacks from arbitrary protocols
// Rule: if you make an external call, treat your contract state as if it
// could be re-entered. Apply CEI + ReentrancyGuard.
// ============================================================
// 7) Test it with Foundry
// ============================================================
import 'forge-std/Test.sol';
contract ReentrancyTest is Test {
SafeVault vault;
function setUp() public {
vault = new SafeVault();
vm.deal(address(this), 10 ether);
}
function test_no_double_withdraw() public {
vault.deposit{ value: 1 ether }();
Attacker a = new Attacker(VulnerableVault(address(vault)));
vm.expectRevert();
a.attack{ value: 1 ether }();
}
}
// ============================================================
// 8) Audit checklist
// ============================================================
// - Every external call followed by state mutation -> SUSPICIOUS
// - Every payable function that sends ETH -> apply CEI or nonReentrant
// - Every callback hook (ERC777, ERC1155) -> applies same rule
// - Every '.call{ value }' -> consider what re-entry could do
// - 'Receive ERC20 -> do something' patterns -> watch for hook re-entry
// ============================================================
// 9) Why default to CEI even with nonReentrant?
// ============================================================
// nonReentrant catches the obvious cases. CEI defends against:
// - Cross-function reentrancy via shared state
// - Cross-contract reentrancy where the guard does not apply
// - Future code changes that move calls around
// Belt + braces; both cost nothing.
Why it matters
Checks-Effects-Interactions + ReentrancyGuard is belt-and-braces against the bug class that drained The DAO. Apply both: CEI makes the bug structurally impossible for the function under review; the guard catches future changes that quietly move an external call before a state update.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// BAD: external call before state update
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0; // updated AFTER call — re-entrancy!
}
// FIX: Checks-Effects-Interactions, or ReentrancyGuard
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // effect first
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
Try it Yourself »
Exercise
OpenZeppelin re-entrancy modifier.
function withdraw() external
{ /* … */ }
camelCase.
Discussion
Loading…