3.8.2 The Reentrancy Family
Reentrancy is the most famous vulnerability in smart contract history. The 2016 DAO exploit cost roughly 3.6 million ETH and forced the hard fork that created Ethereum Classic. A decade later, reentrancy in new forms continues to drain protocols — the Curve Finance Vyper compiler incident in 2023 ($73M across multiple pools) and the Cream Finance attacks ($130M total across 2021) are recent reminders that "we solved reentrancy" has never been true.
What changed is the shape of the problem. The classic same-function reentrant withdrawal is well understood and easy to defend against. The harder variants — cross-function, cross-contract, cross-chain, and read-only reentrancy — defeat naive defenses and continue to appear in audits. This section walks through each variant with vulnerable code, the fix, and a Foundry test that demonstrates both.
The Core Mechanic
Reentrancy is possible whenever a contract makes an external call before finishing its own state updates. The external call hands execution to another contract, which may call back into the original contract while it is still in an intermediate state. Any decision based on the not-yet-updated state can be exploited.
The mechanic depends on three conditions:
- A function performs an external call (
call,transfer,send, a token transfer, or any cross-contract invocation that can run arbitrary code). - Critical state changes happen after that external call.
- The function can be re-entered before its first invocation completes.
Remove any one of these and reentrancy is impossible. The Checks-Effects-Interactions pattern works by eliminating condition 2; reentrancy guards work by eliminating condition 3. Both are valid, both have edge cases, and combining them is the defense-in-depth posture.
A subtle point worth stating up front: any function that transfers ETH or calls an ERC-777 / ERC-1363 / ERC-721 / ERC-1155 token can trigger code in the recipient. ERC-20 transfer calls do not execute recipient code (this is one of the original ERC-20 simplifications), but a surprising number of "ERC-20-like" tokens do, and assuming otherwise has led to multiple production exploits.
Variant 1: Direct (Single-Function) Reentrancy
This is the DAO pattern. A single function performs the external call before updating internal state.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// VULNERABLE: external call before state update
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0; // too late
}
}
The attacker contract deposits, calls withdraw(), and in its receive() function calls withdraw() again — at which point balances[msg.sender] still holds the original amount.
contract DirectAttacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw();
}
}
}
The Fix: Checks-Effects-Interactions
function withdraw() external {
uint256 amount = balances[msg.sender]; // Check
require(amount > 0, "no balance");
balances[msg.sender] = 0; // Effect (before interaction)
(bool ok, ) = msg.sender.call{value: amount}(""); // Interaction
require(ok, "transfer failed");
}
When the attacker re-enters, balances[msg.sender] is already zero, so the require reverts and the recursive call unwinds.
Defense-in-Depth: Reentrancy Guard
CEI is the cheapest fix, but a guard is cheap insurance. OpenZeppelin's ReentrancyGuard adds a nonReentrant modifier that uses a single storage slot as a lock:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
In Solidity 0.8.24+, OpenZeppelin's ReentrancyGuardTransient uses transient storage (EIP-1153) for the lock, reducing the gas cost from ~2,300 (warm SSTORE) to ~100 (TSTORE). If your target chain supports the Cancun upgrade, prefer the transient variant.
Foundry Test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/SafeVault.sol";
import "../src/DirectAttacker.sol";
contract ReentrancyTest is Test {
function test_vulnerableVaultIsDrained() public {
VulnerableVault vault = new VulnerableVault();
// Honest user funds the vault
vm.deal(address(this), 10 ether);
vault.deposit{value: 10 ether}();
// Attacker arrives with 1 ETH and drains 11
address attacker = makeAddr("attacker");
vm.deal(attacker, 1 ether);
vm.prank(attacker);
DirectAttacker exploit = new DirectAttacker(address(vault));
vm.prank(attacker);
exploit.attack{value: 1 ether}();
assertEq(address(vault).balance, 0, "vault should be drained");
assertGt(address(exploit).balance, 1 ether, "attacker profited");
}
function test_safeVaultRejectsReentrancy() public {
SafeVault vault = new SafeVault();
vm.deal(address(this), 10 ether);
vault.deposit{value: 10 ether}();
address attacker = makeAddr("attacker");
vm.deal(attacker, 1 ether);
vm.prank(attacker);
DirectAttacker exploit = new DirectAttacker(address(vault));
vm.prank(attacker);
vm.expectRevert();
exploit.attack{value: 1 ether}();
assertEq(address(vault).balance, 10 ether, "honest deposit untouched");
}
}
A Hardhat equivalent would use ethers.js to deploy contracts and expect(...).to.be.reverted from Chai; the test structure is identical.
Variant 2: Cross-Function Reentrancy
CEI inside a single function is not enough when two functions share the same state variable. If function A updates state safely but function B reads that state during A's external call, B becomes the attack vector.
Vulnerable Pattern
pragma solidity ^0.8.20;
contract VulnerableRewards {
mapping(address => uint256) public balances;
mapping(address => uint256) public claimed;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Looks safe: CEI applied within this function
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
// But this function reads balances *during* withdraw's external call
function claimReward() external {
require(claimed[msg.sender] == 0, "already claimed");
uint256 reward = balances[msg.sender] / 10; // 10% of deposit
claimed[msg.sender] = block.timestamp;
(bool ok, ) = msg.sender.call{value: reward}("");
require(ok);
}
}
The attacker calls withdraw(), and in receive() calls claimReward() — at that moment balances[msg.sender] was already zeroed by withdraw, but if the order were inverted (e.g. withdraw reads from a different state variable that claimReward depends on), the cross-function attack succeeds. The general lesson is that any state-shared functions form an attack surface together, not individually.
A real example: in Uniswap V1's original code, tokenToEthSwapInput could be re-entered through addLiquidity because both touched the same reserve variables.
The Fix: Contract-Level Reentrancy Guard
A nonReentrant modifier applied to every externally-callable function that touches shared state prevents cross-function reentrancy because the lock is per-contract, not per-function.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeRewards is ReentrancyGuard {
mapping(address => uint256) public balances;
mapping(address => uint256) public claimed;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
function claimReward() external nonReentrant {
require(claimed[msg.sender] == 0, "already claimed");
uint256 reward = balances[msg.sender] / 10;
claimed[msg.sender] = block.timestamp;
(bool ok, ) = msg.sender.call{value: reward}("");
require(ok);
}
}
Note that both functions need the modifier. A guard on only withdraw() would still let the attacker enter through claimReward() after withdraw() calls back.
Variant 3: Cross-Contract Reentrancy
When two contracts share state — usually because one stores data the other reads — reentrancy can cross the contract boundary. A guard on contract A doesn't protect contract B if B reads A's state during A's external call.
Vulnerable Pattern
pragma solidity ^0.8.20;
contract SharedBalanceStore {
mapping(address => uint256) public balances;
function setBalance(address user, uint256 amount) external {
balances[user] = amount;
}
}
contract VaultA {
SharedBalanceStore public store;
constructor(address _store) {
store = SharedBalanceStore(_store);
}
function withdraw() external {
uint256 amount = store.balances(msg.sender);
require(amount > 0);
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
store.setBalance(msg.sender, 0); // state cleared after the call
}
}
contract LoanContractB {
SharedBalanceStore public store;
function borrow() external view returns (uint256) {
// grants loan based on the shared store's balance
return store.balances(msg.sender) * 2;
}
}
If the attacker re-enters during VaultA.withdraw() and calls LoanContractB.borrow(), B sees the pre-clearing balance and approves a loan against funds that are about to leave the system.
The Fix
Cross-contract reentrancy is harder to fix because there is no shared lock by default. Three approaches, in order of preference:
- Strict CEI in the contract that controls the shared state — clear
store.balancesbefore the external call, not after. - Shared guard contract — implement a global lock checked by all contracts that read the shared state.
- Snapshot pattern —
LoanContractBreads from a snapshot updated at safe points rather than from live state.
Option 1 is almost always the right answer in new code. Options 2 and 3 are retrofits for existing systems.
Variant 4: Read-Only Reentrancy
The most subtle variant. A view function has no state changes — so it doesn't need a guard, right? Wrong, when other contracts use it as an oracle.
If getPrice() reads from state that is mid-update during an external call, an attacker can re-enter and call getPrice() to read a momentarily inconsistent value. Then they call a second contract that trusts getPrice() and acts on the bad data.
This is what happened to dForce in 2020 and to Sturdy Finance in 2023. Curve's pool get_virtual_price() was the most-cited example: during a removal of liquidity, the LP token total supply was updated before reserves, so get_virtual_price() reported an inflated value mid-transaction. Lending protocols using get_virtual_price() as collateral pricing then issued loans against the inflated value.
Vulnerable Pattern
pragma solidity ^0.8.20;
contract LiquidityPool {
uint256 public totalSupply;
uint256 public reserves;
// view function used by other protocols as a price feed
function getVirtualPrice() external view returns (uint256) {
if (totalSupply == 0) return 1e18;
return (reserves * 1e18) / totalSupply;
}
function removeLiquidity(uint256 lpAmount) external {
require(lpAmount <= totalSupply);
uint256 toReturn = (reserves * lpAmount) / totalSupply;
// BUG: totalSupply updated, reserves not yet updated
totalSupply -= lpAmount;
(bool ok, ) = msg.sender.call{value: toReturn}(""); // re-entry point
require(ok);
reserves -= toReturn; // updated after the call
}
}
Between totalSupply -= lpAmount and reserves -= toReturn, getVirtualPrice() returns an inflated price. Any contract that reads it during the re-entry window gets bad data.
The Fix
Fix the ordering — update both state variables before the external call:
function removeLiquidity(uint256 lpAmount) external {
require(lpAmount <= totalSupply);
uint256 toReturn = (reserves * lpAmount) / totalSupply;
totalSupply -= lpAmount;
reserves -= toReturn;
(bool ok, ) = msg.sender.call{value: toReturn}("");
require(ok);
}
When you must call out before all state is consistent, expose a guarded read function:
function getVirtualPrice() external view returns (uint256) {
require(!_isLocked(), "pool mid-operation"); // reverts during sensitive ops
return _calculateVirtualPrice();
}
OpenZeppelin's ReentrancyGuard does not expose its lock state publicly. For a read-only pattern you need to either inherit from a guard that exposes _reentrancyGuardEntered() (added in OZ v5.0) or roll your own:
contract PoolWithReadGuard {
uint256 private _status = 1;
modifier nonReentrant() {
require(_status == 1, "reentrant");
_status = 2;
_;
_status = 1;
}
function getVirtualPrice() external view returns (uint256) {
require(_status == 1, "mid-op");
return _calculateVirtualPrice();
}
}
Integrating protocols should also pull prices from time-weighted oracles rather than spot view functions where possible.
Variant 5: Cross-Chain Reentrancy
A newer attack surface, enabled by bridges and messaging protocols. A bridge contract on chain A locks tokens and emits a message; the corresponding contract on chain B mints wrapped tokens on receipt. If the bridge accepts a callback before fully accounting for the lock, an attacker can re-enter the bridge on chain B with a second message that appears legitimate.
This is less of a "code pattern" vulnerability and more of a protocol-design vulnerability — the relevant state is split across two chains, and consistency is only enforced asynchronously. The Nomad incident (August 2022, ~$190M) is the canonical example: an initialization mistake meant any message with an unproven Merkle root was accepted, and once one user demonstrated this, hundreds of independent attackers copy-pasted the exploit transaction with their own addresses.
Cross-chain reentrancy defense is largely a matter of bridge architecture rather than function-level guards. The principles:
- Treat each chain as an untrusted external caller from the other chain's perspective.
- Require proof of finalization before crediting bridged value.
- Apply the same CEI discipline within each chain's contracts.
- Never assume messages from a connected chain are atomic with local state.
This topic is treated more fully in Section 3.11.5 (Cross-Chain & Bridge Security).
Decision Guide for Developers
| Situation | Defense |
|---|---|
| Single function transferring ETH or calling external contracts | Apply Checks-Effects-Interactions |
| Multiple functions sharing state, any of which makes external calls | Add nonReentrant to all of them |
view function read by other protocols as an oracle | Order state changes so view is always consistent, or expose lock status |
| Multiple contracts sharing state | Strict CEI in the state-owner; consider shared guard for retrofits |
| Cross-chain message flows | Architectural review; not a code-pattern fix |
| Target chain supports EIP-1153 (Cancun+) | Prefer ReentrancyGuardTransient for cheaper locks |
The two non-negotiable habits: always update state before external calls, and always apply nonReentrant to externally-callable functions that touch funds. The remaining variants are refinements of these two rules.
Testing for Reentrancy
A development-time discipline that catches reentrancy bugs before they reach audit:
- Write a malicious contract for every function that calls externally. A short attacker contract whose
receive()orfallback()re-enters the target. If the contract under test passes this Foundry test, that function is reentrancy-resistant. - Fuzz with stateful invariants. Foundry's invariant testing can assert that "no actor can withdraw more than they deposited" across arbitrary call sequences. Cross-function reentrancy frequently fails this invariant even when individual tests pass. See Section 3.4.6 (Invariant Analysis).
- Slither's
reentrancy-*detectors. Slither flags reentrancy candidates at four severity levels:reentrancy-eth,reentrancy-no-eth,reentrancy-benign,reentrancy-events. Run before every commit. See Section 4.6.1.
Cross-References
- Pattern background — Section 3.7.1 (Security-Critical Control Flow Patterns) covers Reentrancy Guards and CEI as patterns
- Auditor's view — Section 4.11.8 (Re-entrancy Vulnerabilities) walks through detection heuristics during a security review
- Historical context — Section 3.10.1 (The DAO) walks through the original exploit in detail
- Read-only reentrancy in DeFi — Section 3.11.1 (Oracles & External Data) covers price-feed exposure
Section status: This subsection is part of the expanded Section 3.8 (Common Vulnerabilities) draft. Companion subsections (3.8.1 Solidity Language Pitfalls, 3.8.3 Arithmetic, etc.) follow the same Concept → Vulnerable → Fixed → Foundry Test → Cross-Reference template.