Reentrancy Variants
The general reentrancy treatment in §4.11.8 covers the canonical single-function pattern: a contract makes an external call before updating its state, and the callee re-enters and drains funds. This page covers the variants — cross-function, cross-contract, and read-only — that the simple nonReentrant modifier on a single function does not address.
Why Variants Exist
A standard nonReentrant modifier guards one function from being re-entered through itself. It does nothing about:
- A different function on the same contract that shares state, called during the re-entry window.
- State spread across multiple contracts, where the guard sits on one contract but the relevant accounting lives on another.
- A view function whose return value is consumed by an external integrator while the original call's state transition is still partially applied.
Each of these has produced eight- and nine-figure exploits in production.
Cross-Function Reentrancy
Two functions share state. One is guarded; one is not. The unguarded function reads or modifies the same state during the re-entry from the guarded function's external call.
mapping(address => uint256) public balance;
function withdraw() external nonReentrant {
uint256 amt = balance[msg.sender];
(bool ok, ) = msg.sender.call{value: amt}(""); // re-entry window
require(ok);
balance[msg.sender] = 0; // updated after call
}
function transfer(address to, uint256 amt) external {
require(balance[msg.sender] >= amt); // not nonReentrant
balance[msg.sender] -= amt;
balance[to] += amt;
}
During withdraw's external call, the receiver re-enters transfer and moves their pre-zeroed balance to an accomplice. Control returns to withdraw, the balance is zeroed (already moved), and the contract loses double the amount.
Detection. Cluster every external/public function by the storage slots it reads and writes. Any cluster of two or more functions that touch the same slots must share a reentrancy guard — either via a contract-wide nonReentrant (the standard OpenZeppelin pattern uses one reentrancy slot for the whole contract, which guards correctly across functions) or via redesigned state transitions that complete before any external call (Checks-Effects-Interactions).
Remediation. Apply CEI rigorously: zero the balance before the external call. The nonReentrant modifier is a backstop, not a substitute. Confirm that the modifier you are using is contract-wide, not per-function — OpenZeppelin's is contract-wide via a single _status slot; some hand-rolled guards are per-function and therefore broken.
Cross-Contract Reentrancy
The state required to enforce a check lives on one contract while the external call happens on another. Each contract may individually be nonReentrant, but the system as a whole is not.
A canonical pattern: a token contract calls into a hook on the recipient (ERC777 tokensReceived, ERC1155 onERC1155Received, ERC721 onERC721Received), and the hook re-enters a different contract (an AMM, a lending pool) whose accounting is updated after the token transfer it just initiated returns.
// On AMM
function swap(address tokenIn, uint256 amtIn) external nonReentrant {
IERC777(tokenIn).transferFrom(msg.sender, address(this), amtIn);
// hook fires here on a malicious tokensReceived implementation
uint256 out = getAmountOut(...);
reserves[tokenIn] += amtIn; // state update after hook
IERC20(tokenOut).transfer(msg.sender, out);
}
The hook reads reserves[tokenIn] before it has been updated and exploits the stale read on a different contract (e.g., a price oracle or another pool reading the same reserves).
Detection. Map every external call out of every state-changing function. Note which downstream contracts read the not-yet-updated state. Pay particular attention to ERC-777 / ERC-1155 / ERC-721 transfers, native ETH sends to contracts, and any callback into user-supplied addresses.
Remediation. Apply CEI across the trust boundary, not just within the function: finalize all state on every dependent contract before any callback-triggering operation. Where that is impossible (the system genuinely needs the call to happen mid-transition), use a coordinating mutex shared across the contracts that may be re-entered. The Curve re-entrancy exploits of 2023 are the canonical case study; see §4.16.10.
Read-Only Reentrancy
The exploit does not modify the vulnerable contract's state. It re-enters a view function that returns a value computed from mid-transition state, and an external integrator — a price oracle, a lending market, a liquidator — consumes that incorrect value.
The typical setup:
- Protocol A holds liquidity and exposes
getVirtualPrice()orgetPricePerShare(). - The function reads
totalSupplyandreserves(or equivalent) to compute the price. - A withdraw function on Protocol A updates reserves before updating totalSupply, or vice versa, creating a brief window where the read is wrong.
- During the external call in withdraw (sending the user's tokens back), the user re-enters Protocol B, which calls Protocol A's
getVirtualPrice()and trusts the inflated/deflated number to value collateral.
The standard nonReentrant modifier does not guard view functions. Protocol A may not even be aware that Protocol B exists.
// Protocol A
function withdraw(uint256 lpAmt) external nonReentrant {
uint256 share = lpAmt * reserve / totalSupply;
reserve -= share; // updated
// totalSupply NOT yet updated
(bool ok, ) = msg.sender.call{value: share}(""); // re-entry window
require(ok);
totalSupply -= lpAmt; // updated after call
}
function getVirtualPrice() external view returns (uint256) {
return reserve * 1e18 / totalSupply; // wrong during window
}
Protocol B's liquidator calls getVirtualPrice mid-window and decides a position is under-water (or over-collateralized) when it isn't.
Detection. For every view function exported by a contract, identify the storage it reads. Then identify every state-changing function that writes those same slots. If any of those writers performs an external call between two writes that the view function reads, you have read-only reentrancy exposure. The exploitability depends on whether anyone downstream actually consumes the view function — but as an auditor of the exposing contract, you cannot know all downstream consumers, so the finding stands.
Remediation options.
- Apply a read-only guard: a modifier on the view function that checks the reentrancy status slot (OpenZeppelin's
ReentrancyGuardexposes_reentrancyGuardEntered()for this). Reverts if called during a state-changing call. - Redesign the state transition so the view function's read is consistent at every point: update all dependent slots atomically before any external call.
- Document the unsafe-during-state-change semantics prominently in NatSpec so downstream integrators do not consume the value.
// OpenZeppelin pattern
function getVirtualPrice() external view returns (uint256) {
require(!_reentrancyGuardEntered(), "READ_ONLY_REENTRANCY");
return reserve * 1e18 / totalSupply;
}
Reentrancy on Native ETH Transfers
The pre-EIP-1884 wisdom of "use transfer or send because the 2300-gas stipend prevents reentrancy" is no longer safe. Gas costs of opcodes have changed across hard forks (EIP-1884 SLOAD repricing, EIP-2929 access-list repricing), and the 2300-gas stipend may be insufficient for the recipient's fallback even when there is no malicious intent. The same change also did not eliminate reentrancy concerns from transfer/send — proxies, smart wallets, and account-abstraction wallets can do quite a bit in 2300 gas, especially in fallback handlers that delegate to other contracts.
Remediation. Use low-level call{value:}("") with a checked return value, combined with a reentrancy guard. See §4.18.4 for the hardcoded-gas discussion.
Auditor Checklist
- Every contract with multiple state-changing external functions uses a contract-wide reentrancy guard, not per-function guards.
- CEI is applied within every state-changing function: all writes precede all external calls.
- Mapped every external call across the system; identified cross-contract paths where state on one contract is read by another during re-entry.
-
Listed every
viewfunction and the storage it reads; confirmed no state-changing function leaves those slots in an inconsistent state across an external call. - Where read-only reentrancy is possible, the view function is guarded or the docs explicitly warn integrators.
- ERC-777, ERC-1155, ERC-721 hooks and native ETH receive paths are reviewed as re-entry vectors.
-
No reliance on the 2300-gas stipend as a reentrancy defense; native-ETH sends use
callwith explicit guards.