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.