Reentrancy is the exploit that launched a thousand audits. The DAO hack of 2016 resulted in a $60M loss and a hard fork of Ethereum itself. Yet the pattern still appears in production contracts today.
Let's walk through exactly how a reentrancy attack unfolds.
The Setup: A Vulnerable Vault
contract VulnerableVault {
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");
// Step 1: send ETH — triggers attacker's receive()
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
// Step 2: update state — too late!
balances[msg.sender] = 0;
}
}
The Attacker Contract
contract Attacker {
VulnerableVault public vault;
uint256 public count;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(); // triggers the loop
}
receive() external payable {
if (count < 10 && address(vault).balance >= 1 ether) {
count++;
vault.withdraw(); // re-enter before state is updated
}
}
}
Attack Flow
- Attacker deposits 1 ETH.
- Attacker calls
withdraw(). - Vault sends 1 ETH to attacker —
balanceis still 1 ETH at this point. - Attacker's
receive()fires and callswithdraw()again. - Vault checks
balances[attacker]— still 1 ETH — sends another 1 ETH. - Repeat 10 times: attacker walks away with 10 ETH, vault loses 9.
Fixes
Option A: Checks-Effects-Interactions Pattern
Zero the balance before the external call:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // ← effect first
(bool ok,) = msg.sender.call{value: amount}(""); // ← interaction last
require(ok);
}
Option B: ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
function withdraw() external nonReentrant {
// ...
}
}
Option C: Pull-over-Push
Instead of sending ETH directly, let users claim it:
mapping(address => uint256) public pending;
function claimFunds() external {
uint256 amount = pending[msg.sender];
pending[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
Cross-Function Reentrancy
Don't forget that reentrancy can occur across multiple functions sharing state:
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
// ← External call in another function can re-enter here
balances[to] += amount;
}
Always audit functions that modify shared state alongside functions that make external calls.
ContractScan's static analysis engine flags all of these patterns. Upload your contract{: target="_blank"} and check for reentrancy in seconds.