Overview
A reentrancy attack is a serious vulnerability where a function calls an external contract, allowing that contract to make repeated calls back before execution completes.
The DAO hack in 2016 exploited a reentrancy vulnerability, resulting in approximately $60 million USD in losses.
How It Works
The basic mechanism: Contract A calls Contract B. The exploit allows B to call back into A before A finishes execution.
Attack Sequence
Step-by-Step Example
- User initiates withdrawal from bank contract holding 10 ETH
- Bank verifies balance equals 10 ETH
- Bank begins transferring 10 ETH to user
- Attacker’s contract receives funds and immediately triggers another withdrawal
- Balance hasn’t been updated yet, so contract allows another 10 ETH withdrawal
- Cycle repeats until bank is drained
Vulnerable Code
// VULNERABLE: State updated after external call
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
// External call BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
// State update happens too late
balances[msg.sender] -= amount;
}
Attacker Contract
contract Attacker {
Bank public bank;
constructor(address _bankAddress) {
bank = Bank(_bankAddress);
}
receive() external payable {
if (address(bank).balance >= 10 ether) {
bank.withdraw(10 ether); // Re-enter!
}
}
function attack() public payable {
bank.deposit{value: 10 ether}();
bank.withdraw(10 ether);
}
}
Safe Patterns
Checks-Effects-Interactions
// SAFE: State updated BEFORE external call
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
// Update state FIRST
balances[msg.sender] -= amount;
// External call LAST
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
Reentrancy Guard
// SAFE: Mutex lock prevents re-entry
bool private locked;
modifier noReentrant() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
function withdraw(uint256 amount) public noReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
Reentrancy Variants
| Variant | Description |
|---|
| Single-function | Re-entering the same function |
| Cross-function | Re-entering a different function that shares state |
| Cross-contract | Re-entering via another contract in the same protocol |
| Read-only | Exploiting view functions during reentrancy |
API Detection
{
"issues": [
{
"tag": "reentrancy",
"severity": "high",
"description": "External call before state update in withdraw()",
"location": "withdraw(uint256)",
"variant": "single-function"
}
]
}
Prevention Checklist
transfer() and send() are deprecated due to their hardcoded 2300 gas limit, which can fail after gas cost changes (EIP-1884). Use .call{value: ...}("") instead.