Reentrancy Attack
How attackers exploit external calls that execute before state updates to recursively drain funds from smart contracts.
Overview
Reentrancy is one of the most devastating attack classes in smart contract security. It occurs when a contract makes an external call to an untrusted address before updating its own state, allowing the called contract to “re-enter” the original function and repeat actions — such as withdrawing funds — using stale state. The result is typically a complete drain of the victim contract’s assets.
Since the 2016 DAO hack, reentrancy attacks have caused over $263M in cumulative losses across DeFi protocols. Despite being well-understood, new variants continue to surface as contract architectures grow more complex, particularly cross-function and read-only reentrancy patterns that evade traditional guards.
Sigvex detects reentrancy vulnerabilities through static bytecode analysis. See the Reentrancy Detector for detection methodology and the Remediation Guide for step-by-step fixes.
How This Attack Works
From an attacker’s perspective, exploiting a reentrancy vulnerability follows a consistent sequence:
- Identify a target — Find a contract that makes an external call (sending ETH or calling another contract) before updating its internal state (balances, flags, counters).
- Deploy a malicious contract — Write an attacker contract with a
fallback()orreceive()function that triggers re-entry into the vulnerable function when it receives ETH. - Initiate the attack — Call the vulnerable function from the attacker contract, causing the victim to send ETH to the attacker.
- Re-enter on callback — When the attacker contract receives ETH, its
receive()function fires automatically and calls the vulnerable function again. Because the victim’s state has not yet been updated, the balance check still passes. - Recurse until drained — Repeat the cycle until the victim contract’s balance is exhausted or gas runs out.
Attack Flow
sequenceDiagram
participant Attacker as Attacker Contract
participant Victim as Vulnerable Contract
Attacker->>Victim: withdraw(1 ETH)
Victim->>Victim: Check: balances[attacker] >= 1 ETH? Yes
Victim->>Attacker: Transfer 1 ETH (external call)
Note over Attacker: receive() triggers
Attacker->>Victim: withdraw(1 ETH) [re-entry]
Victim->>Victim: Check: balances[attacker] >= 1 ETH? Still yes (not updated)
Victim->>Attacker: Transfer 1 ETH (external call)
Note over Attacker: receive() triggers again
Attacker->>Victim: withdraw(1 ETH) [re-entry]
Note over Victim: ...repeats until drained...
Victim->>Victim: balances[attacker] -= 1 ETH (finally executes, but too late)
Vulnerable Code
The root cause is always the same: an external call that executes before state is updated.
// Vulnerable: external call before state update
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // Too late!
}
The balances mapping is decremented after the ETH transfer. During the transfer, execution passes to the recipient, who can call withdraw() again while the balance is still at its original value.
Attacker Contract
contract ReentrancyAttack {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(msg.value);
}
}
}
The attacker deposits a small amount (e.g., 1 ETH), then calls withdraw(). Each time the vault sends ETH, the receive() function re-enters withdraw(). The loop continues as long as the vault has funds and the attacker has gas.
Historical Exploits
The DAO ($60M, June 2016)
| Detail | Value |
|---|---|
| Contract | 0xbb9bc244d798123fde783fcc1c72d3bb8c189413 |
| Loss | ~$60M (3.6M ETH at the time) |
| Root cause | splitDAO() sent Ether to the caller before zeroing their balance |
| Impact | Led to the Ethereum hard fork, splitting the chain into ETH and ETC |
The DAO was a decentralized investment fund holding roughly 14% of all circulating Ether. The splitDAO() function allowed investors to withdraw their share, but it transferred ETH before resetting the investor’s internal balance. An attacker deployed a contract that recursively called splitDAO() through its fallback function, draining 3.6 million ETH over several hours.
This exploit defined reentrancy attacks for the entire industry. The Ethereum community’s decision to hard fork and reverse the theft remains one of the most consequential events in blockchain history.
Cream Finance ($130M, August 2021)
| Detail | Value |
|---|---|
| Contract | 0x5f18c75abdae578b483e5f43f12a39cf75b973a9 |
| Loss | ~$130M |
| Root cause | Cross-function reentrancy via shared state between borrow() and flashLoan() |
This was a cross-function reentrancy — the attacker did not re-enter the same function, but instead exploited shared state between two different functions. The attack sequence:
- Take a flash loan from Cream
- Use borrowed tokens as collateral to open a borrow position
- During the flash loan callback, re-enter via
borrow()before the flash loan state was finalized - The protocol calculated collateral using stale values, allowing the attacker to borrow far more than their collateral warranted
- Withdraw the excess, repay the flash loan, keep the profit
This was Cream Finance’s second major hack and demonstrated that traditional single-function reentrancy guards are insufficient when multiple functions share mutable state.
Curve/Vyper ($73M, July 2023)
| Detail | Value |
|---|---|
| Contract | 0x8301ae4fc9c624d1d396cbdaa1ed877821d7c511 |
| Loss | ~$73M across multiple pools |
| Root cause | Vyper compiler bug (versions 0.2.15 through 0.3.0) generated incorrect reentrancy lock code for remove_liquidity |
This exploit was uniquely dangerous because it was a supply chain attack at the compiler level. Developers had correctly applied reentrancy guards in their Vyper source code, but the Vyper compiler silently generated bytecode with a broken lock. The @nonreentrant decorator failed to prevent re-entry on affected functions.
Multiple Curve pools using vulnerable Vyper versions were drained simultaneously. The incident demonstrated that reentrancy protection can fail even when developers follow best practices, if the toolchain itself is compromised. It underscored the importance of bytecode-level verification rather than relying solely on source-level audits.
Variants
Understanding the different reentrancy variants is critical for both attackers and defenders, as each requires distinct detection and mitigation strategies.
-
Single-function reentrancy: The classic pattern, as seen in The DAO. The attacker re-enters the exact same function that made the external call. A
nonReentrantmodifier on that function is sufficient defense. -
Cross-function reentrancy: The attacker re-enters a different function that shares mutable state with the function that made the external call. This was the pattern in the Cream Finance hack. Per-function reentrancy guards do not protect against this — a contract-wide lock is needed.
-
Cross-contract reentrancy: The attacker re-enters a different contract entirely, one that reads state from the first contract. Because the first contract’s state is stale during the external call, the second contract makes decisions on incorrect data. This variant is particularly insidious in multi-protocol DeFi compositions.
-
Read-only reentrancy: The attacker does not modify state during re-entry. Instead, view functions on the victim contract return stale values, which other contracts or off-chain systems consume. The Curve/Vyper exploit had characteristics of this variant. Read-only reentrancy bypasses most reentrancy guards because no state-modifying function is re-entered.
Detection Patterns
At the bytecode level, reentrancy vulnerabilities exhibit consistent patterns that static analysis can identify:
-
External calls before state writes: Any
CALL,DELEGATECALL, orSTATICCALLopcode that precedes anSSTOREto the same storage slot is a potential vulnerability. This is the fundamental pattern. -
Missing reentrancy guards: Functions that make external calls without a mutex-style storage check at entry (the pattern used by OpenZeppelin’s
nonReentrantmodifier) are unguarded. -
View functions callable during state transitions: If a contract’s view functions read storage that is modified after an external call, those views will return stale data during re-entry.
-
Cross-function state dependencies: When multiple functions read and write overlapping storage slots, and at least one of those functions makes an external call, the entire set is potentially vulnerable to cross-function reentrancy.
Mitigation Strategies
1. Checks-Effects-Interactions Pattern
The most fundamental defense. Restructure all functions to follow a strict order: validate inputs (checks), update state (effects), then make external calls (interactions).
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount; // Effect BEFORE interaction
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
By decrementing the balance before the external call, any re-entrant call to withdraw() will see the updated (reduced) balance and fail the require check.
2. Reentrancy Guard
A mutex lock that prevents any function marked nonReentrant from being called while another nonReentrant function is executing. This is the standard defense for cross-function reentrancy.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
The nonReentrant modifier sets a storage flag on entry and clears it on exit. Any re-entrant call sees the flag and reverts. Apply this modifier to every function that modifies shared state or makes external calls — not just the obvious ones.
3. Pull Payment Pattern
Eliminate the external call from the state-modifying function entirely. Instead of pushing funds to the user, record a credit and let the user pull funds in a separate transaction.
// Instead of pushing funds, let users pull
mapping(address => uint256) public pendingWithdrawals;
function requestWithdrawal(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
pendingWithdrawals[msg.sender] += amount;
}
function claimWithdrawal() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
This pattern is the most robust defense because the state-modifying logic (requestWithdrawal) never makes an external call. The claimWithdrawal function only sends funds that have already been fully accounted for.