Cross-Function Reentrancy
Detects reentrancy attacks that exploit multiple functions sharing the same state, where an attacker re-enters a different function before the original call completes its state updates.
Cross-Function Reentrancy
Overview
Remediation Guide: How to Fix Cross-Function Reentrancy
Cross-function reentrancy is a variant of the classic reentrancy attack where the re-entry target is a different function than the one making the external call. Two functions in the same contract share mutable state — a balances mapping, a collateral record, or a positions table — and the re-entrant call reads that shared state before the initiating function has finished updating it. A nonReentrant modifier applied only to the function that makes the external call provides no protection against re-entry into its unguarded sibling.
Sigvex detects this pattern by building a call graph, identifying shared storage slots accessed by multiple functions, and tracing external calls followed by storage writes where the same slots are read by other reachable functions without mutex protection. The detector operates on the intermediate representation produced by the decompiler’s lifting pipeline.
Why This Is an Issue
Classic reentrancy guards protect a single function. Cross-function reentrancy bypasses that protection by pivoting to a second function that reads the same state. The attack is especially effective in DeFi lending protocols where withdraw() and borrow() both reference the same collateral balance: an attacker initiates a withdrawal, and while the Ether transfer is in flight, re-enters the borrow function against the not-yet-decremented balance.
The practical impact is unlimited: an attacker can receive both a full withdrawal and a loan backed by that same withdrawn collateral, effectively stealing double the funds. This attack pattern exploits the EVM’s synchronous execution model — external calls execute arbitrary code before returning — without requiring any bug in the called contract.
The Uniswap/Lendf.Me hack (April 2020, $25M) was caused by ERC-777 token callback reentrancy where the attacker re-entered a lending protocol function sharing state with the deposit function. The protected deposit flow did not guard the associated borrow function.
How to Resolve
The primary fix is to apply the Checks-Effects-Interactions (CEI) pattern to all functions that share state. Update state before making any external call, so that any re-entrant call sees the post-update values.
// After: Fixed with Checks-Effects-Interactions on all shared-state functions
contract SafeProtocol {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// CHECK
require(balances[msg.sender] >= amount, "Insufficient balance");
// EFFECT — state updated BEFORE the external call
balances[msg.sender] -= amount;
// INTERACTION — safe because state is already consistent
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function borrowAgainst(uint256 loanAmount) external {
// Sees the correct post-withdrawal balance
require(balances[msg.sender] >= loanAmount, "Insufficient collateral");
_issueLoan(msg.sender, loanAmount);
}
}
Alternative: apply nonReentrant to all functions that share the same state. The OpenZeppelin ReentrancyGuard mutex prevents any guarded function from being called while another guarded function is executing.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeProtocol is ReentrancyGuard {
mapping(address => uint256) public balances;
// nonReentrant on BOTH functions prevents cross-function re-entry
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
function borrowAgainst(uint256 loanAmount) external nonReentrant {
require(balances[msg.sender] >= loanAmount);
_issueLoan(msg.sender, loanAmount);
}
}
Examples
Vulnerable Code
contract VulnerableProtocol {
mapping(address => uint256) public balances;
// Function A: makes external call before updating balances
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// External call — reentrancy window opens here
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
// State update happens AFTER the external call.
// Attacker's receive() can re-enter borrowAgainst() before this line.
balances[msg.sender] -= amount;
}
// Function B: reads the shared balances mapping as collateral
function borrowAgainst(uint256 loanAmount) external {
// Reads balances BEFORE withdraw() has updated it — sees the pre-withdrawal value
require(balances[msg.sender] >= loanAmount, "Insufficient collateral");
_issueLoan(msg.sender, loanAmount);
}
}
Attack sequence:
- Attacker calls
withdraw(100 ETH). - Protocol sends 100 ETH to the attacker’s malicious contract.
- Attacker’s
receive()immediately callsborrowAgainst(100 ETH). borrowAgainstsees the attacker’s balance as 100 ETH (not yet decremented).- Attacker receives a 100 ETH loan.
- Control returns to
withdraw, which decrements the balance to 0. - Attacker has received 100 ETH withdrawal + 100 ETH loan backed by zero collateral.
Common Mistake: Partial nonReentrant Protection
// INSUFFICIENT: nonReentrant on withdraw() does NOT prevent
// re-entry into borrowAgainst() from within withdraw()'s external call
contract InsufficientGuard is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
// nonReentrant mutex is held here
(bool success, ) = msg.sender.call{value: amount}("");
// But borrowAgainst() has no guard — still callable from receive()
balances[msg.sender] -= amount;
}
function borrowAgainst(uint256 loanAmount) external {
// UNPROTECTED: the nonReentrant mutex on withdraw() does not cover this
require(balances[msg.sender] >= loanAmount);
_issueLoan(msg.sender, loanAmount);
}
}
Sample Sigvex Output
{
"detector_id": "cross-function-reentrancy",
"severity": "critical",
"confidence": 0.88,
"description": "Functions withdraw() and borrowAgainst() share storage slot 0x02 (balances mapping). withdraw() makes an external CALL at offset 0x94 before SSTORE at offset 0xb8. borrowAgainst() reads slot 0x02 at offset 0x12 without a reentrancy guard. An attacker can re-enter borrowAgainst() via the external call in withdraw().",
"location": {
"function": "withdraw(uint256)",
"offset": 148
}
}
Detection Methodology
Sigvex identifies cross-function reentrancy through a multi-stage analysis of the decompiled HIR:
- Shared-slot analysis: Builds a map of storage slots accessed (read or written) by each function. Identifies slots accessed by two or more functions.
- External call identification: Locates
CALL,DELEGATECALL, andCALLCODEopcodes in each function’s CFG. - Post-call state update detection: For each external call, checks whether any
SSTOREto a shared slot follows the call in CFG topological order. - Re-entry path check: For each identified post-call SSTORE to a shared slot, traverses the call graph to find other functions that read the same slot without a reentrancy guard check.
- Guard detection: Checks whether all functions accessing the shared slot are protected by a mutex pattern (a storage slot read-before-write that reverts on contention, matching the OpenZeppelin
ReentrancyGuardABI).
Confidence is set to High when the shared slot is confirmed to be a balance or accounting variable (derived from type analysis) and both functions are externally callable.
Limitations
False positives:
- Contracts where the external call is to a trusted, non-upgradeable contract (e.g., a well-known DEX router) may produce findings that are not exploitable in practice.
- If the shared storage slot is access-controlled such that only one caller can invoke both functions, the cross-function path may not be reachable by an attacker.
False negatives:
- Cross-contract reentrancy (where the shared state is split across two separate contracts) is not detected by this detector.
- ERC-777 and ERC-1155 token callback reentrancy may require the
token-hook-reentrancydetector for full coverage.
Related Detectors
- Reentrancy — detects the classic single-function reentrancy pattern
- Unchecked Call — detects external calls whose return values are not checked