iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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 { /* … */ }

Test yourself

Q1. Reentrancy attacks exploit…
Q2. The canonical pattern to prevent it is…
Q3. A reusable defence is…

Discussion

Loading…