In 2016, a single vulnerability drained $60 million from The DAO—a decentralized venture fund that held 14% of all Ether in existence at the time. The attack was elegant in its simplicity: call a function, receive funds, call it again before the first transaction completes. This is reentrancy, and nearly a decade later, it remains one of the most exploited vulnerabilities in smart contracts.
What Is Reentrancy?
Reentrancy occurs when a contract makes an external call to another contract before updating its own state. The called contract can then “re-enter” the original function and execute it again with stale state data.
Think of it like this: you withdraw $100 from an ATM. Before the ATM updates your balance, you quickly start another withdrawal. The second withdrawal still sees your original balance and dispenses another $100. Repeat until empty.
The Classic Attack Pattern
Here’s a simplified vulnerable contract:
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 balance = balances[msg.sender];
require(balance > 0, "No balance");
// DANGER: External call before state update
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// State update happens AFTER the external call
balances[msg.sender] = 0;
}
} An attacker deploys a contract with a receive() function that immediately calls withdraw() again:
contract Attacker {
VulnerableVault public vault;
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // Re-enter before balance is zeroed
}
}
} Each re-entry, the attacker’s balance still shows 1 ETH because the state update (balances[msg.sender] = 0) hasn’t executed yet.
Why This Still Happens
You might think that after The DAO, developers would have learned. But reentrancy attacks keep happening:
- Curve Finance (2023): $70M stolen due to a reentrancy bug in Vyper’s compiler
- Fei Protocol (2022): $80M drained through reentrancy in a flash loan
- Grim Finance (2021): $30M lost to cross-contract reentrancy
The reason? As DeFi grows more complex, so do the attack vectors. Modern protocols involve dozens of interconnected contracts, each a potential entry point for reentrancy.
Defense Patterns That Actually Work
1. Checks-Effects-Interactions (CEI)
The gold standard. Always update state before making external calls:
function withdraw() external {
uint256 balance = balances[msg.sender];
require(balance > 0, "No balance");
// EFFECT: Update state first
balances[msg.sender] = 0;
// INTERACTION: External call last
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
} 2. Reentrancy Guards
OpenZeppelin’s ReentrancyGuard is battle-tested and simple:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
function withdraw() external nonReentrant {
// Now protected against reentrancy
}
} Under the hood, it uses a mutex that prevents nested calls to the same function.
3. Pull Over Push
Instead of sending funds to users, let them withdraw:
mapping(address => uint256) public pendingWithdrawals;
function allowWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
} This pattern eliminates most reentrancy risk because the state is always updated before any transfer.
The Subtler Variants
Cross-Function Reentrancy
An attacker might re-enter a different function that shares state:
function withdraw() external {
uint256 balance = balances[msg.sender];
(bool success, ) = msg.sender.call{value: balance}("");
balances[msg.sender] = 0;
}
function transfer(address to, uint256 amount) external {
// This reads stale balance during reentrancy
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
} If an attacker re-enters transfer() before withdraw() updates the balance, they can transfer tokens they’ve already withdrawn.
Solution: Apply nonReentrant to all functions that modify shared state, or use a global lock.
Cross-Contract Reentrancy
Modern DeFi protocols span multiple contracts. An attacker might:
- Call Contract A
- Contract A calls Contract B
- Contract B calls back to Contract A
This is harder to prevent because you don’t control Contract B’s behavior.
Solution: Treat all external calls as potentially malicious. Update your state before calling any external contract, even ones you trust.
Read-Only Reentrancy
A newer variant where the attacker doesn’t modify state but manipulates read operations:
function getPrice() public view returns (uint256) {
return totalAssets / totalShares;
}
function deposit() external payable {
uint256 shares = msg.value * totalShares / totalAssets;
// External call before updating totalAssets
externalProtocol.notifyDeposit(msg.sender);
totalAssets += msg.value;
} If externalProtocol calls getPrice() during the callback, it sees stale totalAssets—potentially enabling price manipulation.
Auditing for Reentrancy
When we audit contracts at CyberPal, we follow a systematic approach:
- Map all external calls: Every
call,transfer,send, and external contract interaction - Trace state changes: What state is modified before vs. after each call?
- Identify shared state: Which functions read/write the same variables?
- Test cross-contract flows: How do interactions with other protocols affect invariants?
- Verify guards: Are reentrancy protections applied consistently?
We use automated tools (Slither, Mythril) as a first pass, but these can miss complex reentrancy patterns. Manual review remains essential.
Key Takeaways
- Always follow CEI: Checks first, effects (state changes) second, interactions (external calls) last
- Use reentrancy guards: They’re cheap and eliminate entire classes of bugs
- Think cross-function: One function’s external call can affect another function’s logic
- Audit external integrations: Third-party protocols can introduce unexpected callbacks
- Test aggressively: Use fuzzing and formal verification to catch edge cases
Reentrancy has been known for nearly a decade, yet it continues to drain millions from DeFi protocols. The difference between a secure contract and a vulnerable one often comes down to the order of three lines of code. When those three lines are worth $60 million, getting them right matters.
Need a reentrancy review of your smart contracts? Get in touch for a confidential assessment.
Ready to Secure Your Project?
Whether you're preparing for launch or strengthening an existing protocol, we're here to help. Get in touch for a confidential conversation about your security needs.