
Preventing Cross-Function Reentrancy in Solidity
What cross-function reentrancy is
Cross-function reentrancy happens when:
- Function A performs an external call.
- Before Function A completes, the callee reenters the contract.
- The attacker calls Function B, which relies on state that Function A has not finished updating.
Unlike classic single-function reentrancy, the reentrant path does not need to target the same function. Any externally callable function that touches shared state can become the second stage of the attack.
Why this matters
A contract may protect its main withdrawal function with a guard, yet leave an administrative or accounting function unprotected. If both functions depend on the same balances, totals, or status flags, the attacker can use the unprotected function to break invariants mid-execution.
Common examples include:
withdraw()calls an external token or Ether transferdeposit()orclaim()can be reentered during the transfersync(),skim(),harvest(), oremergencyWithdraw()reads stale stateapproveAndCall-style flows invoke callbacks before internal bookkeeping finishes
A realistic attack pattern
Consider a vault that tracks user shares and total assets. The developer protects withdraw() with a reentrancy guard, but claimRewards() remains unguarded because it only transfers accumulated rewards.
If withdraw() transfers tokens before updating the user’s share balance, an attacker-controlled token or recipient contract can reenter and call claimRewards() while the vault still believes the attacker owns the original shares. The result can be double counting, reward overpayment, or a broken accounting invariant.
The key lesson is that a guard on one function does not protect the contract as a whole.
Example: vulnerable contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract RewardVault {
IERC20 public immutable asset;
IERC20 public immutable rewardToken;
mapping(address => uint256) public shares;
mapping(address => uint256) public rewards;
uint256 public totalShares;
constructor(IERC20 _asset, IERC20 _rewardToken) {
asset = _asset;
rewardToken = _rewardToken;
}
function deposit(uint256 amount) external {
// simplified: assume asset transfer already happened
shares[msg.sender] += amount;
totalShares += amount;
rewards[msg.sender] += amount / 100;
}
function withdraw(uint256 amount) external {
require(shares[msg.sender] >= amount, "insufficient shares");
// External call before state update: dangerous
require(asset.transfer(msg.sender, amount), "asset transfer failed");
shares[msg.sender] -= amount;
totalShares -= amount;
}
function claimRewards() external {
uint256 reward = rewards[msg.sender];
require(reward > 0, "no rewards");
rewards[msg.sender] = 0;
require(rewardToken.transfer(msg.sender, reward), "reward transfer failed");
}
}What is wrong here?
The withdraw() function makes an external call before reducing the caller’s share balance. If the recipient is a contract, its fallback or token hook may reenter claimRewards() while shares[msg.sender] and rewards[msg.sender] still reflect the pre-withdrawal state.
Even though claimRewards() does not itself modify shares, it depends on the same user identity and accounting assumptions. That is enough to create a cross-function exploit.
How to fix it
The safest mitigation is to treat the contract as a single state machine and preserve invariants across all entry points.
1. Update state before external calls
Apply checks-effects-interactions consistently. In the example above, reduce shares and totals before transferring assets.
function withdraw(uint256 amount) external {
require(shares[msg.sender] >= amount, "insufficient shares");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "asset transfer failed");
}This prevents the contract from exposing stale balances during the external call.
2. Guard all sensitive entry points
If multiple functions share critical state, protect them with the same reentrancy lock. A guard on only one function is not enough.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
abstract contract ReentrancyGuard {
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private status = NOT_ENTERED;
modifier nonReentrant() {
require(status != ENTERED, "reentrant call");
status = ENTERED;
_;
status = NOT_ENTERED;
}
}Then apply it to every function that can observe or mutate shared invariants:
contract SafeRewardVault is ReentrancyGuard {
// state omitted for brevity
function withdraw(uint256 amount) external nonReentrant {
require(shares[msg.sender] >= amount, "insufficient shares");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "asset transfer failed");
}
function claimRewards() external nonReentrant {
uint256 reward = rewards[msg.sender];
require(reward > 0, "no rewards");
rewards[msg.sender] = 0;
require(rewardToken.transfer(msg.sender, reward), "reward transfer failed");
}
}3. Minimize shared mutable state
Cross-function reentrancy becomes much harder when functions do not rely on overlapping state. Prefer local accounting, isolated modules, and explicit state transitions.
For example:
- separate reward accrual from withdrawal accounting
- avoid using a single boolean status for multiple workflows
- keep “pending” and “finalized” states distinct
- do not let administrative functions bypass user-facing invariants
Which functions should be protected?
Not every external function needs a reentrancy guard, but any function that touches shared state or can trigger external code should be reviewed carefully.
| Function type | Risk level | Typical issue |
|---|---|---|
| Ether/token withdrawal | High | External transfer before state update |
| Reward claiming | High | Reentry into accounting or accrual logic |
| Deposit with callbacks | High | Token hooks or recipient callbacks |
| Admin rescue functions | Medium | Can observe inconsistent totals |
| View functions used for pricing | Medium | Read-only reentrancy and stale state |
| Pure state setters with no external calls | Low | Usually safe if isolated |
A useful rule: if a function depends on balances, totals, or lifecycle flags that can change elsewhere, treat it as part of the reentrancy surface.
Design patterns that reduce exposure
Use pull-based transfers
Instead of pushing assets during complex state transitions, record what users can claim and let them withdraw later in a dedicated function. This reduces the number of places where external calls happen.
Separate accounting from interaction
Finish all bookkeeping first, then perform the transfer. If a transfer fails, revert the whole transaction so the state remains consistent.
Keep callbacks narrow and explicit
If your contract must support hooks, such as ERC-777-style callbacks or receiver interfaces, isolate them in a small adapter layer rather than mixing them into core accounting logic.
Avoid “one guard per function” thinking
A reentrancy guard is not a substitute for correct state management. It is a defense-in-depth measure. The contract should still be safe if a future refactor adds a new external call path.
Testing for cross-function reentrancy
Unit tests that only call one function at a time often miss this bug class. You need adversarial tests that reenter through alternate entry points.
What to test
- withdraw followed by claim
- deposit followed by withdraw
- reward claim followed by emergency exit
- callback-triggered calls into unrelated public methods
- nested calls through ERC-20 hooks, ERC-777 hooks, or malicious recipient contracts
Practical test strategy
- Deploy a malicious helper contract.
- Make it call the target function.
- During the external call, reenter through a different public function.
- Assert that balances, totals, and reward counters remain correct.
A good invariant is: the sum of all user balances plus all pending obligations must remain consistent before and after any external call.
Common mistakes
Guarding only the “main” function
Developers often protect withdraw() and forget claimRewards(), emergencyWithdraw(), or sweep(). Attackers look for the unguarded path.
Assuming ERC-20 transfers are harmless
A token transfer can still trigger unexpected behavior through malicious token implementations, wrappers, or callback-enabled standards. Never assume an external call is inert.
Relying on view functions for security decisions
If a view function reads mutable state that can be changed mid-transaction, it may return misleading values during reentrancy. Do not use such reads for critical authorization or pricing without understanding the call graph.
Forgetting upgrade paths
A contract that was safe before may become vulnerable after adding a new public function or callback integration. Reassess the entire reentrancy surface after every feature addition.
A secure refactor
Here is a safer version of the earlier vault with consistent state updates and broader protection:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
abstract contract ReentrancyGuard {
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private status = NOT_ENTERED;
modifier nonReentrant() {
require(status != ENTERED, "reentrant call");
status = ENTERED;
_;
status = NOT_ENTERED;
}
}
contract SafeRewardVault is ReentrancyGuard {
IERC20 public immutable asset;
IERC20 public immutable rewardToken;
mapping(address => uint256) public shares;
mapping(address => uint256) public rewards;
uint256 public totalShares;
constructor(IERC20 _asset, IERC20 _rewardToken) {
asset = _asset;
rewardToken = _rewardToken;
}
function deposit(uint256 amount) external nonReentrant {
shares[msg.sender] += amount;
totalShares += amount;
rewards[msg.sender] += amount / 100;
}
function withdraw(uint256 amount) external nonReentrant {
require(shares[msg.sender] >= amount, "insufficient shares");
shares[msg.sender] -= amount;
totalShares -= amount;
require(asset.transfer(msg.sender, amount), "asset transfer failed");
}
function claimRewards() external nonReentrant {
uint256 reward = rewards[msg.sender];
require(reward > 0, "no rewards");
rewards[msg.sender] = 0;
require(rewardToken.transfer(msg.sender, reward), "reward transfer failed");
}
}This version is not perfect for every protocol, but it demonstrates the right shape: update state first, keep entry points consistently protected, and avoid leaving partially completed invariants visible to external code.
Security review checklist
Use this checklist when auditing a contract for cross-function reentrancy:
- Identify every external call, including token transfers and callbacks
- List all public and external functions that touch shared state
- Check whether any function can be called during another function’s execution
- Verify that state updates happen before external interactions
- Ensure all invariant-sensitive functions use the same protection strategy
- Test malicious reentry through alternate entry points, not just the original function
- Reevaluate the contract after adding new features or integrations
Conclusion
Cross-function reentrancy is a contract-wide design problem, not a single-line bug. The exploit appears when one function exposes inconsistent state and another function trusts that state too early. The safest approach is to design around explicit invariants, update state before interaction, and protect every sensitive entry point that shares accounting logic.
If you review Solidity contracts with this broader model in mind, you will catch many vulnerabilities that standard “reentrancy in withdraw” checklists miss.
