Reentrancy is the bug that took $60M out of the Ethereum ecosystem in 2016 and led to a chain split. Ten years later, it is still the most common HIGH-severity finding on the contracts we scan. If your contract sends ETH or calls another contract, this post is for you.
What is reentrancy?
A reentrancy attack happens when a function makes an external call before it has finished updating its own state. The external recipient can call back into the original function — re-enter it — and exploit the inconsistent state to do something the contract did not intend.
The canonical example is a vault's withdraw:
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool ok, ) = msg.sender.call{value: amount}(""); // ← external call
require(ok, "send failed");
balances[msg.sender] = 0; // ← state update AFTER the call
}
The attacker deploys a contract whose receive() function calls withdraw() again. The flow:
- Attacker contract calls
withdraw().balances[attacker] = 10 ETH. Vault sends 10 ETH to attacker. - Attacker's
receive()triggers. It callswithdraw()again. The vault's state still showsbalances[attacker] = 10 ETH(we haven't reached the= 0line yet). Vault sends 10 ETH again. - Repeat until the vault is empty or runs out of gas.
The vault loses everything. The attacker walks away with 10 × N ETH for a balances[attacker] of 10.
Why is it still happening in 2026?
Three reasons:
- External calls are everywhere now. Composability is the whole point of EVM. Every router, vault, AMM, and aggregator calls other contracts.
- Cross-function reentrancy is sneakier. The attacker doesn't have to re-enter the same function. They can re-enter a different function that shares state. Static analysis catches the obvious case; cross-function is what slips through.
- Read-only reentrancy is even sneakier. The attacker doesn't have to modify state — just read it during the inconsistent window. Curve and other oracles have lost to this.
Fix 1: Checks-Effects-Interactions
The classic fix. Update state before the external call.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0; // ← state update FIRST
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
Pros: zero gas overhead. No imports. Works for every case of single-function reentrancy.
Cons: does not protect against cross-function reentrancy. If withdraw() updates balances but getReward() reads totalBalances (which was not updated), the attacker can re-enter through getReward.
Fix 2: ReentrancyGuard
OpenZeppelin's ReentrancyGuard adds a mutex. The first call sets the lock; nested calls revert.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
function withdraw() external nonReentrant {
// ... your code, possibly with external calls in any order
}
}
Pros: protects against same-function and cross-function reentrancy across all functions marked nonReentrant. Hard to get wrong.
Cons: ~2,300 gas per call (one SSTORE for the lock + one for the unlock). Not enough by itself for read-only reentrancy — a view function isn't marked nonReentrant, so an external read during the locked window still sees inconsistent state.
Fix 3: Pull payments + read-only guard
Push payments (you transfer to a user) are inherently risky because the recipient runs code. Pull payments invert it: the user calls a function on your contract to claim. Your contract never calls an arbitrary address.
mapping(address => uint256) public pendingWithdrawals;
function requestWithdrawal() external {
pendingWithdrawals[msg.sender] = balances[msg.sender];
balances[msg.sender] = 0;
}
function claim() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
For read-only reentrancy, you add a _lock check in your view functions that other contracts use as oracles:
function getPrice() external view returns (uint256) {
require(!_locked(), "reentrant read");
return _computePrice();
}
Pros: strongest protection. Compatible with composability.
Cons: requires API change (users now make two transactions, not one). Adds complexity.
Which fix should I use?
- One simple
withdrawwith no cross-function state sharing → Fix 1 (CEI). Cheapest, smallest. - Multiple functions that mutate shared balances → Fix 2 (ReentrancyGuard) on every external mutator.
- Your contract is used as an oracle by other contracts → Fix 3 (pull payments + read-only guard).
In practice we recommend ReentrancyGuard by default. The 2,300 gas is worth not having to think about cross-function paths.
How AEDSC finds these
AEDSC runs Slither (which catches single-function and many cross-function cases) and Mythril (which uses symbolic execution to catch deeper cases) in parallel. When both flag the same external-call-before-state-update pattern, we mark it HIGH confidence and write a CEI patch PR against your branch automatically.