Skip to content
Learn · Advanced

Reentrancy and checks-effects-interactions

The classic smart contract exploit — how reentrant calls drain contracts and how OpenZeppelin guards and CEI stop it.

Advanced
14 min read

The classic pattern

Contract sends ETH or calls external code before updating balances. The recipient's fallback reenters the same function while the first call's state updates are incomplete. Second withdrawal passes the balance check again — repeat until drained. The DAO hack (2016) popularized this class.

  • ETH transfers via call forward all gas by default — reentrancy friendly.
  • ERC777 tokens call hooks on transfer — reentrancy without ETH.
  • Read-only reentrancy: view functions see stale state during nested calls.

ReentrancyGuard

OpenZeppelin ReentrancyGuard maintains a status flag. nonReentrant modifier blocks nested entry to the same function (and optionally cross-function variants with upgraded guard). Cheap insurance for withdrawal and claim functions.

  • Import from @openzeppelin/contracts/utils/ReentrancyGuard.sol (or upgradeable variant).
  • Apply nonReentrant on every external entry that touches shared balances.
  • Guard does not fix incorrect CEI by itself — combine both.
Vault.solsolidity
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Vault is ReentrancyGuard {
    IERC20 public immutable token;
    mapping(address => uint256) public balances;

    constructor(IERC20 token_) {
        token = token_;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient");
        balances[msg.sender] -= amount;
        token.transfer(msg.sender, amount);
    }
}

Cross-function reentrancy

Guarding withdraw() alone fails if redeem() reads the same balance mapping without a guard. Attacker reenters through a sibling function. Audit all code paths that read or write overlapping state during an external call window.

  • Map all functions that touch balances, shares, or debt.
  • Pausable can halt deposits during incident — not a structural fix.
  • Read-only reentrancy affects oracle pricing if spot balances are wrong mid-tx.

Pull over push payments

Instead of pushing ETH to many recipients in a loop, record entitlements and let users withdraw. Isolates failure — one bad receiver cannot block everyone. OpenZeppelin PaymentSplitter and pull patterns follow this model.

  • Crowdfunding refunds benefit from pull claims after campaign ends.
  • Gas limits on push loops can brick settlement (DoS).
  • Escrow releases to pull-based withdraw after conditions met.

Pausable emergency stop

OpenZeppelin Pausable lets authorized roles pause user-facing functions while admins investigate. Not a substitute for secure design — attackers may drain in one transaction before you pause. Pair with monitoring on anomalous outflows.

  • whenNotPaused modifier on deposit/withdraw/swap entrypoints.
  • PAUSER_ROLE separate from DEFAULT_ADMIN reduces key exposure.
  • Unpause after patch deploy and layout verification.
Pausable.solsolidity
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract Service is AccessControl, Pausable {
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
}

Checks-effects-interactions in practice

Checks: validate amounts, roles, deadlines. Effects: update balances, increment nonces, mark claimed. Interactions: external calls, transfers, callbacks. If interactions must come first (rare), use reentrancy guard and document why.

  • ERC1155 safeTransfer triggers onERC1155Received — treat as interaction.
  • Oracle updates should use TWAP or staleness bounds — not spot mid-reentrancy.
  • Test with malicious receiver contracts in Foundry/Hardhat.

Audit mindset

Ask for every external call: can the callee reenter? What state is stale? What sibling functions assume atomicity? OpenZeppelin templates apply guards on sensitive paths — custom overrides can remove them. Diff your changes against upstream.