Reentrancy attacks have drained over $2 billion from smart contracts. Understanding them is non-negotiable if you're writing production Solidity.
What Is Reentrancy?
When your contract sends ETH to an external address, execution pauses and control passes to the recipient. If the recipient is a malicious contract, it can call back into your function before the first call finishes -- re-entering the function while state is still inconsistent.
Classic Vulnerable Pattern
// VULNERABLE
contract Bank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// Bug: send ETH before updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Attacker re-enters before this line runs
balances[msg.sender] = 0;
}
}
The Attack Contract
contract Attacker {
Bank public bank;
uint256 public attackCount;
constructor(address _bank) {
bank = Bank(_bank);
}
function attack() external payable {
require(msg.value >= 1 ether);
bank.deposit{value: 1 ether}();
bank.withdraw();
}
// This is called every time the Bank sends ETH
receive() external payable {
if (address(bank).balance >= 1 ether && attackCount < 10) {
attackCount++;
bank.withdraw(); // Re-enter!
}
}
}
The attacker deposits 1 ETH, withdraws, and during the ETH transfer re-enters withdraw() repeatedly before balances[msg.sender] = 0 runs. They drain the entire contract.
Fix 1: Checks-Effects-Interactions (CEI)
The canonical fix: always update state BEFORE external calls.
// SAFE: CEI pattern
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// Effects first
balances[msg.sender] = 0;
// Interaction last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Now when the attacker re-enters, balances[msg.sender] is already 0 and the require fails.
Fix 2: Reentrancy Guard
For complex functions where CEI is hard to apply cleanly:
// OpenZeppelin's ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Bank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant sets a mutex that prevents re-entry. Implementation:
contract ReentrancyGuard {
uint256 private _status = 1; // NOT_ENTERED
modifier nonReentrant() {
require(_status != 2, "ReentrancyGuard: reentrant call");
_status = 2; // ENTERED
_;
_status = 1; // NOT_ENTERED
}
}
Cross-Function Reentrancy
A subtler variant: the attacker re-enters a DIFFERENT function:
// Still vulnerable even with state update in withdraw()
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}(""); // attacker calls transfer()
require(success, "Transfer failed");
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount); // Uses stale state if re-entered
balances[msg.sender] -= amount;
balances[to] += amount;
}
Fix: use nonReentrant on both functions, or use a single mutex across the contract.
Read-Only Reentrancy
Emerging attack vector where a view function is called during reentrancy and returns inconsistent state that a dependent protocol relies on. Solution: ensure state consistency before any external call, even if those calls appear "safe."
Audit Checklist
Before deploying any contract that handles ETH or ERC-20 tokens:
- Every
call,transfer, orsend-- is state updated before? - Every function that sends value -- does it have
nonReentrant? - Are there cross-function reentrancy paths between state-modifying functions?
- Do you call external contracts whose implementation you don't control?
Use Slither or Echidna for automated detection: slither . --detect reentrancy-eth