Module 8 · Ethereum, the EVM & on-chain zero-knowledge
External calls and reentrancy
The most famous class of smart-contract bug is not about arithmetic or cryptography — it is about control flow. The moment a contract makes an external call, it can hand execution to code it does not control, and that code can call back into the original contract before it has finished updating its own state. Reentrancy is the family of attacks that exploit this window. The defences — Checks-Effects-Interactions and reentrancy guards — are specific, but the underlying lesson is general: every boundary where you yield control to someone else is a place where your invariants can be violated behind your back. That lesson reappears, unchanged in shape, when an autonomous agent calls out to a tool.
Learning objective
Understand why any external call hands control to untrusted code; how single-function, cross-function and read-only reentrancy exploit state that is inconsistent mid-call; the Checks-Effects-Interactions discipline and reentrancy guards that defend against them; and how the whole problem generalizes to any boundary where a program yields control — including an agent that calls a tool.
- Track
- Engineering
- Module
- 8 · Ethereum, the EVM & on-chain zero-knowledge
- Prerequisites
- The EVM execution model
Conceptual introduction
A single contract running alone is predictable: it executes top to bottom and nothing interrupts it. The danger begins the instant it talks to another contract. On the EVM, calling another address is not like calling a library function — it transfers execution to code at that address, and that code can do anything, including calling straight back into you.
Reentrancy is the attack that lives in that gap. If a function sends value or otherwise calls out before it has finished updating its own bookkeeping, the called party can re-enter the same function (or a sibling function) while the books still show the old, pre-update state — and drain or corrupt the contract by replaying an action that should only have happened once.
In June 2016 a reentrancy bug in a large decentralized investment fund drained around 3.6 million ETH (about $60M at the time) and led to a contentious hard fork. The attack did not break any cryptography; it exploited the ordering of two lines of code. That is what makes this class so instructive.
Technical foundation: external calls hand over control
Solidity offers several ways to send ETH or invoke another contract, and they differ mostly in how much gas they forward and how they report failure. The historical transfer and send forward a fixed 2300 gas stipend — originally intended as a crude reentrancy brake, since 2300 gas is barely enough to log an event. That brake proved fragile: gas costs shifted over time (so contracts that used to fit broke), and it is now discouraged. The recommended primitive, a low-level call, forwards all remaining gas by default.
Forwarding all gas is flexible but it is exactly what makes reentrancy possible: the recipient has enough gas to run arbitrary logic — including a callback into the caller. The receiving contract's receive() or fallback() runs automatically when it is paid, so "just sending ETH" is already an invocation of someone else's code.
- External call
- Any operation that transfers execution to code at another address — a low-level call, a value transfer that triggers receive/fallback, or an interface method on another contract. Control leaves your function and may not come back the way you expect.
- receive() / fallback()
- Special functions that run when a contract is sent ETH or called with unrecognised calldata. They turn a plain payment into an opportunity for the recipient to execute code — the hook an attacker uses to re-enter.
- Reentrancy
- A control-flow attack in which external code, invoked mid-function, calls back into a contract before that contract has finished updating state, observing and exploiting an inconsistent intermediate state.
- Callback hook
- A function a standard mandates the caller invoke on the recipient (for example token-received or NFT-received hooks). Any such hook is an external call and therefore a reentrancy surface, even when no ETH moves.
Always check the return value of a low-level call. It returns a boolean rather than reverting on failure, so an unchecked call can "succeed" silently while the transfer failed — a separate but closely related footgun.
Mechanism: the classic withdraw re-entry
The canonical vulnerable function sends ETH before zeroing the caller's balance. Because the send hands control to the recipient while the balance still reads its old value, the recipient re-enters withdraw and passes the balance check again. Every re-entry sends more ETH; the single balance-zeroing line only runs after the recursion unwinds — far too late.
synthetic — vulnerable vs. fixed withdraw (the heart of reentrancy)// VULNERABLE: interaction happens BEFORE the effect function withdraw() external { uint256 bal = balances[msg.sender]; require(bal > 0, "no balance"); (bool ok, ) = msg.sender.call{value: bal}(""); // <-- hands control away require(ok); balances[msg.sender] = 0; // <-- state updated too late } // Attacker's receive() re-enters while balances[attacker] is still non-zero: // receive() { if (bank.balance >= 1 ether) bank.withdraw(); } // -> withdraw -> send -> receive -> withdraw -> send -> ... drains the bank. // FIXED: effect BEFORE interaction (Checks-Effects-Interactions) function withdraw() external { uint256 bal = balances[msg.sender]; // CHECK require(bal > 0, "no balance"); balances[msg.sender] = 0; // EFFECT (before the external call) (bool ok, ) = msg.sender.call{value: bal}(""); // INTERACTION (last) require(ok, "transfer failed"); }
- 1The attacker deposits a small amount so they have a non-zero balance.
- 2The attacker calls withdraw(); the contract reads their balance and passes the check.
- 3The contract sends ETH, which triggers the attacker's receive() function.
- 4Inside receive(), before the contract has zeroed anything, the attacker calls withdraw() again.
- 5The balance still reads its original value, so the check passes and more ETH is sent — the loop repeats until the contract is empty.
- 6Only as the recursion unwinds does balances[attacker] = 0 finally run, long after the damage is done.
The variant family: single-function, cross-function, read-only
Reentrancy is not one bug but a family, distinguished by what the attacker re-enters into. Recognising the variants matters because a defence that stops one does not automatically stop the others.
- Single-function reentrancy
- The attacker re-enters the very same function that made the external call (the classic withdraw loop). Checks-Effects-Interactions alone defeats it, because the effect runs before control is handed away.
- Cross-function reentrancy
- The attacker re-enters a different function that shares the same state. For example, during a withdraw callback they call transfer to move a balance that has not yet been zeroed. CEI on withdraw alone is not enough; every function touching the shared state must be protected.
- Cross-contract reentrancy
- The re-entry travels through another contract that shares state or depends on the victim — common with tokens whose transfer logic runs a callback. The guard must be global across the related contracts, not local to one function.
- Read-only reentrancy
- The attacker never writes to the victim during the callback; instead a view function returns a value computed from mid-transaction, inconsistent state, and a third protocol reads that value as if it were final. A guard that protects only state-changing functions misses this, because view functions are left unprotected.
Read-only reentrancy is especially subtle: a reentrancy guard protects functions that modify state, but a view function — an on-chain price oracle, say — carries no guard, returns a temporarily wrong number during another protocol's callback window, and a lending protocol downstream makes a loan against the inflated value. A class of such attacks caused tens of millions of dollars of losses across DeFi protocols in 2023.
synthetic — read-only reentrancy: the lock must also cover the view// A pool sends tokens to the user BEFORE burning their shares, so during the // callback totalSupply is stale and get_virtual_price() returns an inflated value. // A separate lending protocol reads that price as an oracle and over-lends. contract SafePool { uint256 private _locked; modifier lock() { require(_locked == 0, "reentrancy"); _locked = 1; _; _locked = 0; } // The fix: the VIEW function also refuses to answer while the lock is held, // so no downstream protocol can read mid-transaction state. function get_virtual_price() external view returns (uint256) { require(_locked == 0, "reentrancy"); return totalAssets() * 1e18 / totalSupply; } function remove_liquidity() external lock { /* ... */ } }
The disciplines: Checks-Effects-Interactions and guards
Two complementary defences cover almost all cases. The first is an ordering discipline; the second is an explicit lock. They are not alternatives — robust systems use both.
- Checks-Effects-Interactions (CEI)
- Structure every function so it first validates preconditions (Checks), then updates its own state (Effects), and only then makes external calls (Interactions). By the time control can be handed away, the contract's state is already consistent, so a re-entry sees up-to-date bookkeeping and the check fails.
- Reentrancy guard (mutex)
- A modifier backed by a status flag: it rejects the call if the flag is already set, sets it, runs the function, then clears it. A nested re-entry finds the flag set and reverts. The same flag can be checked inside view functions to close read-only reentrancy.
- Pull-over-push
- Rather than pushing funds to many recipients in a loop (where one malicious or reverting recipient can stall or re-enter), record what each user is owed and let each withdraw individually, applying CEI in the withdrawal. This shrinks the external-call surface to one recipient at a time.
- 1Default to CEI in every function: validate, then write your own state, then call out — in that order, always.
- 2Add a reentrancy guard (prefer a well-audited library implementation) to every function that touches shared critical state, not just the obvious one.
- 3If other protocols read your view functions as oracles, make those views respect the same lock so they cannot be queried mid-transaction.
- 4Prefer pull-over-push for distributing funds, so a single untrusted recipient cannot block or re-enter a batch.
- 5Assume any token transfer or callback hook can execute arbitrary code; treat every such line as an external call.
A practical detail from real guard implementations: they store 1 and 2 rather than 0 and 1 for "not entered" / "entered", because flipping a storage slot from zero to non-zero costs far more gas than toggling between two non-zero values. Correctness first, but the idiom is worth recognising in audits.
The deeper pattern: any boundary where you hand control away
Strip away the Solidity specifics and reentrancy is a statement about invariants and control flow: if you yield control to someone else while your own state is halfway through an update, that someone can act on the inconsistent state before you finish. The EVM makes this vivid because the yield is an external call and the attacker is literally another contract, but the shape is universal.
The same shape appears whenever an autonomous agent calls a tool. The agent pauses mid-reasoning, hands control to an external tool (a web request, a sub-agent, a plugin), and resumes with whatever that tool returns. If the agent has already committed to part of an action — reserved a budget, assumed an authorization, half-written a record — a tool that re-enters the agent (via a crafted response, a callback, or a chained tool-call) can exploit that half-finished state exactly as an attacker contract exploits a half-finished withdraw.
- ▸Checks-Effects-Interactions becomes: validate and commit your own decision before you call the tool, so a manipulated tool response cannot rewrite a decision you have already recorded.
- ▸The reentrancy guard becomes: a re-entry lock or idempotency key so the same tool-triggered action cannot be replayed within one logical operation.
- ▸Read-only reentrancy becomes: do not let other components read your mid-operation state (a tentative balance, an un-finalized plan) as if it were final.
- ▸Pull-over-push becomes: narrow the surface — hand control to one external component at a time, with the least authority needed, rather than fanning out mid-operation.
This is why the reentrancy lesson sits in an agent-security curriculum at all: the fix is not "write better Solidity", it is "never trust that your invariants hold across a boundary you handed control through". Tool-and-supply-chain compromise is the agent-world expression of the same discipline.
Risks & limitations
- ▸CEI alone defeats single-function reentrancy but not cross-function, cross-contract or read-only variants; guards must cover every function (and view) that shares critical state.
- ▸A reentrancy guard protects only what it is applied to. Forgetting it on one sibling function, or on a view used as an oracle, re-opens the hole.
- ▸Any token standard with transfer hooks (and NFT standards with received-callbacks) turns ordinary transfers into external calls; a safe-mint loop that callbacks before updating a counter can be re-entered to exceed a mint cap.
- ▸Fixed-gas sends (transfer/send) are not a reliable reentrancy defence: gas repricing has broken them and they can make legitimate recipients fail. Use explicit guards and CEI instead.
- ▸Flash loans amplify reentrancy by giving an attacker temporary access to enormous capital with no collateral, turning a small inconsistency into a large drain within one transaction.
- ▸Defences prove that your state stays consistent across a call; they do not prove the external party is honest or the action is wise. A guarded, successful call is still a call to untrusted code.
Exercise
A pool pays out tokens to a user inside remove_liquidity() before it reduces totalSupply, and it also exposes a view function get_virtual_price() = totalAssets * 1e18 / totalSupply that a separate lending protocol reads as a price oracle. The team adds a standard reentrancy guard to all state-changing functions and considers the issue closed. Explain why an attacker can still profit, and what additional step actually closes it.
Expected direction: This is read-only reentrancy. During the token payout in remove_liquidity(), control reaches the attacker while totalSupply has not yet been reduced, so get_virtual_price() returns an inflated value. The reentrancy guard on state-changing functions does not help, because get_virtual_price() is a view function and carries no guard — the lending protocol reads the stale, inflated price mid-transaction and lets the attacker over-borrow against it. The additional step is to make the view function respect the same lock: have get_virtual_price() revert (or otherwise refuse) while the lock is held, so no downstream consumer can read mid-operation state as if it were final. More generally, the fix is to reorder so state is consistent before any external call (CEI) and to ensure reads of in-progress state are not exposed.
Check your understanding
- Why does making an external call expose a contract to reentrancy at all?
- An external call transfers execution to code at another address, which can run arbitrary logic — including a callback into the calling contract — before the original function has finished. If state was not yet updated, the callback sees inconsistent state and can exploit it.
- State Checks-Effects-Interactions and why the ordering matters.
- A function should first validate preconditions (Checks), then update its own state (Effects), and only then make external calls (Interactions). Updating state before yielding control means any re-entry observes up-to-date bookkeeping, so the re-entrant check fails and the replay is blocked.
- How does read-only reentrancy evade an ordinary reentrancy guard?
- An ordinary guard is placed on state-changing functions, but read-only reentrancy exploits an unprotected view function that returns a value computed from mid-transaction state. A third protocol reads that inconsistent value as an oracle. The fix is to have the view honor the same lock.
- How does the reentrancy lesson generalize to an agent calling a tool?
- A tool-call is a boundary where the agent yields control to external code and resumes with its result. If the agent has committed to part of an action before calling out, a crafted or re-entrant tool response can exploit that half-finished state. The defences map directly: commit decisions before calling out (CEI), use idempotency/re-entry locks (guards), do not expose tentative state as final (read-only), and hand control to one least-authority component at a time (pull-over-push).
References
- [1]Solidity documentation — Security Considerations (Reentrancy; Checks-Effects-Interactions)
- [2]OpenZeppelin Contracts — ReentrancyGuard implementation and documentation
- [3]ConsenSys — Ethereum Smart Contract Best Practices (External Calls / Reentrancy)
- [4]Antonopoulos & Wood — Mastering Ethereum (O'Reilly, 2018)
References point to public, well-known technical sources for further study.
Related articles
