← All posts
May 24, 2026 · 4 min read · by AEDSC team

Reentrancy attacks: anatomy + 3 ways to fix yours

Reentrancy is the bug that caused the DAO hack, and it's still the number-one finding on contracts we scan. Here's how it works, the three idiomatic fixes, and the trade-offs between them.

reentrancysecuritysolidityvulnerabilitychecks-effects-interactions

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:

  1. Attacker contract calls withdraw(). balances[attacker] = 10 ETH. Vault sends 10 ETH to attacker.
  2. Attacker's receive() triggers. It calls withdraw() again. The vault's state still shows balances[attacker] = 10 ETH (we haven't reached the = 0 line yet). Vault sends 10 ETH again.
  3. 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:

  1. External calls are everywhere now. Composability is the whole point of EVM. Every router, vault, AMM, and aggregator calls other contracts.
  2. 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.
  3. 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 withdraw with 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.

Scan your contract for reentrancy →