Crypto Training
Reentrancy in 2026: Callback Surfaces You Still Miss
Reentrancy is not just withdraw-before-update. Token standard callbacks, cross-function paths, and read-only windows still create severe failures.
“Use CEI” is still correct and still incomplete.
Modern incidents often come from callback surfaces teams did not model as callbacks.
flowchart TD
U[User action] --> T[Token / hook interaction]
T --> C[External callback]
C --> X[Unexpected reentry path]
X --> S[State observed mid-transition]
S --> L[Loss or liveness failure]
Reentrancy surface map#
| Surface | Why teams miss it | Typical impact |
|---|---|---|
| ERC-777 hooks | “We only support ERC-20” assumptions | Cross-function reentry |
| ERC-721 safe callbacks | Treated like plain transfer | Inventory/accounting mismatch |
| ERC-1155 batch callbacks | Complex multi-asset flows | Partial state mutation abuse |
| Read-only reentry | “view means safe” thinking | Oracle/state desync exploit |
A realistic cross-function bug#
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Pool {
mapping(address => uint256) public credit;
function deposit() external payable {
credit[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(credit[msg.sender] >= amount, "insufficient");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
credit[msg.sender] -= amount;
}
function migrate(address to, uint256 amount) external {
// unguarded sibling function
require(credit[msg.sender] >= amount, "insufficient");
credit[msg.sender] -= amount;
credit[to] += amount;
}
}
Even if withdraw gets guarded later, migrate may still be reachable during callback chains if not phase-gated.
Phase-gated reentrancy defense#
stateDiagram-v2
[*] --> Idle
Idle --> Mutating: enter user function
Mutating --> External: performing external call
External --> Mutating: callback returns
Mutating --> Idle: finalize
External --> Blocked: sibling state function called
Blocked --> External: revert
Treat reentrancy as state-machine correctness, not a decorator checkbox.
Read-only reentrancy is operationally dangerous#
If a protocol reads your view function while your state is mid-update, that protocol may act on inconsistent data.
This can break:
- collateral checks
- liquidation eligibility
- price guards
Mitigation patterns:
- publish only finalized state from views.
- split internal transient values from exported values.
- avoid exposing half-settled accumulators.
Test strategy that actually catches variants#
Use malicious receiver contracts for each integration surface.
function test_ReenterViaERC777Hook() public {
attacker.armHook();
vm.expectRevert();
victim.depositAndStake(address(attacker), 100e18);
}
function invariant_NoNegativeAccountingAfterCallbacks() public {
assertGe(victim.totalAssets(), victim.totalLiabilities());
}
Practical audit checklist#
- List every external call (token, router, hook, oracle).
- Enumerate every function reachable from callback context.
- Verify sibling mutators share the same phase lock.
- Assert post-callback invariants in tests.