Reentrancy Guard

Difficulty: Intermediate

Reentrancy is one of the most well-known and devastating vulnerabilities in smart contract security. The classic reentrancy attack occurs when a contract makes an external call to another contract before updating its own state. The called contract can then re-enter the original function before the state update happens, exploiting the stale state. The most famous example is the 2016 DAO hack, which resulted in the loss of 3.6 million ETH and ultimately led to the Ethereum hard fork.

The attack works like this: imagine a contract with a `withdraw()` function that first checks the user's balance, then sends ETH via a low-level `.call{value: amount}("")`, and finally sets the user's balance to zero. If the recipient is a contract with a `receive()` function, that function executes during the ETH transfer. The malicious `receive()` function calls `withdraw()` again. Since the balance hasn't been zeroed yet (that line comes after the transfer), the check passes, and the contract sends ETH again. This loop continues until the contract is drained.

The Checks-Effects-Interactions (CEI) pattern is the primary defense against reentrancy. The pattern prescribes a strict ordering for function logic: first perform all checks (require statements, validations), then make all state changes (effects), and finally interact with external contracts (interactions). By updating the state before making external calls, re-entering the function will encounter the already-updated state, and the checks will fail. In the withdraw example, setting the balance to zero before sending ETH prevents the attack because a re-entrant call would see a zero balance.

The mutex (mutual exclusion) lock, commonly known as a reentrancy guard, provides an additional layer of defense. OpenZeppelin's `ReentrancyGuard` contract implements a `nonReentrant` modifier that sets a boolean lock at function entry and releases it at exit. If a re-entrant call attempts to enter the function while the lock is held, the transaction reverts. This approach is belt-and-suspenders: even if a developer accidentally violates the CEI pattern, the reentrancy guard catches the attack.

Modern Solidity development best practices recommend using both CEI and reentrancy guards together. While CEI is the fundamental pattern, reentrancy guards catch edge cases in complex multi-function interactions where cross-function reentrancy might occur. Cross-function reentrancy happens when function A calls an external contract, which then calls function B in the original contract, where function B reads state that function A hasn't finished updating. The reentrancy guard protects against this by locking the entire contract, not just individual functions.

Code examples

Vulnerable Contract vs Protected Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ========== VULNERABLE CONTRACT ==========
contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // VULNERABLE: sends ETH before updating state
    function withdraw() external {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");

        // INTERACTION before EFFECT - vulnerable!
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");

        // State update happens AFTER the external call
        balances[msg.sender] = 0;
    }
}

// ========== ATTACKER CONTRACT ==========
contract Attacker {
    VulnerableBank public bank;
    uint256 public attackCount;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH");
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    // This is called when the bank sends ETH
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            attackCount++;
            bank.withdraw();  // Re-enter!
        }
    }
}

// ========== PROTECTED CONTRACT ==========
contract SecureBank {
    mapping(address => uint256) public balances;
    bool private locked;

    modifier nonReentrant() {
        require(!locked, "Reentrancy detected");
        locked = true;
        _;
        locked = false;
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // PROTECTED: uses CEI pattern + reentrancy guard
    function withdraw() external nonReentrant {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");  // CHECK

        balances[msg.sender] = 0;  // EFFECT (state update FIRST)

        (bool success, ) = msg.sender.call{value: balance}("");  // INTERACTION
        require(success, "Transfer failed");
    }
}

The VulnerableBank sends ETH before zeroing the balance, enabling reentrancy. The SecureBank applies both the CEI pattern (zeroing balance before transfer) and a nonReentrant modifier for defense in depth.

Cross-Function Reentrancy

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// VULNERABLE to cross-function reentrancy
contract VulnerableTokenBank {
    mapping(address => uint256) public balances;
    mapping(address => bool) public hasAccount;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        hasAccount[msg.sender] = true;
    }

    function withdraw() external {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");

        // External call - attacker can re-enter via transfer()
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }

    // A different function that reads the same state
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

// PROTECTED with OpenZeppelin ReentrancyGuard
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureTokenBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");
        balances[msg.sender] = 0;  // CEI: effect before interaction
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
    }

    function transfer(address to, uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

Cross-function reentrancy exploits shared state between functions. An attacker re-enters via transfer() during withdraw(). Using nonReentrant on both functions prevents this since the mutex lock is contract-wide.

Key points

Concepts covered

Reentrancy, Mutex, CEI Pattern, nonReentrant