Introduction: Why Your Audit May Have Missed the Real Reentrancy Risks
When your smart contract audit report arrives, the reentrancy section often feels like a checkbox item: "ReentrancyGuard applied — no issues found." But for many Solidity teams, that sense of security is premature. The classic single-function reentrancy, where an external call drains a contract through a recursive call to the same function, has been well-understood since the 2016 DAO hack. Yet practitioners increasingly report that audits miss more subtle variants — cross-function reentrancy, read-only reentrancy, and reentrancy in token approval workflows — because automated scanners and even manual reviewers often focus on the obvious patterns. This overview reflects widely shared professional practices as of May 2026; verify critical details against current official guidance where applicable. In this guide, we will walk through each of these three pitfalls, explain why they evade detection, and provide a step-by-step fix framework your team can implement immediately. The goal is not to replace your auditor but to equip you with the knowledge to ask better questions and patch vulnerabilities before they become exploits.
Pitfall 1: Cross-Function Reentrancy — The Silent Drain Across Multiple Functions
Cross-function reentrancy occurs when an external call from one function allows an attacker to call a different function in the same contract that modifies a shared state variable. The classic checks-effects-interactions pattern protects against reentrancy within the same function, but it does not prevent an attacker from exploiting a second function that uses the same state. For example, consider a contract with a withdraw() function that sends ETH and a transfer() function that updates a balance mapping. If both functions read from the same balances mapping before updating it, an attacker can call withdraw(), which triggers a fallback in the attacker's contract, which then calls transfer() before the first function completes its state update. The result: the attacker's balance is debited twice from the same initial state. Auditors often miss this because they test each function in isolation. Automated scanners, which analyze control flow per function, rarely simulate cross-function call sequences. The fix requires a broader mutex or a global reentrancy lock that covers all functions modifying shared state.
Anonymized Scenario: The Lending Protocol That Lost Liquidity
In one composite scenario, a lending protocol implemented a repayLoan() function that used checks-effects-interactions correctly for its own logic. However, the protocol also had a claimRewards() function that read from the same userDebt mapping. An attacker deposited collateral, took a loan, then called repayLoan(). The function sent ETH to the attacker's contract, which in its fallback called claimRewards() before repayLoan() updated the debt mapping. Because claimRewards() read the original debt amount, the attacker received rewards on a loan that was already being repaid. The attacker repeated this across multiple transactions, draining the reward pool. The protocol's audit had only tested each function separately and assumed the ReentrancyGuard modifier on repayLoan() was sufficient. This pitfall teaches us that a single-function lock is not enough when multiple functions share state.
How to Fix: Implement a Global Mutex
The most reliable fix for cross-function reentrancy is to use a global reentrancy lock that applies to all functions that share sensitive state. OpenZeppelin's ReentrancyGuard provides a nonReentrant modifier that uses a single storage slot to prevent any nested calls to functions with the same modifier. Apply this modifier to every function that reads or writes to shared state, including getters that are not intended to be called externally. For contracts with many functions, consider using a custom mutex that locks a specific state variable rather than the entire contract. For example, use a mapping of mutexes per user address to allow concurrent operations on different users while preventing reentrancy on the same user's state. Test cross-function call sequences explicitly in your test suite by writing fuzz tests that simulate arbitrary call orders. Tools like Foundry's fuzzer can generate sequences of function calls with external triggers, helping you uncover these hidden paths.
Closing Thought on Cross-Function Risks
Cross-function reentrancy is not a theoretical edge case; it is a practical vulnerability that has affected real protocols. By broadening your reentrancy protection to cover all functions sharing state, you close this common audit gap.
Pitfall 2: Read-Only Reentrancy — Exploiting View Functions for Profit
Read-only reentrancy is a more recent and subtle variant where an attacker exploits a view function (marked as view or pure) that returns a state value that is temporarily inconsistent due to an ongoing state change. The attacker calls a mutating function that triggers an external call to their contract, which then calls a view function on the same contract before the mutating function completes. The view function returns a stale or intermediate value, which the attacker uses in a second transaction or as part of a price oracle manipulation. For example, consider a DEX that uses a getReserves() view function to compute token prices. If a swap() function updates reserves after sending tokens, an attacker can call swap(), which sends tokens to their contract, which then calls getReserves() before the swap function updates the reserves. The attacker sees the old reserve values and uses that information to execute a second trade at an outdated price. Auditors often overlook this because view functions are assumed to be safe — they do not modify state. But they can still be exploited when called during an ongoing state transition. Automated scanners rarely flag view functions as reentrancy vectors because they focus on state-modifying calls.
Anonymized Scenario: The Oracle Manipulation That Went Undetected
In a composite case, a lending protocol used a getCollateralRatio() view function that read from a userCollateral mapping. The liquidate() function sent collateral to the liquidator before updating the mapping. An attacker created a contract that, when receiving collateral during liquidation, called getCollateralRatio() on the same transaction. The view function returned the pre-update ratio, which was above the liquidation threshold. The attacker then used this stale ratio to convince a third-party oracle that the position was healthy, preventing a legitimate liquidation. The protocol lost collateral because the attacker could delay liquidation long enough to withdraw more funds. The audit had only checked mutating functions for reentrancy and assumed view functions were side-effect-free. This pitfall highlights that view functions can be dangerous if they return state that is temporarily inconsistent.
How to Fix: Apply Reentrancy Guards to View Functions and Use Snapshots
To mitigate read-only reentrancy, apply the same nonReentrant modifier to view functions that return state values that could be inconsistent during a state transition. While this may increase gas costs for read operations, it is necessary when the view function is used as part of an external decision (e.g., oracle price feeds). Alternatively, use a snapshot pattern: store a copy of the state before making external calls and return the snapshot from view functions until the state update completes. This approach is more gas-efficient because it avoids locking the entire contract. Implement a snapshotId that increments with each state-changing operation, and have view functions read from the snapshot mapping if a snapshot exists. Test your view functions with reentrancy fuzzing to ensure they return consistent values during ongoing state changes. Document in your code which view functions are safe to call externally and which require a lock.
Closing Thought on View Function Risks
Read-only reentrancy is a reminder that reentrancy is not only about state modification — it is about state consistency. Protecting view functions that serve as data sources for other contracts is essential for preventing oracle manipulation and other attacks.
Pitfall 3: Reentrancy in Token Approval Workflows — The ERC20 Permit Trap
Reentrancy in token approval workflows is a pitfall that arises when a contract uses ERC20's approve and transferFrom pattern, or the newer permit function (EIP-2612), in combination with external calls. The vulnerability occurs when a contract calls approve or permit to set an allowance, then makes an external call to a user-controlled contract before using that allowance. The user's contract can reenter the token contract's transferFrom function during the external call, spending the allowance multiple times. For example, consider a staking contract that calls token.permit() to approve a transfer, then calls stake() which sends tokens to the staking contract. If the stake() function makes an external call to the user's contract before updating the staking balance, the user can call transferFrom on the token contract to drain the allowance. Auditors sometimes miss this because they focus on the main contract's logic and assume the token contract's approval mechanism is safe. However, the allowance is a shared state that can be exploited across contract boundaries. Automated scanners that do not simulate cross-contract call sequences may not detect this pattern.
Anonymized Scenario: The Permit-Based Drain
In a composite scenario, a yield aggregator allowed users to deposit tokens using a single transaction with permit. The aggregator's depositWithPermit() function called token.permit() to approve the transfer, then called _deposit() which sent tokens to the aggregator and updated the user's balance. The _deposit() function included a call to an external oracle for price data. An attacker deployed a contract that, when called by the oracle, reentered the token contract's transferFrom function, spending the allowance multiple times before the aggregator could update its balance. The attacker drained the aggregator's allowance pool, stealing tokens from other users. The audit had tested the depositWithPermit() function with a mock token that did not allow reentrancy, so the issue was missed. This pitfall shows that third-party token contracts can introduce reentrancy vectors that your audit may not cover.
How to Fix: Use Pull-Over-Push for Approvals and Limit Allowances
The most robust fix is to avoid using approve or permit in combination with external calls. Instead, use a pull-over-push pattern where users first approve the contract to spend their tokens, then call a separate function to deposit. This separates the approval step from the external call, reducing the risk of reentrancy. If you must use permit in a single transaction, set the allowance to exactly the amount needed, not the maximum, and use a reentrancy guard on the entire function. Additionally, use OpenZeppelin's SafeERC20 library which includes safeTransferFrom that returns a boolean and reverts on failure, but does not prevent reentrancy. For critical functions, consider using a custom mutex that locks the token contract's allowance for that user until the transaction completes. Write tests that simulate reentrancy by creating a malicious token contract that calls back into your contract during transferFrom. This will help you catch approval-based reentrancy before deployment.
Closing Thought on Approval Workflows
Token approval workflows are a common source of reentrancy vulnerabilities because they involve cross-contract state changes. By separating approvals from external calls and using reentrancy guards, you can protect against this subtle pitfall.
Comparison of Reentrancy Protection Methods
Choosing the right protection method depends on your contract's architecture, gas budget, and security requirements. Below is a comparison of three common approaches: OpenZeppelin's ReentrancyGuard, custom mutexes, and the pull-over-push pattern. Each has trade-offs in terms of gas cost, implementation complexity, and coverage of reentrancy types.
| Method | Gas Cost (per call) | Implementation Complexity | Coverage of Reentrancy Types | Best Use Case |
|---|---|---|---|---|
| ReentrancyGuard (OpenZeppelin) | ~5,000 gas (storage write) | Low — import and apply modifier | Cross-function, read-only (if applied to view functions) | Contracts with few functions sharing state |
| Custom Mutex (per user or per variable) | ~2,000 gas (mapping read/write) | Medium — requires careful state management | Cross-function, read-only, approval workflows | Contracts with many users or complex state |
| Pull-Over-Push Pattern | ~1,000 gas (no storage lock) | High — requires redesign of user flow | Approval workflows, cross-function (partial) | Contracts with frequent external calls |
ReentrancyGuard is the easiest to implement and provides broad coverage, but it locks the entire contract, which can be gas-inefficient for high-throughput applications. Custom mutexes allow finer-grained locking, such as per user or per state variable, reducing contention but requiring more careful design to avoid deadlocks. The pull-over-push pattern eliminates reentrancy by removing external calls from state-changing functions, but it requires users to perform two transactions, which may degrade user experience. For most DeFi protocols, a combination of ReentrancyGuard on critical functions and pull-over-push for token approvals offers the best balance of security and usability. Evaluate your contract's specific risk profile before choosing a method.
Step-by-Step Fix Framework for Solidity Teams
This framework provides a systematic approach to identifying and fixing reentrancy pitfalls in your Solidity contracts. Follow these steps after your audit or as part of your development process. Each step includes specific actions and checks to ensure comprehensive coverage.
Step 1: Map All External Calls and Shared State
Create a diagram of your contract's functions and identify every external call (to other contracts or addresses). For each external call, list the shared state variables that are read or written by any function in the call path. Include view functions that return state values used externally. Use a spreadsheet or a tool like Solgraph to visualize the call graph. This step helps you identify which functions could be exploited in a cross-function or read-only reentrancy attack. For example, if withdraw() sends ETH and getBalance() reads the same mapping, both are in scope.
Step 2: Apply Reentrancy Guards to All Affected Functions
Apply a reentrancy guard modifier (e.g., nonReentrant) to every function that makes an external call and shares state with another function. Do not limit the guard to functions that send ETH; include functions that make any external call, including to oracles, routers, or other contracts. Apply the guard to view functions that return shared state if they are called externally. Use OpenZeppelin's ReentrancyGuard for simplicity, or implement a custom mutex if you need finer control. Test that the guard prevents any nested calls by writing unit tests that simulate reentrancy.
Step 3: Refactor Approval Workflows to Use Pull-Over-Push
If your contract uses approve or permit in combination with external calls, refactor to a two-step process. First, have the user approve the contract to spend their tokens via a separate transaction. Second, have the user call a function that transfers tokens using transferFrom without any external calls before the transfer completes. If you must keep a single transaction for gas efficiency, set the allowance to exactly the amount needed and use a reentrancy guard that locks the entire function. Consider using a deadline for permits to prevent stale approvals. Document these changes in your code comments.
Step 4: Write Reentrancy-Specific Tests
Write tests that simulate reentrancy attacks for each function with an external call. Use a malicious contract that calls back into your contract during the external call. For cross-function tests, have the malicious contract call a different function than the one that triggered the external call. For read-only tests, have the malicious contract call a view function. For approval tests, have the malicious contract call transferFrom on the token contract. Use Foundry's fuzz testing to generate random call sequences. Ensure that all reentrancy attempts revert or produce expected state. Run these tests in your CI pipeline to catch regressions.
Step 5: Review with a Second Pair of Eyes
After implementing fixes, have a different team member review the changes, focusing on the reentrancy map and guard placements. The reviewer should look for functions that were missed, such as internal functions that are called by multiple external functions. Use a checklist based on the three pitfalls described in this guide. If possible, run a second automated audit with a tool that specializes in reentrancy detection, such as Slither or MythX, and compare results with your manual review. Document any remaining risks in your security report.
Common Questions and Answers (FAQ)
Q: Can I rely solely on automated scanners to detect reentrancy?
No. Automated scanners are good at finding classic single-function reentrancy but often miss cross-function and read-only variants because they analyze each function in isolation. They also struggle with cross-contract call sequences, such as those involving token approvals. Manual review by an experienced auditor is essential for catching these subtle pitfalls. Use automated tools as a complement, not a replacement, for manual review.
Q: Do I need to apply reentrancy guards to all view functions?
Only if the view function returns state that could be inconsistent during an ongoing state change and if that state is used externally for decision-making (e.g., as a price oracle). If the view function is only used internally or for display purposes, a guard may not be necessary. Evaluate each view function's role in your system. When in doubt, apply the guard — gas costs are minimal compared to the cost of an exploit.
Q: Is the checks-effects-interactions pattern still effective?
Yes, but it is not sufficient on its own. Checks-effects-interactions prevents reentrancy within the same function by updating state before making external calls. However, it does not prevent cross-function reentrancy or read-only reentrancy because other functions may read the same state before it is updated. Combine checks-effects-interactions with reentrancy guards for complete protection.
Q: How do I test for read-only reentrancy?
Write a test where a malicious contract calls a mutating function that triggers an external call, and during that external call, the malicious contract calls a view function on your contract. Assert that the view function returns the updated state, not the stale state. Use a mocking framework to simulate the call sequence. Foundry's fuzzer can generate these sequences automatically if you define the attack surface.
Q: What is the gas cost of using ReentrancyGuard?
Each call to a function with the nonReentrant modifier costs approximately 5,000 gas for the storage write that sets the lock. This is a one-time cost per transaction, regardless of the number of functions called. For high-frequency functions, consider using a custom mutex with a mapping to reduce gas costs. However, the security benefit usually outweighs the gas overhead for critical functions.
Conclusion: Strengthen Your Contracts Against Hidden Reentrancy
Reentrancy is not a solved problem. While the classic exploit is well-understood, cross-function, read-only, and approval-based variants continue to evade audits and cost protocols millions. By understanding these three pitfalls and applying the step-by-step fix framework, your team can close gaps that automated scanners and even manual reviewers may miss. Start by mapping your contract's external calls and shared state, apply reentrancy guards broadly, refactor approval workflows, and write targeted tests. Remember that security is a process, not a one-time event. Regularly update your contracts as new attack vectors emerge and as your protocol grows. The key takeaway is this: reentrancy protection must be holistic, covering all functions that share state, including view functions and cross-contract interactions. Do not assume that a single guard on your main function is enough. With the practices outlined in this guide, you can build more resilient smart contracts and reduce the risk of costly exploits.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!