Command Palette

Search for a command to run...

Reentrancy — Classic Pattern

The vault below has a subtle flaw that lets an attacker drain it. The Primer teaches the model you need; the Hunt is where you find it.

~8 min read

Available

If you come from Java/TypeScript, Solidity looks familiar but the execution model is not. Three sections below give you just enough to hunt this class of bug — not the whole language.

1. The EVM in 60 seconds

Your contract runs in a single global virtual machine shared with every other contract on the chain. There's no JVM per app, no process isolation by compilation — only by storage address. Every instruction (opcode) costs gas; if the transaction runs out, it reverts and state is rolled back, but the caller still pays for the burned gas.

State lives in three places. storage is persistent per-contract, stored on-chain, expensive to write (20k gas for a new slot). memory is a scratchpad per call, cheap, gone when the call ends. calldata is the read-only transaction input.

Every contract has an address and a balance in wei (1 ETH = 10^18 wei). Inside a function, msg.sender is whoever called this frame (could be a user, could be another contract), and msg.value is the ETH attached to this call.

2. When a contract sends ETH, anything can happen

There are several ways to send ETH. transfer() forwards 2300 gas and reverts on failure (historically safe, no longer recommended after EIP-1884 changed opcode costs). send() returns a bool. The modern primitive is call{value: x}("") which forwards all remaining gas and returns a bool.

If the recipient is an externally-owned account (EOA, a user's wallet), the ETH just arrives. If the recipient is a contract, the EVM runs that contract's receive() function (or fallback() if no receive() is defined). That function can do anything: write to storage, emit events, call other contracts — including calling yours back.

This is the pivot point for reentrancy: when you do msg.sender.call{value: ...}(""), you're not dispatching a passive transfer. You're handing execution control to whatever code lives at that address. If the address is user-controlled and the user is an attacker, they get to run code with a call stack that includes your not-yet-finished function.

3. Reentrancy — the attack

The VulnerableVault below tracks user balances and lets them withdraw. Pseudocode of the vulnerable flow:

withdraw(amount):
  1. require(balances[msg.sender] >= amount)   ← check
  2. msg.sender.call{value: amount}("")         ← interaction
  3. balances[msg.sender] -= amount              ← effect (too late)

Now imagine msg.sender is an attacker contract with a receive() function that calls withdraw again. The attack sequence:

Attack timeline

  1. 1

    Setup

    Attacker deposits 1 ETH. balances[attacker] = 1.

  2. 2

    Frame 1

    Attacker calls withdraw(1). Check passes (balance = 1). Vault sends 1 ETH to attacker via call.

  3. 3

    Frame 2 (nested)

    Attacker's receive() runs. It calls withdraw(1) on the vault AGAIN. Check passes (balance STILL = 1 — the outer frame hasn't decremented yet). Vault sends another 1 ETH.

  4. 4

    Frame 3, 4, 5…

    Each recursive receive() calls withdraw(1) once more. Each call drains 1 ETH. Loop until the vault is empty or gas runs out.

  5. 5

    Unwind

    Only now does the deepest frame return. Every frame decrements balance by 1 on unwind, but the vault is already drained.

4. The fix — Checks-Effects-Interactions

Reorder the function so state updates happen before the external call. This is called the Checks-Effects-Interactions (CEI) pattern:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount); // CHECKS
    balances[msg.sender] -= amount;           // EFFECTS (state BEFORE calls)
    (bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTIONS
    require(ok, "transfer failed");
}

Now when the attacker's receive() re-enters withdraw, the check fails (balance is already 0). The attack collapses.

An alternative is the nonReentrant modifier (OpenZeppelin's ReentrancyGuard): a storage slot acts as a lock, reverting if the function is already on the stack. Cheaper to reason about, slightly more gas. CEI is preferred when you can do it — it addresses the underlying order-of-operations issue instead of bolting on a guard.

5. What to look for as an auditor

  • Any function that makes an external call: call / send / transfer / staticcall. Follow the call path.
  • For each external call, ask: does any contract state get written AFTER this call? If yes, reentrancy surface.
  • Who is the call target? Known trusted address (owner, known token) → lower risk. User-controlled (msg.sender, to, recipients[i]) → high risk.
  • Is there a nonReentrant modifier or a manual lock? No guard + user-controlled target + state-after-call = bug.
  • Don't forget read-only reentrancy: view functions can return stale state mid-attack, feeding a downstream contract wrong numbers.
  • Cross-function reentrancy: attacker re-enters a DIFFERENT function that reads the same state. The victim function isn't locked if the guard is only on one function.

With this model in hand, open the Hunt tab. Read the contract, form your hypothesis, write it in the scratchpad, then check yourself against Reveal.

← Back to skill tree