The Bug That Keeps Returning
The audit report is published. Green across the board. You read through the executive summary, see the auditor's logo on the cover, and feel that small, specific relief that comes with a clean bill of health. Then, weeks later, the postmortem drops, and buried in the technical prose is the phrase: reentrancy vulnerability.
How? Reentrancy is one of the oldest, most documented attack classes in Ethereum's history. How does it survive a paid professional review?
Auditors catch the simple version. The multi-call variant is a different animal, and most checklists aren't built to see it.
What Everyone Already Knows (And Why It's Not Enough)
Classic single-function reentrancy is the DAO hack in a nutshell: a contract sends ETH before updating its own state, the recipient is a malicious contract, and that malicious contract's `receive()` function immediately calls back into the original function before the balance gets zeroed. The loop runs until the vault is empty.
Auditors know this cold. Static analysis tools like Slither flag it automatically. The fix, the checks-effects-interactions pattern, is drilled into every Solidity course. Use a reentrancy guard (OpenZeppelin's `ReentrancyGuard` adds a single modifier), update state before the external call, done.
But this mental model is anchored to one function calling itself. Real protocols don't work that way. They have dozens of functions, multiple contracts, shared state variables, and integrations with external DeFi primitives. The attack surface is not a single door. It's a floor plan, and auditors are often handed only one room's blueprints.
The Cross-Function Trap
Here's where most guides skip the interesting part.
Imagine a lending protocol with two functions on the same contract: `withdraw()` and `borrow()`. The `withdraw()` function sends ETH to the caller before updating that user's balance in storage. Standard reentrancy guards, applied naively, protect each function from calling itself recursively. They do not necessarily prevent `withdraw()` from being re-entered via `borrow()`.
A concrete scenario: Alice is a legitimate user with 10 ETH deposited. An attacker, call him Marcus, also deposits 10 ETH. Marcus deploys a malicious contract. When he calls `withdraw()`, the protocol sends him 10 ETH before updating his internal balance. His `receive()` function doesn't call `withdraw()` again (the guard catches that). Instead, it calls `borrow()`. At the moment `borrow()` executes, Marcus's balance in storage still reads 10 ETH, because `withdraw()` hasn't finished yet. So `borrow()` happily extends him a loan against collateral that is already leaving the contract. Marcus exits with the 10 ETH withdrawal and a loan he has no intention of repaying.
The guard on `withdraw()` never triggered. Neither did the guard on `borrow()`. Both functions were technically protected. The vulnerability lived in the gap between them, in the stale state that one function could read while another was mid-execution.
This is cross-function reentrancy. It has been exploited in production, and the mechanics above are not a hypothetical. They are a simplified version of attack patterns that have drained real protocols. If you think a `nonReentrant` modifier is a complete defense, you have been sold a comfortable lie.
Why Audits Miss It
Three structural reasons. Not excuses.
First: scope fragmentation. A typical engagement covers a defined set of contracts over a fixed window, often one to three weeks. Cross-contract reentrancy, where Contract A calls Contract B which calls back into Contract A via a different entry point, requires the auditor to hold the entire call graph in working memory simultaneously. With a 40-contract codebase, that's genuinely hard. Auditors chunk the work. Some chunks don't talk to each other.
Second: tool limitations. Automated tools excel at intra-function pattern matching. Slither's reentrancy detectors, even the more aggressive `reentrancy-benign` and `reentrancy-no-eth` variants, struggle with cross-contract, multi-step call sequences where the malicious callback is three hops away from the vulnerable state write. The tool sees no single function that both sends ETH and reads the stale variable. So it reports nothing.
Third: the read-only reentrancy blind spot. This is the nastiest variant. Some protocols use a view function, one that reads but doesn't write state, as a price oracle or collateral check. No funds move inside it, so it gets no reentrancy guard. Think of it like a security camera that only records: nobody bolts it to the wall. But if an attacker can reenter during a state-changing operation in Contract A, the state that Contract A's view function reports to Contract B is temporarily wrong. Contract B makes a decision based on a lie. The Curve Finance read-only reentrancy vector, which put hundreds of millions in integrated protocols at theoretical risk, worked almost exactly this way. The vulnerable contract itself was arguably fine. The protocols trusting its mid-execution state were not.
What a Rigorous Audit Actually Requires
The checklist approach is not sufficient for multi-call reentrancy. Full stop.
A few practices that separate thorough from checkbox:
Explicit call graph mapping. Every external call in every function should be traced to its possible recipients, including ERC-20 hooks like `tokensReceived` in ERC-777, NFT safe transfer callbacks, and low-level `.call()` patterns. The auditor should be able to answer: if execution leaves this contract at line X, which state variables are still in a transitional condition?
State invariant analysis. Define what must be true at all times: a user's recorded balance should never exceed their actual deposits, total borrows should never exceed total supplied liquidity. Then ask whether any reentrant path can temporarily violate those invariants in a way an external contract could exploit.
Integration-aware review. If the protocol integrates with Curve, Uniswap V3, Aave, or any contract that issues callbacks, the auditor needs to understand those external contracts' callback timing. Not just what they do, but when they call back relative to state updates.
Formal verification tools like Certora Prover can encode invariants and check them exhaustively. They're not cheap or fast. Still, for protocols holding nine figures in TVL, the cost-benefit calculation is not subtle.
What People Get Wrong About the Fix
The folk remedy that needs to die: slapping `nonReentrant` on every public function and calling it done. That handles single-function reentrancy. It does not handle cross-function reentrancy between two functions that each carry the modifier but share mutable state. The modifier prevents the same function from being called twice in a single transaction. It does not freeze shared state between function calls.
The real fix is a commitment to checks-effects-interactions at the architecture level, not the function level. State updates happen before any external call, in every function, without exception. External calls are treated as potential adversaries regardless of the recipient's reputation. Any function that reads state used in financial decisions gets the same scrutiny as a function that writes it.
Found a reentrancy guard on every function in a protocol you're evaluating? Good start. Now ask whether all the state those functions share gets updated before the ETH leaves. That's the question auditors sometimes forget to ask. It is also, reliably, the one that costs the most when the answer is no.