Reentrancy in 2025: read-only, cross-function, and cross-contract patterns
Reentrancy in 2025: Read-Only, Cross-Function, and Cross-Contract Patterns
By antonio — Security Researcher at Recon
If you think reentrancy is a solved problem, you're not paying attention. The classic ETH withdrawal bug? Sure, most devs know about that one. But the patterns showing up in real exploits today look nothing like the textbook withdraw() example. They're subtler, harder to spot in review, and they slip right past simple reentrancy guards.
Let's walk through the modern reentrancy patterns that are actually draining contracts in 2025 — and more importantly, the invariant properties that catch them before an attacker does.
The classic pattern (quick refresher)
You've seen this a thousand times, but here's the skeleton so we're on the same page:
// VULNERABLE -- state update after external call
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] -= amount; // too late
}
The fix is simple: update state before the external call, or slap a nonReentrant modifier on it. Most developers know this. But here's the thing: modern reentrancy doesn't look like this at all.
Pattern 1: read-only reentrancy
This one hit hard in 2023 with the Curve/Vyper exploit and has kept showing up since. The idea is deceptively simple: you don't reenter a state-changing function. You reenter a view function that returns stale data.
How it works
- Protocol A calls an external contract during a state-changing operation (say, removing liquidity from a Curve pool).
- During that external call, the attacker's contract calls back into Protocol B.
- Protocol B reads a price or balance from Protocol A using a view function.
- But Protocol A's state is mid-update, so the view function returns an incorrect value.
- Protocol B acts on the wrong price.
// Curve-style pool -- simplified
contract Pool {
uint256 public totalLiquidity;
uint256 public tokenReserve;
function removeLiquidity(uint256 shares) external {
uint256 tokenAmount = (shares * tokenReserve) / totalSupply;
// External call BEFORE state update
// Attacker receives ETH here and can reenter
(bool ok, ) = msg.sender.call{value: ethAmount}("");
require(ok);
// State not yet updated -- getPrice() still returns old value
totalLiquidity -= shares;
tokenReserve -= tokenAmount;
}
// This view function returns stale data during removeLiquidity
function getPrice() external view returns (uint256) {
return (tokenReserve * 1e18) / totalLiquidity;
}
}
// Protocol that reads the stale price
contract VulnerableLender {
Pool public pool;
function getCollateralValue(address user) public view returns (uint256) {
// Reads manipulated price during reentrancy window
uint256 price = pool.getPrice();
return userCollateral[user] * price / 1e18;
}
}
The nasty part? nonReentrant on the pool's removeLiquidity doesn't help Protocol B at all. The view function isn't guarded, and even if it were, the downstream protocol is the one being exploited.
The invariant that catches it
// Property: price derived from pool state should never change
// within the same transaction in a way that benefits a single actor
function invariant_priceConsistencyAcrossCallbacks() public returns (bool) {
uint256 priceBefore = pool.getPrice();
// Trigger a liquidity operation
pool.removeLiquidity(someShares);
uint256 priceAfter = pool.getPrice();
// Price change per operation should be bounded
uint256 delta = priceBefore > priceAfter
? priceBefore - priceAfter
: priceAfter - priceBefore;
// Flag if price moved more than expected for the given shares
return delta <= maxExpectedDelta(someShares);
}
When you fuzz this with stateful testing, the fuzzer can interleave callback calls mid-operation and catch the window where view functions return garbage.
Pattern 2: cross-function reentrancy
This one doesn't reenter the same function. It reenters a different function on the same contract, one that the nonReentrant modifier might not cover.
How it works
contract Vulnerable {
mapping(address => uint256) public balances;
bool private locked;
modifier nonReentrant() {
require(!locked);
locked = true;
_;
locked = false;
}
// Protected with nonReentrant
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: bal}("");
require(ok);
balances[msg.sender] = 0;
}
// NOT protected -- attacker reenters here
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
During the withdraw callback, the attacker calls transfer to move their balance to another address before it gets zeroed out. If the nonReentrant guard isn't global (or if transfer doesn't share it), the attack works.
This showed up in multiple real exploits where teams protected their "dangerous" functions but left "safe" functions unguarded.
The invariant that catches it
// Property: total balance accounting must be conserved
function invariant_balanceSolvency() public returns (bool) {
uint256 totalTracked = 0;
for (uint256 i = 0; i < actors.length; i++) {
totalTracked += vulnerable.balances(actors[i]);
}
// Sum of all user balances must never exceed contract's actual ETH
return totalTracked <= address(vulnerable).balance;
}
This is a classic solvency invariant, and it's exactly the kind of property that invariant testing excels at finding violations for. The fuzzer discovers the withdraw-then-transfer sequence on its own.
Pattern 3: cross-contract reentrancy
Now we're talking about callbacks that flow across contract boundaries. Contract A calls Contract B, which calls back into Contract C, which reads stale state from Contract A. It's the same principle as read-only reentrancy but across a multi-contract system.
How it works
Think of a DeFi protocol with separate Vault, Controller, and Oracle contracts:
contract Vault {
Controller public controller;
function liquidate(address user) external {
uint256 debt = controller.getDebt(user);
uint256 collateral = controller.getCollateral(user);
// Seize collateral -- triggers external transfer
IERC20(collateralToken).transfer(msg.sender, collateral);
// State update happens AFTER transfer
controller.clearPosition(user);
}
}
contract Controller {
mapping(address => uint256) public debts;
mapping(address => uint256) public collaterals;
// During the collateral transfer in Vault.liquidate(),
// this still returns pre-liquidation values
function getDebt(address user) external view returns (uint256) {
return debts[user];
}
}
If the collateral token has transfer callbacks (ERC-777, or an ERC-1155 with hooks), the attacker reenters during the transfer. They can interact with any other protocol that reads from Controller. Controller still shows the pre-liquidation state.
The invariant
// Property: system-wide accounting consistency
function invariant_crossContractConsistency() public returns (bool) {
for (uint256 i = 0; i < users.length; i++) {
uint256 vaultView = vault.userCollateral(users[i]);
uint256 controllerView = controller.getCollateral(users[i]);
// These two views must ALWAYS agree
if (vaultView != controllerView) return false;
}
return true;
}
The key insight: when you fuzz with stateful sequences, the fuzzer will naturally interleave calls across contracts and find states where views disagree.
Pattern 4: ERC-777 and ERC-1155 callback reentrancy
These token standards have built-in hooks (tokensReceived for ERC-777, onERC1155Received for ERC-1155) that execute code on the recipient during transfers. Any contract that handles these tokens without reentrancy protection is at risk.
The classic ERC-777 trap
contract VulnerableExchange {
IERC777 public token;
mapping(address => uint256) public deposits;
function deposit(uint256 amount) external {
token.operatorSend(msg.sender, address(this), amount, "", "");
deposits[msg.sender] += amount; // State update after hook
}
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount);
deposits[msg.sender] -= amount;
token.send(msg.sender, amount, ""); // Triggers tokensReceived hook
}
}
The imBTC Uniswap V1 drain in 2020 used exactly this pattern. ERC-777's tokensReceived hook let attackers reenter during every swap.
Properties for token callback reentrancy
// Property: no value extraction beyond deposits
function invariant_noFreeTokens() public returns (bool) {
uint256 totalDeposits = 0;
for (uint256 i = 0; i < actors.length; i++) {
totalDeposits += exchange.deposits(actors[i]);
}
uint256 actualBalance = token.balanceOf(address(exchange));
// Contract should never have fewer tokens than total deposits
return actualBalance >= totalDeposits;
}
Building your reentrancy test suite
Here's how I'd structure invariant tests for reentrancy across all four patterns:
contract ReentrancyInvariantTest is Test {
// Actors include attacker contracts with callback hooks
MaliciousReceiver attacker;
ERC777Attacker tokenAttacker;
function setUp() public {
// Deploy system
// Deploy attacker contracts that implement:
// - receive() for ETH callbacks
// - tokensReceived() for ERC-777
// - onERC1155Received() for ERC-1155
attacker = new MaliciousReceiver(address(target));
tokenAttacker = new ERC777Attacker(address(exchange));
}
// Core solvency -- catches cross-function and classic reentrancy
function invariant_solvency() public {
assertGe(
address(target).balance,
target.totalDeposits(),
"Solvency violated"
);
}
// Price consistency -- catches read-only reentrancy
function invariant_priceNeverStale() public {
uint256 computedPrice = target.computeSpotPrice();
uint256 cachedPrice = target.getPrice();
uint256 tolerance = computedPrice / 100; // 1%
assertApproxEqAbs(computedPrice, cachedPrice, tolerance);
}
// Cross-contract consistency -- catches cross-contract reentrancy
function invariant_stateAgreement() public {
assertEq(
vault.totalAssets(),
controller.totalTrackedAssets(),
"Cross-contract state mismatch"
);
}
}
The trick is making sure your fuzzer's actor set includes contracts with callback hooks. Without those, you'll never trigger the reentrancy paths. Both Echidna and Medusa support this, so you just need to deploy the attacker contracts in your setup and include them in the target list.
Mitigations that actually work
-
Global reentrancy locks. Not per-function, but per-contract or per-system. OpenZeppelin's
ReentrancyGuardworks, but only if every external-facing function uses it. -
Checks-Effects-Interactions pattern. Still the gold standard. Update all state before making any external call. Period.
-
For read-only reentrancy: Add reentrancy guards to view functions that downstream contracts might call. Curve added this after their exploit (
reentrancy_lockchecks onget_virtual_price()). -
For cross-contract: The consuming protocol needs to protect itself. You can't rely on the upstream contract's guards. Consider using reentrancy-aware oracles or adding your own staleness checks.
-
Token hook awareness: If you're handling ERC-777 or ERC-1155, treat every
transfer/sendas a potential callback entry point.
The bottom line
Reentrancy hasn't gone away, it's evolved. The patterns that hit protocols today aren't the ones in your Solidity 101 course. Read-only reentrancy, cross-function, cross-contract, and callback-based variants all exploit the same fundamental issue: external calls happen before state settles.
Invariant testing catches these because it doesn't care about the path to the violation. It checks the property. If solvency breaks, if prices go stale, if cross-contract state disagrees, the fuzzer finds the sequence that causes it.
Want to test your protocol against these patterns? Try Recon Pro and run stateful fuzzing campaigns that include callback-equipped actors. Or if you'd rather have experts write the properties, request an audit and we'll build a custom reentrancy test suite for your codebase.
Further reading
Related Posts
Mutation testing for smart contracts: measure your test suite quality
Your tests pass. But are they actually good? Mutation testing injects faults into your code and chec...
Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't
The dev team ran Echidna for 24 hours: zero findings. The same vulnerability was found by invariant ...