Reentrancy happens when your contract hands control to another contract before it has finished updating its own state, and that other contract calls back in and exploits the half-updated state. It's the bug behind the 2016 attack on The DAO, and it still shows up in audits, often in forms subtler than the textbook example.
The fix is a combination of three habits: update state before making external calls, add a reentrancy guard to functions that move value, and be aware of every place an external call can hide.
Why it happens
Any external call gives the callee control of execution: sending ETH with call, calling a token, or transferring an NFT with a receiver hook. The callee can run arbitrary code, including calling your contract again, while your original function is paused mid-execution. If you haven't recorded the effects of the first call yet, the second call sees stale state.
Here is the classic vulnerable withdrawal:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}(""); // control leaves here
require(ok, "send failed");
balances[msg.sender] = 0; // too late
}
}
And the attacker:
contract Attacker {
VulnerableVault public immutable vault;
constructor(VulnerableVault v) {
vault = v;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
// Re-enter while our balance still reads as unpaid
if (address(vault).balance >= msg.value) {
vault.withdraw();
}
}
}
Each nested withdraw reads the same non-zero balance and sends it again, until the vault can't cover another payment. Only then does the call stack unwind and set the balance to zero, several times over.
Note that Solidity 0.8's checked arithmetic sometimes stops this by accident. If the last line were balances[msg.sender] -= amount, the second subtraction would underflow and revert the whole transaction. Don't rely on that: real code rarely has such a clean arithmetic dependency.
Fix 1: checks-effects-interactions
Order every function the same way:
- Checks: validate inputs and permissions.
- Effects: update your own storage.
- Interactions: call external contracts last.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw"); // checks
balances[msg.sender] = 0; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction
require(ok, "send failed");
}
Now a reentrant call sees a zero balance and fails. If the send fails, the require reverts everything, including the balance update, so no funds are lost.
Fix 2: a reentrancy guard
Checks-effects-interactions is easy to break in a later edit, and hard to apply when state spans several functions. A mutex blocks re-entry altogether. With OpenZeppelin Contracts v5 (note the utils/ path, it moved from security/ in v4):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balances;
error NothingToWithdraw();
error TransferFailed();
function deposit() external payable nonReentrant {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
if (amount == 0) revert NothingToWithdraw();
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
}
nonReentrant uses a single lock per contract, so it also blocks cross-function reentrancy: while withdraw is running, the attacker can't call deposit or any other guarded function either. Apply it to every external function that reads or writes the shared state, not just the one that sends ETH.
On chains with transient storage (EIP-1153), OpenZeppelin also ships ReentrancyGuardTransient, which does the same job with cheaper TSTORE/TLOAD operations.
Use both fixes together. The guard catches what you miss; checks-effects-interactions keeps the code correct even where a guard can't be used.
Where external calls hide
Reentrancy isn't limited to sending ETH. Watch for:
- Token hooks. ERC-721
safeTransferFromand_safeMintcallonERC721Receivedon the recipient. ERC-1155 transfers callonERC1155Received. ERC-777 tokens call hooks on both sender and receiver. Any of these hands control to the other party. - Arbitrary tokens. If users can supply any token address,
token.transfermight be a malicious contract. - Callbacks by design. Flash loans and swap callbacks call back into you on purpose. Validate the caller and the state inside them.
- Cross-contract reentrancy. Two of your contracts share state (e.g. a vault and a separate rewards contract). A guard in one doesn't protect the other.
- Read-only reentrancy. During an external call, one of your
viewfunctions (say, a share price) may return an inconsistent value. Another protocol that reads it as a price oracle mid-callback can be exploited even though your own state is never corrupted. If other contracts depend on your views, make them revert while the lock is held. In OpenZeppelin v5,_reentrancyGuardEntered()tells you whether it is.
What doesn't fix it
transferandsend. They forward only 2300 gas, which used to make re-entry impossible. Gas costs have been repriced before and could be again, and the stipend breaks legitimate smart-wallet recipients. Usecallwith the patterns above.- Checking
msg.sender == tx.originto block contracts. It breaks smart accounts and doesn't address the root cause.
Test for it
Write the attacker contract as part of your test suite. In Foundry, deploy the vault and the attacker, fund the vault from other accounts, run attack(), and assert with vm.expectRevert that the attempt fails and the vault balance is unchanged. Static analyzers such as Slither also flag state writes after external calls.
Checklist
- Order every function as checks, effects, interactions.
- Put
nonReentranton all external functions that touch shared state, not only on the one that pays out. - Treat token transfers, NFT safe transfers, and user-supplied addresses as external calls.
- Guard
viewfunctions that other protocols use for pricing. - Don't rely on
transfer/sendgas limits ortx.originchecks. - Keep an attacker contract in your tests.
Get the weekly commit
New blockchain deep dives every week.