Crypto Training
Reentrancy Beyond the Basics: Cross-Function, Cross-Contract, and Read-Only
Reentrancy is not just 'send ETH then update state'. It’s any time external code can observe or mutate your state mid-invariant.
Reentrancy is taught as a single bug pattern, but in practice it’s a family of bugs.
The best way to think about it is not “did we update state before sending ETH?” but:
Did we cross a trust boundary while an invariant was temporarily false?
If yes, reentrancy is on the table.
The reentrancy matrix#
| Variant | What re-enters | What breaks | Typical trigger |
|---|---|---|---|
| Classic | same function | balances / accounting | ETH send, token transfer |
| Cross-function | different function in same contract | shared state invariant | fallback -> other entry point |
| Cross-contract | another contract re-enters you | multi-contract invariant | hooks, callbacks |
| Read-only | view path is called mid-transition | price/snapshot assumptions | oracle reads, share price |
The last two are the ones that surprise teams.
The invariant lens (the only one that scales)#
When you do:
- read state
- compute something meaningful
- call out
- continue
You are implicitly assuming that state did not change between steps 1 and 4.
Reentrancy violates that assumption.
Even worse: you can violate it without the attacker “calling back into the same function”. Any reachable entry point that touches shared state is enough.
Example 1: classic reentrancy (tutorial shape)#
function withdraw(uint256 shares) external {
uint256 assets = shares * totalAssets() / totalShares;
// External call before state is committed.
token.transfer(msg.sender, assets);
_burn(msg.sender, shares);
}
What matters is not “transfer”. What matters is “external call while the share invariant is in-flight.”
Example 2: cross-function reentrancy (realistic)#
You add a guard on withdraw(), but forget another entry point reaches the same internal transition.
function withdraw(uint256 shares) external nonReentrant {
_withdraw(shares);
}
function emergencyExit() external {
// no guard
_withdraw(balanceOf[msg.sender]);
}
Now the attacker re-enters through emergencyExit().
This is why “add nonReentrant” is not a complete fix unless you reason about all entry points that touch shared state.
Example 3: tokens and hooks (transfer is a callback)#
In 2026, you cannot assume token transfers are pure.
A token transfer can:
- call arbitrary code (hooks)
- consume a lot of gas
- revert to halt progress
So this is always suspicious:
- transferring inside accounting
- transferring before updating state
A safer pattern is to commit state before transfer, or to split into phases.
Example 4: read-only reentrancy (composition hazard)#
Read-only reentrancy looks harmless because no state write happens.
The exploit is about observing a temporary state and using it elsewhere.
A plausible shape:
- your vault updates a cached value mid-function
- it calls an external module
- attacker calls a
viewfunction during the window - a downstream protocol reads that view and uses it as an oracle
If other systems integrate your view as a price feed, your internal temporary state becomes their external truth.
This is how “my contract didn’t lose funds” can still become “my contract caused a liquidation cascade elsewhere.”
A concrete toy example#
Vault:
// Sketch only.
contract Vault {
uint256 public cachedPrice;
IERC20 public token;
function withdraw(uint256 shares) external {
cachedPrice = _expensivePrice();
token.transfer(msg.sender, _assetsFor(shares, cachedPrice));
_burn(msg.sender, shares);
cachedPrice = 0;
}
function price() external view returns (uint256) {
// Downstream protocols might treat this as an oracle.
return cachedPrice == 0 ? _spotPrice() : cachedPrice;
}
}
Attacker:
- trigger
withdraw()socachedPriceis temporarily set - reenter via a token hook and call
price() - use that price in a second protocol that trusts the view
The exploit isn’t “Vault lost funds directly”. The exploit is “Vault exposed a temporary internal value as an oracle.”
Defensive designs that actually work#
Design A: commit state, then interact#
If the invariant is “shares correspond to assets”, commit share changes before transfers.
Design B: split into phases#
- phase 1: compute + commit
- phase 2: interact with external contracts
This is often easier than sprinkling nonReentrant everywhere.
Design C: isolate callbacks#
If you provide hooks (like afterDeposit), treat them as optional:
- call them in a best-effort manner
- ensure failure does not revert the core accounting
If a hook can revert and freeze deposits, your hook is a veto.
Audit workflow: how I find reentrancy#
I don’t start with “where is nonReentrant?”
I do this:
- List every external call.
- Mark which invariants are in-flight around it.
- List all reachable entry points during that window.
- Try to construct a cycle in the call graph.
If there’s a cycle while invariants are false, you have a reentrancy candidate.
Defenses: what they cost you#
Reentrancy defenses are tradeoffs. The goal is to choose the tradeoff that matches your protocol.
| Defense | Strength | Cost / failure mode |
|---|---|---|
nonReentrant mutex | simple, effective | can block composability; easy to forget on other entry points |
| commit-then-interact | preserves invariants | requires careful ordering and design |
| pull payments | avoids push-revert DoS | more state, more UX steps |
| two-phase state machines | bounded progress | more complexity; needs careful replay protection |
In audits, I prefer “commit-then-interact” and pull patterns because they fix the underlying invariant problem rather than just blocking a class of paths.
A subtle anti-pattern: external calls inside pricing#
If your pricing function calls out (oracle, module, hook) and is used inside a state transition, you have combined:
- reentrancy risk
- revert/liveness risk
- gas griefing risk
This is a common shape in modular systems (fee modules, hook-based DEX designs, vault strategies).
Audit move: separate price computation from state transition, or snapshot the needed values before external calls.
Testing reentrancy in Foundry (minimal harness)#
When a path is plausible, I prefer to lock it down with a reproduction.
Pattern:
- write an attacker contract that implements the callback/hook you fear
- call the victim entry point
- reenter a second entry point (or a view) during the callback
- assert invariant break
Even a small harness makes the call graph concrete and avoids “theoretical” findings.
A practical checklist#
These questions catch most reentrancy issues:
- Is there any external call between “compute” and “commit”?
- Are there multiple entry points that reach the same state transition?
- Are token transfers treated as external calls (they are)?
- Are view functions used as oracles by other protocols?
Exercises (fast and valuable)#
If you want to sharpen intuition, do these drills:
- Build a toy vault and an attacker that re-enters through a different entry point.
- Add a view function that exposes a temporary state mid-withdraw; use it as an oracle in a second contract.
- Reorder state updates until the exploit stops.
You’ll stop thinking of reentrancy as “that one bug” and start treating it as a control-flow property.
Further reading#
- Cross-contract reentrancy write-up: https://inspexco.medium.com/cross-contract-reentrancy-attack-402d27a02a15
- Solidity-by-example patterns: https://docs.soliditylang.org/en/latest/solidity-by-example.html