Reentrancy: The Bug That Forked Ethereum
10 min read
July 4, 2026

Table of contents
👋 Introduction
Hey everyone!
Last week we covered flash loans: uncollateralized borrowing that removes the capital barrier to exploiting DeFi. Flash loans often provide the fuel. This week covers the engine they frequently ignite: reentrancy.
Reentrancy forced Ethereum to fork in 2016. 60 million left The DAO in recursive withdrawal calls before anyone could stop it. The Ethereum community could not agree on how to respond, so they split the chain. Ethereum Classic exists because of this vulnerability. In 2023, Curve Finance lost 52 million to reentrancy again, this time through a Vyper compiler bug. Developers wrote @nonreentrant. The compiler generated code where the lock only protected against itself.
Security researchers documented the fix in 2016: update state before making external calls. Every Solidity tutorial covers it. Protocols still get it wrong, still at scale, still for hundreds of millions.
This week: every reentrancy variant, the cases where nonReentrant guards still fail, read-only reentrancy through view functions no one expected to be dangerous, the Vyper compiler bug that hit multiple protocols simultaneously, and the static analysis tools that catch it before it reaches production.
Let’s get into it 👇
🔄 Classic Reentrancy and Why CEI Exists
The vulnerable pattern is a single function that sends ETH to an external address before recording that the withdrawal happened. The recipient controls code that runs automatically on ETH receipt via receive() or fallback(). That code calls back into the withdrawal function before the original call completes. The balance check still passes because state hasn’t updated. The cycle repeats until the contract is drained.
// VULNERABLE: Interactions before Effects, the classic pattern
contract VulnerableBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 bal = balances[msg.sender];
require(bal > 0, "Nothing to withdraw");
// VULNERABILITY: external call fires before state update
(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Transfer failed");
balances[msg.sender] = 0; // Never runs before attacker re-enters
}
}
// Attacker contract
contract Attacker {
VulnerableBank public victim;
// Called automatically every time victim sends ETH
receive() external payable {
// balances[attacker] is still the original amount, so re-enter
if (address(victim).balance >= 1 ether) {
victim.withdraw();
}
}
function attack() external payable {
victim.deposit{value: 1 ether}();
victim.withdraw(); // Triggers recursive drain
}
}
The Checks-Effects-Interactions (CEI) pattern is the fix. Validate conditions first (Checks). Update all state variables second (Effects). Make external calls last (Interactions). Set balances[msg.sender] = 0 before the call. The re-entrant invocation finds zero balance and reverts. This is the withdrawal pattern we analyzed in Issue 17 on Web3 withdrawal vulnerabilities, and the flash loans from Issue 56 frequently amplify reentrancy by providing capital to trigger the vulnerable function at maximum scale before the contract drains.
OpenZeppelin’s ReentrancyGuard provides a nonReentrant modifier that uses a storage slot as a mutex. It is a backup, not a substitute for CEI. Guards without CEI still fail, as the following variants show.
🔀 Cross-Function Reentrancy: When the Guard Doesn’t Cover Enough
A developer marks withdrawAll() with nonReentrant. They don’t notice that transfer(), which reads the same balances mapping, has no guard. During the external call window in withdrawAll(), the re-entrant entry point is not withdrawAll() (the lock blocks that). The entry point is transfer(), which the lock never covered.
contract VulnerableVault {
mapping(address => uint256) public balances;
bool private locked;
modifier noReentrant() { require(!locked); locked = true; _; locked = false; }
function withdrawAll() external noReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0);
(bool sent,) = msg.sender.call{value: bal}(""); // Attacker's receive() fires here
require(sent);
balances[msg.sender] = 0; // Effects still happen AFTER the call
}
function transfer(address to, uint256 amount) external { // No guard
require(balances[msg.sender] >= amount);
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
During the withdrawAll() external call, the attacker’s receive() runs. noReentrant blocks re-entry into withdrawAll(). transfer() is unguarded and reads balances[attacker], which still shows the original amount. The attacker transfers their full balance to an accomplice address. withdrawAll() resumes, zeroes the attacker’s balance, and pays out the ETH. The victim loses both the ETH paid out and the transferred balance.
Fei Protocol lost 80M in April 2022 to this variant. The CertiK post-mortem shows that a reentrancy guard was active on borrow(), but exitMarket() appeared on an explicit allowlist of functions permitted to execute even while the lock was held. Cross-function reentrancy succeeded through the intentional exception. Any allowlist in a reentrancy guard requires auditing every listed function for shared state. If it reads the same mapping, it is the bypass.
👁️ Read-Only Reentrancy: The View Function Oracle
This variant requires no state modification on the victim contract. Just a read. At the wrong moment, against a protocol that trusts the read.
Read-only reentrancy exploits the window when a contract’s internal state is temporarily inconsistent mid-execution. An attacker calls a view function on Protocol A during that window, gets a manipulated value, and uses it to take destructive action on Protocol B, which trusts Protocol A’s oracle.
Curve’s AMM pools report LP token value via get_virtual_price(): total pool value divided by total LP supply. During remove_liquidity(), execution burns LP tokens first, then transfers underlying assets out. If ETH is involved, an ETH transfer triggers the attackerdolar per token’s callback. At that moment, LP supply has decreased but pool balances have not fully resolved:
Normal state: virtual_price = $1,000,000 / 1,000 LP = $1,000 per LP token
Mid-execution: virtual_price = $1,000,000 / 900 LP = $1,111 per LP token (inflated)
The attacker calls get_virtual_price() from inside the callback. dForce, a lending protocol using Curve LP as collateral, sees the inflated price and allows borrowing against collateral valued at 1,111 dollar per token. The attacker borrows far more than actual collateral warrants and drains dForce’s reserves. The CertiK analysis of this 3.7M dollar attack documents the oracle read timing precisely.
Standard nonReentrant guards never apply to view functions. The defense for any protocol consuming a Curve LP oracle is to call pool.claim_admin_fees() before reading get_virtual_price(). This function carries its own reentrancy guard that reverts if Curve is mid-execution. The revert propagates up, preventing any oracle read during inconsistent state.
🔗 When the Compiler Breaks the Guard: Vyper 2023
Curve’s own pools were written in Vyper, not Solidity. Vyper’s @nonreentrant decorator is supposed to use a single shared storage slot as a mutex across all decorated functions in a contract. In Vyper versions 0.2.15, 0.2.16, and 0.3.0, a compiler bug caused each decorated function to allocate its own independent storage slot. Each function locked only against itself.
# Vyper with compiler bug: two separate locks, neither checks the other
@nonreentrant("lock")
def add_liquidity(amounts: uint256[2]) -> uint256:
# Uses storage slot A for "lock"
...
@nonreentrant("lock")
def remove_liquidity(amount: uint256) -> uint256[2]:
# SHOULD use same slot A, but actually uses slot B
# remove_liquidity can execute while add_liquidity's lock is held, and vice versa
...
With independent locks, remove_liquidity() holding slot B left add_liquidity() (slot A) completely unguarded. During the ETH callback in remove_liquidity(), an attacker re-entered add_liquidity(), receiving LP token calculations based on pre-update pool state. Multiple pools fell in July 2023 within hours of each other: JPEG’d (11.5M dollar), Metronome (1.6M dollar), Alchemix (13.6M dollar), Curve’s CRV/ETH pool. Total net losses: approximately $52M after whitehats frontran some transactions to recover funds.
The ZAN technical analysis documents the storage slot isolation failure and the exact transaction replay. The Halborn writeup traces the exploited pools step-by-step. The lesson: source code audits do not catch compiler-level failures. If you deploy contracts with significant TVL, verify that your nonReentrant implementation produces the expected bytecode, not just the expected source.
🛠️ Detection Tools
Slither detects reentrancy at four severity tiers and runs in CI without a running node:
pip install slither-analyzer
slither MyContract.sol --detect reentrancy-eth,reentrancy-no-eth
# Example output:
# VulnerableBank.withdraw() (VulnerableBank.sol#8-14)
# External call: (sent) = msg.sender.call{value: bal}()
# State variable written after call: balances[msg.sender] = 0
# IMPACT: High | CONFIDENCE: Medium
The four detectors: reentrancy-eth (direct ETH theft), reentrancy-no-eth (state manipulation without ETH theft), reentrancy-balance (stale balance reads), reentrancy-benign (low-impact patterns). Slither misses cross-function and read-only variants in complex cases; complement it with Echidna invariant testing:
// Echidna invariant: total withdrawals should never exceed total deposits
function echidna_balance_invariant() public returns (bool) {
return address(target).balance >= 0 && totalWithdrawn <= totalDeposited;
}
Foundry fork testing lets you replay historical reentrancy attacks against mainnet state:
forge test --fork-url $MAINNET_RPC --fork-block-number 16817996 \
--match-test testReentrancyReplay -vvvv
Ethernaut Level 10 is the canonical hands-on challenge: drain a VulnerableBank-style contract with a malicious receive() fallback. Damn Vulnerable DeFi’s Side Entrance combines flash loans with cross-function reentrancy via deposit accounting and directly mirrors the Fei Protocol attack class.
🎯 Key Takeaways
Reentrancy is conceptually the simplest major Solidity vulnerability class. Check balance. Update balance. Then send ETH. In that order. The entire vulnerability comes from inverting the last two steps. It keeps appearing because sending ETH to confirm a transfer feels like the natural completion of a transaction, and recording the state change afterward feels like database-commit logic. CEI inverts that intuition deliberately and for good reason.
Cross-function and read-only variants are where protocols that believe they’ve addressed reentrancy still fail. A nonReentrant guard on one function that shares state with an unguarded function provides incomplete coverage. A guard with an allowlist is a documented bypass mechanism. Read-only reentrancy never modifies guarded state at all. It reads a view function mid-execution and lets a second protocol take the damage. Guards alone are insufficient. CEI applied to every state-modifying function is the floor.
The Vyper compiler bug is the hardest lesson. Developers deployed @nonreentrant decorators and genuinely believed they were protected. Slither analyzes source semantics, not compiled bytecode. A differential test between expected behavior and bytecode-level execution would have caught it. For any protocol holding significant TVL, verify that your security annotations produce the expected output at the bytecode level, not just in source.
For auditing: run Slither in CI on every PR, write Echidna invariants for every accounting function, and work through the Ethernaut and Damn Vulnerable DeFi challenges until the attack flow is automatic. The hardest pattern to spot manually is cross-function reentrancy via shared state. The tell is two or more external-call functions reading the same storage variable where only some have nonReentrant applied.
Practice:
- Ethernaut Level 10: Re-Entrancy - classic single-function reentrancy challenge, deploy an attacker contract to drain
- Damn Vulnerable DeFi: Side Entrance - cross-function reentrancy via flash loan + deposit accounting confusion
- Cyfrin Updraft Security Course - free course covering reentrancy auditing, fuzzing, and invariant testing
- Slither (crytic) - static analysis with four reentrancy severity tiers, runs in CI
- Echidna (crytic) - property-based fuzzer for writing and breaking accounting invariants
- Foundry (foundry-rs) - exploit PoC development with mainnet fork testing
- The DAO hack explained (Chainlink) - foundational 2016 case study with vulnerable and fixed code
- Curve Vyper compiler bug (ZAN analysis) - most technical post-mortem of the $52M July 2023 attack
- Fei Protocol cross-function reentrancy (CertiK) - $80M case study showing guard allowlist bypass
Thanks for reading, and happy hunting!
— Ruben
Other Issues
Previous Issue
Next Issue
💬 Comments Available
Drop your thoughts in the comments below! Found a bug or have feedback? Let me know.