A reentrancy attack is one of the most well-known and devastating vulnerability classes in smart contract security. It occurs when an attacker repeatedly calls a function in a smart contract before the initial function call has completed, exploiting the incomplete state of the contract to drain funds or cause other unintended behavior. These attacks have been responsible for some of the largest losses in blockchain history, making them essential knowledge for any developer or auditor working with smart contracts.
The key to understanding a reentrancy attack lies in how smart contracts handle control flow. When a contract interacts with an external entity - such as another contract or a wallet - it temporarily hands over execution control. If the contract's internal state has not been fully updated before this handoff, a malicious actor can exploit the gap to re-enter the original function or related functions, manipulating the contract's logic in ways the developer never intended.
The DAO Hack: Where It All Began
The most infamous reentrancy attack in history occurred in 2016, when the Ethereum mainnet was about a year old. A decentralized autonomous organization called "The DAO" had been created as a community-controlled investment fund. It raised approximately $150 million worth of Ether (about 3.54 million ETH at the time) by selling its own community token.
An attacker exploited a reentrancy vulnerability in the DAO's smart contract, draining approximately $60 million worth of Ether. The simplest type of reentrancy - a single-function reentrancy - was the root cause of the exploit. The fallout was so severe that it led to a hard fork of the Ethereum network, splitting the blockchain into two: the unaltered "Ethereum Classic" and the altered-history Ethereum network that we know today.
This event demonstrated to the entire blockchain community that even well-funded, high-profile projects could fall victim to fundamental smart contract vulnerabilities. It also established reentrancy attacks as a critical area of study for anyone involved in smart contract development or security auditing.
How Reentrancy Attacks Work
To understand how a reentrancy attack works, you need to understand a fundamental property of smart contract execution: when a function calls another function - whether internal or external - control is passed to the called function. The calling function is paused until the called function finishes executing and returns. This principle applies across different contracts as well.
The classic reentrancy attack happens when a malicious contract repeatedly calls back into a vulnerable contract before the original function execution is completed. This exploits the incomplete state of the vulnerable contract, which arises because external calls are executed before the contract's state is properly updated.
Consider a simplified vulnerable contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// VULNERABLE: External call BEFORE state update
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
// State update happens AFTER external call
balances[msg.sender] -= _amount;
}
function getBalance() public view returns (uint256) {
return balances[msg.sender];
}
}
The critical flaw in this contract is that the balance is updated after the external transfer completes, not before. This ordering creates a window of vulnerability that an attacker can exploit.
The Attack Mechanism
An attacker deploys a malicious contract that exploits this vulnerability:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 _amount) external;
}
contract MaliciousContract {
IVulnerableBank public vulnerableBank;
uint256 public attackAmount;
constructor(address _bankAddress) {
vulnerableBank = IVulnerableBank(_bankAddress);
}
function attack() external payable {
attackAmount = msg.value;
vulnerableBank.deposit{value: msg.value}();
vulnerableBank.withdraw(msg.value);
}
// This function is triggered when the bank sends ETH
receive() external payable {
if (address(vulnerableBank).balance >= attackAmount) {
vulnerableBank.withdraw(attackAmount);
}
}
}
The attack unfolds in the following sequence:
- The attacker deposits 1 ETH into the vulnerable bank contract
- The attacker calls
withdraw(1 ether) - The bank checks the balance - it passes because the attacker has 1 ETH deposited
- The bank sends 1 ETH to the attacker's contract via
call{value: ...} - Receiving the ETH triggers the attacker's
receive()function - The
receive()function immediately callswithdraw(1 ether)again - Since the balance has not been updated yet (step 3's state change is still pending), the check passes again
- Steps 4-7 repeat, draining the contract with each iteration
- The loop continues until the bank's balance is insufficient for another withdrawal
The root cause is always the same: the contract's state is not updated before making an external call. The attacker's contract exploits this by calling back into the vulnerable contract during the brief window when the state is inconsistent.
Types of Reentrancy Attacks
Over the years, researchers and auditors have identified several distinct variants of reentrancy attacks, each with its own characteristics and implications. Understanding all of them is critical for building secure smart contracts.
Single-Function Reentrancy
Single-function reentrancy is the simplest and most classic form of the attack. It occurs when the vulnerable function is the same function the attacker is trying to recursively call. The DAO hack is the prime example of this variant.
Because the balance in the vulnerable contract is only updated after the withdrawal, the attacker can withdraw funds repeatedly in a single transaction until the contract is drained. This attack is straightforward and relatively easy to prevent, which is why it is one of the first vulnerability patterns that auditors check for.
Cross-Function Reentrancy
Cross-function reentrancy is a more sophisticated variant where the vulnerability arises not from a single recursive call to the same function, but from the interplay between multiple functions within the same contract. Unlike the traditional reentrancy attack where a malicious contract repeatedly calls the same function to exploit state inconsistencies, cross-function reentrancy manipulates the contract's behavior by exploiting the sequence of function calls and the interactions between them.
An attacker leverages the dependencies between multiple functions to create a situation where state updates from one function are not fully reflected before another function is called.
contract CrossFunctionVulnerable {
mapping(address => uint256) public balances;
function transfer(address _to, uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[_to] += _amount;
balances[msg.sender] -= _amount;
}
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
// External call before state update
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
balances[msg.sender] -= _amount;
}
}
In this example, the attacker's receive() function does not call withdraw() again. Instead, it calls transfer() to move the still-credited balance to another address controlled by the attacker. Because the withdraw() function has not yet updated the balance when the external call occurs, the transfer() function sees the full balance and executes successfully. The attacker ends up with both the transferred balance and the withdrawn funds.
Cross-Contract Reentrancy
A cross-contract reentrancy attack occurs when a state from one contract is used in another contract before it is fully updated. This variant typically occurs when multiple contracts share the same state variable or rely on each other's state for critical operations.
Cross-contract reentrancy attacks usually occur when multiple contracts manually share state or when one contract reads from another contract's storage during an intermediate state. For example, if Contract A updates a shared token balance and makes an external call before the update is finalized, and Contract B reads that balance during the external call, Contract B will operate on stale data.
This variant is particularly dangerous because it is harder to detect during code review. The vulnerability spans across contract boundaries, and neither contract may appear vulnerable when examined in isolation. Only when their interactions are analyzed together does the attack vector become apparent.
Solutions like OpenZeppelin's ReentrancyGuard store their state within the contract they are incorporated in and thus cannot protect against cross-contract reentrancy on their own. Protecting against this variant requires careful architectural design and potentially shared reentrancy locks across the contract system.
Read-Only Reentrancy
Read-only reentrancy is a newer and more subtle variant that targets contracts during read operations. Unlike traditional reentrancy that exploits state-modifying functions, read-only reentrancy targets view or pure functions that are typically left unguarded because they do not modify the contract's state.
However, if the state is inconsistent at the time a view function is called, wrong values can be reported. A read-only reentrancy scenario occurs when a view function is reentered during an ongoing transaction where the contract's state is only partially updated.
The impact of read-only reentrancy is typically less direct than classic reentrancy attacks, but it can be severe in specific contexts. For example, it could be used to:
- Manipulate oracle prices: If a price oracle reads from a contract whose state is mid-update, it may return incorrect prices
- Mislead external systems: DeFi protocols that rely on other contracts' view functions for calculations can be tricked into making incorrect decisions
- Disrupt decision-making processes: Governance contracts or automated systems that read contract state during vulnerable windows may act on false information
contract LiquidityPool {
uint256 public totalSupply;
uint256 public totalAssets;
// View function - typically unguarded
function getSharePrice() public view returns (uint256) {
if (totalSupply == 0) return 1e18;
return totalAssets * 1e18 / totalSupply;
}
function withdraw(uint256 shares) external {
uint256 assets = shares * totalAssets / totalSupply;
totalSupply -= shares;
// totalAssets not yet updated!
// External call with inconsistent state
(bool success, ) = msg.sender.call{value: assets}("");
require(success);
totalAssets -= assets;
}
}
In this example, during the external call within withdraw(), the totalSupply has been decremented but totalAssets has not. If another contract calls getSharePrice() at this moment, it will receive an inflated price because the numerator (totalAssets) is still at its original value while the denominator (totalSupply) has already decreased.
Real-World Reentrancy Exploits
Beyond the DAO hack, reentrancy attacks have continued to plague the DeFi ecosystem. Understanding these real-world incidents helps illustrate why this vulnerability class remains critical despite being well-documented.
The Penpie Exploit ($27M)
In 2024, the Penpie protocol suffered a $27 million loss due to a reentrancy exploit. The attack demonstrated that even modern DeFi protocols with sophisticated architectures can fall victim to reentrancy when contract interactions are not carefully managed. The attacker exploited a callback mechanism during a staking operation, reentering the contract before reward calculations were finalized.
Cream Finance ($130M)
Cream Finance experienced a devastating exploit that leveraged a combination of flash loans and reentrancy. The attacker manipulated token prices through reentrancy during a flash loan callback, causing the protocol to miscalculate collateral values. This incident highlighted how reentrancy can be combined with other attack vectors to amplify damage.
Curve Finance - Vyper Compiler Bug
In 2023, several Curve Finance pools were exploited due to a reentrancy vulnerability in the Vyper compiler itself. The compiler's reentrancy guard implementation was broken in certain versions, leaving contracts that relied on it completely unprotected. This demonstrated that reentrancy protection must be verified at every level of the stack - not just at the application level.
Prevention Strategies
Preventing reentrancy attacks requires a multi-layered approach. No single technique provides absolute protection, but combining several strategies creates a robust defense.
The Checks-Effects-Interactions Pattern
The Checks-Effects-Interactions (CEI) pattern is the most fundamental and widely recommended defense against reentrancy attacks. It enforces a strict ordering of operations within each function:
- Checks: Validate all conditions and requirements first (require statements, input validation)
- Effects: Update all state variables
- Interactions: Make external calls last, after all state changes are complete
By following this order, even if a called contract attempts a reentrancy attack, the state of the original contract will already be updated, rendering the attack ineffective.
contract SecureBank {
mapping(address => uint256) public balances;
function withdraw(uint256 _amount) public {
// 1. CHECKS
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 2. EFFECTS - Update state BEFORE external call
balances[msg.sender] -= _amount;
// 3. INTERACTIONS - External call LAST
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}
If this principle is meticulously applied, reentrancy vulnerabilities can be effectively eliminated because no state change occurs following external calls. Even if the attacker's contract tries to call withdraw() again during the callback, the balance has already been decremented, so the require check will fail.
Reentrancy Guard (Mutex Lock)
A reentrancy guard uses a mutex (mutual exclusion) lock to prevent any function from being re-entered while it is already executing. OpenZeppelin provides a widely-used implementation called ReentrancyGuard with a nonReentrant modifier.
abstract contract ReentrancyGuard {
bool private _notEntered;
constructor() {
_notEntered = true;
}
modifier nonReentrant() {
require(_notEntered, "ReentrancyGuard: reentrant call");
_notEntered = false;
_;
_notEntered = true;
}
}
The nonReentrant modifier ensures that while a function with this modifier is being executed, it cannot be called again until it completes. This prevents all forms of single-contract reentrancy, including cross-function reentrancy within the same contract.
While reentrancy guards are effective for single-contract protection, they cannot shield against cross-contract reentrancy on their own. The guard state is stored within the contract, so a separate contract reading stale state will not be affected by the lock.
Pull Over Push Payments
The Pull over Push pattern avoids making external calls to send funds during state-changing operations. Instead of pushing funds to recipients, the contract records what is owed and lets recipients pull (withdraw) their funds separately.
contract PullPayment {
mapping(address => uint256) public pendingWithdrawals;
function processPayment(address _recipient, uint256 _amount) internal {
// No external call - just record the debt
pendingWithdrawals[_recipient] += _amount;
}
function withdrawPayment() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
// CEI pattern: update state before external call
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
This pattern eliminates the reentrancy risk from the primary business logic entirely, since no external calls happen during state-changing operations. The withdrawal function itself follows the CEI pattern, providing an additional layer of safety.
Protecting Against Read-Only Reentrancy
Traditional reentrancy guards that block state-modifying functions are not sufficient for read-only reentrancy. Even if a function does not change the state, it should be protected from reentrancy if it is accessed during intermediate stages of a transaction before the contract's state is fully updated.
The approach is to employ a variant of the standard reentrancy guard that also locks view functions during state transitions:
abstract contract ReadOnlyReentrancyGuard {
bool private _locked;
modifier nonReentrant() {
require(!_locked, "Locked");
_locked = true;
_;
_locked = false;
}
// Apply to view functions that return sensitive state
modifier nonReadReentrant() {
require(!_locked, "State is being modified");
_;
}
}
By verifying that the reentrancy guard has not been locked before allowing read operations, you ensure that view functions cannot return stale or inconsistent data during ongoing state modifications.
Best Practices for Smart Contract Security
Beyond the specific prevention techniques, there are broader best practices that development teams should follow to minimize the risk of reentrancy vulnerabilities.
Secure Development Lifecycle
- Follow the CEI pattern consistently: Make it a team-wide convention. Every function that makes an external call should follow the Checks-Effects-Interactions pattern without exception.
- Use established libraries: Leverage battle-tested implementations from OpenZeppelin and similar audited libraries rather than rolling your own security mechanisms.
- Minimize external calls: Reduce the number of external calls in your contracts. Each external call is a potential reentrancy vector.
- Be cautious with callbacks: Any function that accepts a callback or interacts with untrusted contracts should be treated as a potential reentrancy point.
Testing and Auditing
- Write reentrancy-specific tests: Create test cases that explicitly try to re-enter your contract functions during external calls. Use mock malicious contracts in your test suite.
- Conduct regular audits: Have your contracts reviewed by professional auditors who specialize in smart contract security. Reentrancy is one of the first things experienced auditors check for.
- Use static analysis tools: Tools like Slither, Mythril, and Securify can automatically detect common reentrancy patterns in your code.
- Fuzz your contracts: Coverage-guided fuzzing can discover reentrancy paths that human review and static analysis might miss, especially in complex multi-contract systems.
Architectural Considerations
- Minimize shared state: Reducing state dependencies between contracts limits the attack surface for cross-contract reentrancy.
- Use reentrancy-safe tokens: Be aware that some token standards (like ERC-777 and ERC-1155) include callback mechanisms that can introduce reentrancy vectors. When integrating these tokens, ensure your contract handles callbacks safely.
- Document trust assumptions: Clearly document which contracts in your system are trusted and which interfaces might receive calls from untrusted parties. This helps auditors focus their review on the most critical interaction points.
- Implement circuit breakers: Include emergency pause mechanisms that allow you to halt operations if a reentrancy attack is detected in production.
Reentrancy Detection Checklist for Auditors
When auditing smart contracts for reentrancy vulnerabilities, systematically check for the following patterns:
- State changes after external calls: Any
call,delegatecall, ortransfer/sendthat precedes a state variable update is a red flag - Missing reentrancy guards: Functions that make external calls should have either the CEI pattern or a
nonReentrantmodifier - Cross-function state dependencies: Multiple functions that read and write the same state variables, where at least one makes an external call
- Cross-contract state reads: View functions in other contracts that are called during state transitions
- Callback mechanisms: Token hooks (ERC-777
tokensReceived, ERC-1155onERC1155Received), flash loan callbacks, and any other user-controlled callback - Inherited reentrancy: Third-party contracts or libraries that make external calls on behalf of the audited contract
Conclusion
Reentrancy attacks remain one of the most critical vulnerability classes in smart contract security, despite being extensively documented since 2016. The attack vector has evolved from simple single-function reentrancy to sophisticated cross-contract and read-only variants that can evade traditional defenses.
The key lesson is that reentrancy is not just a single bug pattern - it is a class of vulnerabilities that arises whenever a contract's state can be observed or manipulated during an incomplete state transition. Understanding the full spectrum of reentrancy types - single-function, cross-function, cross-contract, and read-only - is essential for building and auditing secure smart contracts.
By consistently applying the Checks-Effects-Interactions pattern, using reentrancy guards, adopting pull-over-push payment patterns, and conducting thorough security audits and testing, developers can significantly reduce their exposure to these attacks. At Zokyo, reentrancy analysis is a fundamental part of every audit engagement, and we strongly encourage all smart contract developers to treat reentrancy prevention as a first-class concern in their development process.
If you are building smart contracts and want help securing them against reentrancy and other vulnerability classes, reach out to our team.