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
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.
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
- 2
- 3
- 4
- 5
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:
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.