Most smart contract teams run an audit and assume they are safe from reentrancy. They slap a ReentrancyGuard on the main withdraw function, follow the Checks-Effects-Interactions pattern, and call it done. Then a cross-function callback drains the treasury. Or a low-level .call bypasses the modifier entirely. Standard audits often miss these edge cases because they focus on obvious single-function recursion. At Upstate, we have seen the same three pitfalls appear again and again in post-audit exploits. This guide walks through each one with real Solidity examples and shows you exactly how to fix them.
1. Why Reentrancy Still Haunts Audited Contracts
Reentrancy is not a solved problem. The 2016 DAO hack made it infamous, but the attack surface has only grown as DeFi protocols become more composable. A standard audit typically checks the obvious: a function that makes an external call before updating state. If the contract uses a reentrancy guard or follows Checks-Effects-Interactions, the auditor marks it clean. But modern contracts call multiple external contracts, use hooks, and rely on upgradeable proxies. Each of these patterns can reintroduce reentrancy in ways that automated scanners miss.
Consider a typical staking contract. The user deposits tokens, and the contract updates a balance mapping. Then a withdraw function sends rewards via a low-level call. The audit checks that the balance is set to zero before the transfer. Clean, right? But what if the reward distribution calls a separate function in the same contract that also makes an external call? That second function might not have a guard. An attacker can reenter through the reward callback, call the unguarded function, and drain the contract before the first transaction completes.
The stakes are high. A single missed reentrancy vector can lock or steal millions in user funds. Auditors are human, and automated tools like Slither and Mythril only catch patterns they are programmed to recognize. The three pitfalls we cover here are not obscure academic scenarios — they appear in production code every quarter. By understanding them, you can harden your contracts beyond the standard checklist and reduce the risk of a post-deploy exploit.
Who This Guide Is For
This guide is written for Solidity developers who have some experience with security but want to go deeper. It is also for security reviewers who want to expand their mental model of reentrancy beyond the textbook case. If you are new to smart contracts, you may want to review the basics of reentrancy first. We assume you know what a modifier is and how external calls work.
2. The Three Reentrancy Pitfalls — Plain Language Overview
Before diving into code, let us describe each pitfall in plain language so you can recognize the pattern in your own contracts.
Pitfall 1: Cross-Function Reentrancy
This happens when two functions in the same contract both make external calls, and only one has a reentrancy guard. An attacker calls the guarded function, which triggers an external call back into the same contract. Instead of reentering the same function, the attacker calls the unguarded one. Since the state changes from the first function are not yet finalized (the guard only blocks reentry to itself), the unguarded function sees stale state and can be exploited.
Pitfall 2: Low-Level Call Bypass
Modifiers like nonReentrant typically use a state variable to track whether the contract is currently executing. If a function uses a low-level .call instead of a direct function call, the modifier might not be applied at all. For example, a contract might have a withdraw function with a guard, but a separate execute function that uses .call to forward arbitrary data. An attacker can craft a call that triggers a fallback function in another contract, which then calls back into the unprotected execute function.
Pitfall 3: Storage Collision Reentrancy in Proxies
Upgradeable contracts using the proxy pattern store state in the proxy contract, while logic is in the implementation. If the implementation contract has its own storage variables that overlap with the proxy’s storage slots (due to incorrect inheritance or variable ordering), an attacker can manipulate state during a reentrant call. The proxy’s state might be updated in one function, but the implementation’s stale storage can be used to bypass checks.
3. How Each Pitfall Works Under the Hood
Let us look at the Solidity mechanics behind each pitfall. Understanding the low-level details will help you spot them in your own code.
Cross-Function Reentrancy — The Mechanics
Consider a simplified vault contract:
contract Vault {
mapping(address => uint) public balances;
bool private locked;
modifier nonReentrant() {
require(!locked, 'Reentrancy');
locked = true;
_;
locked = false;
}
function withdraw() external nonReentrant {
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}('');
require(success, 'Transfer failed');
}
function claimReward() external {
uint reward = rewards[msg.sender];
rewards[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: reward}('');
require(success, 'Transfer failed');
}
}
The withdraw function uses nonReentrant, but claimReward does not. An attacker can call withdraw, which sends Ether to the attacker’s contract. The attacker’s fallback function then calls claimReward. Since locked is still true from the withdraw call, the nonReentrant modifier on withdraw would block reentry into withdraw again, but claimReward has no modifier. The attacker’s balance in rewards has not been updated yet (the state change in claimReward happens after the call), so the attacker can claim the reward multiple times.
Low-Level Call Bypass — The Mechanics
Some contracts use a generic execute function that forwards calls via .call:
function execute(address target, bytes memory data) external nonReentrant returns (bytes memory) {
(bool success, bytes memory result) = target.call(data);
require(success, 'Call failed');
return result;
}
Here, the nonReentrant modifier is applied. But if the contract also has a fallback function that performs a .call, the modifier might be bypassed. For example:
fallback() external payable {
(bool success, ) = address(this).call(abi.encodeWithSignature('someFunction()'));
require(success);
}
If someFunction is not guarded, an attacker can trigger the fallback from an external call, which then calls someFunction without going through the modifier. The nonReentrant modifier only tracks direct calls to functions that have it; internal calls or calls via .call can bypass it if the target function is not guarded.
Storage Collision Reentrancy — The Mechanics
In a proxy pattern, the proxy contract holds the state, and the implementation contract holds the logic. But both contracts have their own storage layout. If the implementation contract has a variable at slot 0, and the proxy also has a variable at slot 0 (like the implementation address), they collide. During a reentrant call, the implementation might read its own storage (which is actually the proxy’s storage at that slot). If the proxy updates its state during the first call, the implementation may see inconsistent values. This can be exploited if the implementation uses that storage for access control or balance tracking.
4. Worked Example: Exploiting Cross-Function Reentrancy
Let us walk through a full exploit scenario for cross-function reentrancy. We will use a simplified DeFi lending contract.
The Vulnerable Contract
contract Lending {
mapping(address => uint) public deposits;
mapping(address => uint) public rewards;
bool private locked;
modifier nonReentrant() {
require(!locked, 'Reentrancy');
locked = true;
_;
locked = false;
}
function deposit() external payable {
deposits[msg.sender] += msg.value;
rewards[msg.sender] += msg.value / 100; // 1% reward
}
function withdraw() external nonReentrant {
uint amount = deposits[msg.sender];
deposits[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}('');
require(success, 'Transfer failed');
}
function claimReward() external {
uint reward = rewards[msg.sender];
rewards[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: reward}('');
require(success, 'Transfer failed');
}
}
The withdraw function is guarded, but claimReward is not. An attacker deposits 1 Ether and gets a reward of 0.01 Ether. The attacker then calls withdraw, which sets deposits[attacker] = 0 and sends 1 Ether. The attacker’s fallback function calls claimReward. At this point, rewards[attacker] is still 0.01 Ether (not yet zeroed). The claimReward function sets rewards[attacker] = 0 and sends 0.01 Ether. The attacker can then call claimReward again (since it has no guard) and drain the reward pool repeatedly until the contract runs out of Ether.
The Fix
The simplest fix is to apply the nonReentrant modifier to every function that makes an external call. But this can be tedious and error-prone. A more robust approach is to use a mutex that covers the entire contract, not just individual functions. For example, use a modifier that locks the whole contract:
modifier contractLock() {
require(!locked, 'Reentrancy');
locked = true;
_;
locked = false;
}
Apply contractLock to all external functions that change state. Alternatively, use OpenZeppelin’s ReentrancyGuard but ensure it is applied to every external function, including those you think are safe. Also consider using nonReentrant on the fallback function if it makes calls.
5. Edge Cases and Exceptions
Even with guards on every function, some edge cases can slip through.
Reentrancy via Delegatecall
If your contract uses delegatecall, the called contract runs in the caller’s storage context. A reentrant call through delegatecall can modify the caller’s state in unexpected ways. For example, if a library function performs a delegatecall to another contract that then calls back into the original contract, the guard might not be effective because the state changes happen in the called contract’s context. The fix is to avoid delegatecall in functions that also make external calls, or to use a separate guard variable that is checked before any delegatecall.
Reentrancy in Constructor or Initializer
During contract creation, the constructor runs in a special context. If a constructor makes an external call, an attacker can reenter before the contract is fully initialized. For upgradeable contracts, the initializer function is often called only once. If the initializer makes an external call, an attacker can reenter and call the initializer again, potentially setting different values. The fix is to use a bool initialized flag and check it at the start of every function, including the initializer itself.
Cross-Chain Reentrancy
In cross-chain bridges, a reentrancy attack can span multiple chains. For example, a contract on Ethereum calls a function on a sidechain via a relayer. The sidechain contract reenters the Ethereum contract before the first call completes. This is difficult to detect because the state is not yet finalized on Ethereum. The fix is to use a unique nonce per cross-chain call and verify it on both ends.
6. Limits of the Approach — When Guards Are Not Enough
Reentrancy guards are a powerful tool, but they are not a silver bullet. Here are some limits you should be aware of.
Guards Cannot Prevent Read-Only Reentrancy
If an attacker reenters a function that only reads state, the guard does not block it. The attacker can use the read to extract information that should not be available during a transaction. For example, a price oracle that updates after a trade can be manipulated if the attacker reenters the oracle before the update. The fix is to use a mutex that also locks read functions that are sensitive to state changes.
Guards Can Be Expensive
Each guard check adds gas cost. In high-frequency functions, this can add up. Some teams remove guards in gas-optimized code, which is risky. A better approach is to use a single contract-level lock instead of per-function locks, and to ensure that all external functions use it.
Guards Do Not Protect Against Malicious Callbacks
If your contract calls an untrusted contract, that contract can call back into your contract in unexpected ways. Even with guards, the callback can trigger a function that is not guarded, or it can cause a reentrancy in a different contract that interacts with yours. The best defense is to minimize external calls and to trust only well-known contracts.
Alternative Approaches
Besides guards, consider using the Checks-Effects-Interactions pattern strictly. Update all state before making any external call. This is the most fundamental defense. Also consider using a pull-over-push pattern: instead of sending Ether directly, let users withdraw their funds themselves. This reduces the attack surface because the contract never initiates calls to user contracts.
7. Reader FAQ
Q: Do I need to put a reentrancy guard on every single function?
Yes, if the function makes an external call or is callable from an external context. Even functions that seem safe, like claimReward in our example, can be exploited. A contract-level mutex is easier to maintain than individual guards.
Q: Can I rely on OpenZeppelin's ReentrancyGuard alone?
OpenZeppelin's guard is well-tested, but it only protects functions that use the nonReentrant modifier. If you forget to apply it to a function, you are vulnerable. Also, the guard does not protect against read-only reentrancy or cross-contract reentrancy.
Q: How do I test for cross-function reentrancy?
Write Foundry tests that simulate an attacker contract calling multiple functions in one transaction. Use the vm.expectRevert cheatcode to check that your guard blocks the reentry. Also fuzz the order of function calls.
Q: What about reentrancy in upgradeable contracts?
Use a storage gap to avoid collisions. Ensure that the implementation contract does not have storage variables that overlap with the proxy's storage. Always use the initializer modifier for initialization functions.
Q: Is it safe to use transfer or send instead of call?
No. transfer and send forward only 2300 gas, which is enough for a simple event but not for complex callbacks. However, they can still be reentered if the recipient is a contract with a fallback that uses minimal gas. The best practice is to use the pull-over-push pattern.
Q: Should I use a static analysis tool to find reentrancy?
Yes, tools like Slither and Mythril can detect common patterns, but they miss cross-function and low-level bypasses. Use them as a supplement, not a replacement, for manual review.
Q: How often do reentrancy bugs occur in audited contracts?
Exact statistics are not publicly available, but security researchers frequently report reentrancy vulnerabilities in post-audit disclosures. Many of these are cross-function or low-level bypasses that auditors missed.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!