Guide
Reentrancy
Understood since 2016, fixed by a three-line pattern, and still shipping. Why the defence fails more often than the mechanism is misunderstood.
In short
- Reentrancy is an ordering failure: the contract hands control to an external address before its own accounting reflects what it is about to do.
- It persists because the handover is often invisible. A token transfer, an NFT safe-transfer and a native send to a contract all run code the sender did not write.
- A reentrancy guard protects one function. Cross-function and cross-contract variants route around it, and the read-only form attacks view functions that no guard was ever placed on.
- The durable fix is ordering, not locking. Update state before the external call, every time, and the class stops existing rather than being blocked.
The mechanism
A contract holding balances receives a withdrawal request. It checks the caller has funds, sends the funds, and then sets the caller balance to zero. Three steps in that order, and the second one hands control to an address the contract does not control.
If the receiving address is a contract, the transfer invokes its code. That code can call back into the withdrawal function before the first call has finished. The balance has not been zeroed yet, because that line has not executed, so the check passes again. And again, until the pool is empty.
In plain terms: the contract is answering a question about a balance it is in the middle of changing, and the attacker asks repeatedly during the window where the answer is still the old one.
The DAO lost a third of its holdings to this in 2016. The mechanism has not changed since.
Why it still happens
Not because the mechanism is obscure. Every competent Solidity developer can describe it. It persists because the handover of control is frequently invisible at the call site.
A native value transfer to a contract address runs that contract receive or fallback function. An ERC-721 safe transfer calls a handler on the recipient by design, so the recipient can reject tokens it cannot process. Some token standards, ERC-777 among them, notify the recipient on every transfer. None of these reads as an external call when scanning the line; each one is.
The same applies to anything that touches an unfamiliar integration. A call into a router, a vault, a reward distributor or a bridge adapter is a call into code written by someone else, which may itself call back.
In plain terms: the question is not whether a line looks like it transfers control. It is whether any address in that line could be a contract. If it could, assume it runs code, because it does.
Four variants, and only one is commonly guarded
Single-function reentrancy is the textbook case: the attacker re-enters the same function they are already inside. This is the variant a mutex stops, and the one most teams have in mind when they say the contract is protected.
Cross-function reentrancy re-enters a different function that shares the same state. The withdrawal is in progress and its guard is held, so the attacker calls transfer instead, moving a balance that is about to be zeroed but has not been. A guard on one function does nothing here unless the same lock covers both.
Cross-contract reentrancy exploits two contracts that read each other. Contract A updates its own state last, and contract B reads A mid-operation and acts on a figure that is briefly wrong. Neither contract is individually incorrect; the composition is.
Read-only reentrancy takes nothing directly and is the one most likely to be live in a system today. It is treated separately below.
Read-only reentrancy
A view function cannot change state, so for years it was treated as safe to call from anywhere. That reasoning holds for the function and fails for the system around it.
During a withdrawal, a pool is briefly inconsistent: tokens have left, and the supply figure that accounts for them has not been updated yet. A view function reading that state mid-operation returns a number that is arithmetically correct and economically false. Any contract using it as a price source acts on the false figure.
The attacker does not re-enter the vulnerable protocol at all. They re-enter a lending market, or a different pool, that quotes the first one. Nothing in the protocol being read is broken, and the protocol doing the reading has no defect of its own.
In plain terms: a guard protects the functions it is placed on. Nobody places guards on view functions, because a view function cannot be attacked in the ordinary sense. This variant attacks what reads them.
The defence is on the consuming side: do not price against a value that can be observed mid-operation, and where a protocol must be quoted, check whether its own locks are held before trusting the figure.
The fix, and the limits of the common one
Checks, effects, interactions. Validate the request, write every state change the call implies, and only then make the external call. Applied consistently, the class stops existing: when control is handed over, the accounting already reflects the operation, so a re-entrant call finds nothing to exploit.
A reentrancy guard is the other approach, and it is a lock rather than a correction. It prevents the same function, or any function sharing the lock, from being entered while held. It is useful and it is not equivalent.
In plain terms: a guard blocks the attack. Ordering removes the condition that makes an attack possible. A system relying only on guards is one unguarded function away from the same loss, and that function is usually one somebody added later.
Three practical consequences. A guard that covers only the obvious entry point leaves the cross-function variant open. A guard cannot help across contract boundaries, because the second contract has its own lock. And no guard is placed on view functions, which is why the read-only variant exists at all.
How a reviewer finds it
Systematically, rather than by intuition. The procedure is to enumerate every point where control can leave the contract, then ask what state is inconsistent at that moment.
That enumeration includes the lines that do not look like calls: native transfers, token transfers under standards with recipient hooks, NFT safe transfers, and any call into an integration. For each, the reviewer establishes which state variables are mid-update and which functions read them.
Then the same question is asked outward: which other contracts read this one, and would any of them be wrong if they read during this window. That second pass is what catches the cross-contract and read-only variants, and it is the pass that gets skipped when a scope covers one contract in isolation.
In plain terms: this is why coverage is what an audit sells. A reviewer checking every external call will find reentrancy wherever it exists. A reviewer looking for clever bugs will find the ones they happen to think of.
What to ask of your own system
Four questions, answerable by your engineers without a review.
Where does control leave our contracts, including the places it does not look like it does? A list of every transfer, hook and integration call.
At each of those points, what state has not yet been written? That is the exploitable window, and it should be empty.
Which of our functions share state, and does a single lock cover all of them? Cross-function reentrancy lives in the gap between two separately guarded functions.
Who reads our view functions as a price or supply source, and could they read during an operation? If the answer is a lending market, that is a read-only reentrancy exposure in someone else code caused by yours.
Questions
We use a reentrancy guard everywhere. Are we covered?
Against the single-function variant, yes. Cross-function reentrancy needs one lock shared across every function touching the same state; cross-contract reentrancy cannot be fixed by a lock in either contract alone; and read-only reentrancy attacks view functions, which carry no guard by convention.
Does using a well-known token standard remove the risk?
It can introduce it. Standards with recipient hooks, including ERC-777 and the safe-transfer path in ERC-721, call code on the receiving address by design. That is the feature working correctly and the handover of control is real.
Is read-only reentrancy our problem if our own contract is sound?
It can be, in both directions. Your protocol may be the one quoted mid-operation, in which case the loss happens in someone else system and the cause is yours. Or you may be the one reading, in which case your soundness depends on a value you do not control.
Can static analysis catch this?
Tools catch the simple pattern reliably and the compositional variants poorly, because those depend on relationships between contracts rather than on a shape within one. Automated detection narrows where to look; it does not replace the outward pass.
The work behind this
Web3 and protocol audit
Line-by-line review of contracts, chain layer and upgrade paths, with proof-of-concept for every finding.
Formal verification
Mathematical proof that a critical property holds for every reachable state, not just the tested ones.
Cross-chain bridge audit
Message passing, proof verification and validator assumptions, on both sides of the bridge.
Related reading
Guide
What audits a bridge protocol needs
A bridge is several systems that have to agree, and most of them are not the contracts. What to commission, what each engagement covers, and the order that makes each one cheaper than the last.
Guide
How protocols lose money
The defect classes that actually cost funds, organised by what the attacker is doing rather than by what the bug is called.
Guide
How to prepare for a smart contract audit
Preparation is the part of the engagement you control, and the part that moves the price. What to have ready before you ask for a quote.