# Solidity Reentrancy Attacks: How They Work and How to Prevent Them

> A deep-dive into Solidity reentrancy attacks — how the exploit works at the EVM level, real patterns that introduce it, and the defenses that reliably stop it.

- **Published:** 2026-06-07
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** solidity, security, reentrancy, smart-contracts, evm
- **Canonical URL:** https://clixo.sh/blog/solidity-reentrancy-attack-prevention

Reentrancy is the most documented vulnerability in Ethereum's history. It drained the DAO in 2016 — a multi-million dollar incident that led to a network fork. Fifteen years later, variants of the same bug still appear in audited codebases. Understanding exactly how it works is the fastest path to not writing it.

## How Solidity Reentrancy Attacks Work

A reentrancy attack exploits the gap between when your contract makes an external call and when it updates its own state.

```mermaid
sequenceDiagram
  participant ATK as Attacker Contract
  participant VLT as Vulnerable Vault
  participant BAL as balances mapping

  ATK->>VLT: withdraw(1 ETH)
  VLT->>BAL: check balances[attacker] >= 1 ETH
  BAL-->>VLT: passes
  VLT->>ATK: send 1 ETH via call
  ATK->>VLT: re-enter withdraw(1 ETH)
  VLT->>BAL: check again — state not updated yet
  BAL-->>VLT: passes again
  VLT->>ATK: send another 1 ETH
  VLT->>BAL: balances[attacker] -= 1 ETH (too late)
```

Consider a simple withdrawal function:

```
mapping(address => uint256) public balances;

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    // External call BEFORE state update — dangerous
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");

    balances[msg.sender] -= amount;  // State updated too late
}
```

When `msg.sender.call{value: amount}("")` executes, control passes to the recipient. If the recipient is a contract, its `receive()` or `fallback()` function runs. A malicious contract can call `withdraw()` again from inside that function — before `balances[msg.sender] -= amount` has executed. The balance check passes every time because the state has not been updated.

The attacker's contract looks like this:

```
contract Attacker {
    VulnerableVault target;

    receive() external payable {
        // Re-enter while the victim's state is still stale
        if (address(target).balance >= 1 ether) {
            target.withdraw(1 ether);
        }
    }

    function attack() external {
        target.withdraw(1 ether);
    }
}
```

Each nested call to `withdraw()` passes the balance check and sends another 1 ETH. The loop continues until the vault is drained or gas runs out.

## Three Defenses That Reliably Prevent Reentrancy

### Defense 1: Checks-Effects-Interactions (CEI) Pattern

This is the primary defense. Restructure every function so that:

1. All checks happen first
2. All state updates happen second
3. External calls happen last

The fixed version of the withdrawal:

```
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");

    balances[msg.sender] -= amount;  // State updated BEFORE external call

    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}
```

Now when the attacker's `receive()` re-enters `withdraw()`, the balance check fails because the state was already decremented.

### Defense 2: ReentrancyGuard

OpenZeppelin's `ReentrancyGuard` implements a mutex at the contract level. A function marked `nonReentrant` cannot be entered again while it is already executing:

```
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureVault is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}
```

The guard stores a lock value in a storage slot. It costs a small amount of extra gas on entry and exit, but the protection is unconditional.

Using both CEI and `ReentrancyGuard` together is common in production systems — defense in depth.

### Defense 3: Pull Over Push

Instead of sending ETH to users inside your function (push), let users withdraw it themselves (pull). This moves the external call risk from your logic to the withdrawal step:

```
mapping(address => uint256) public pendingWithdrawals;

function claimReward() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    pendingWithdrawals[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Withdrawal failed");
}
```

The state is cleared before the external call, and the function is isolated to just the withdrawal concern.

## Cross-Function Reentrancy

A subtler variant occurs across multiple functions in the same contract. If `functionA` changes state and calls an external address, an attacker can re-enter `functionB` — which reads the same state that `functionA` has not finished updating.

The `nonReentrant` modifier blocks this automatically when applied to both functions. CEI alone does not prevent cross-function reentrancy unless both functions update the same state before their external calls.

## Read-Only Reentrancy

An advanced variant targets protocols that read state from other contracts. If your contract calls into a contract that is mid-execution (and therefore in an inconsistent state), your price calculation or balance check might return wrong values. This cannot be prevented by ReentrancyGuard on the attacker's side — you must be careful about which external contracts you read from and when.

## Common Patterns That Introduce Reentrancy

- Sending ETH to `msg.sender` before updating balances
- Calling user-supplied addresses without guards
- ERC-777 token callbacks (which fire on transfer)
- ERC-721 `safeTransfer` which calls `onERC721Received` on the recipient

Any time your contract hands execution to an external address — through a raw call, a token transfer, or a callback — reentrancy is a consideration.

## Preventing Reentrancy in Production

Apply these checks to every contract you ship:

- Run Slither: `slither . --detect reentrancy-eth,reentrancy-no-eth`
- Review every external call for CEI compliance
- Apply `nonReentrant` to all value-moving functions
- Have a second engineer review reentrancy-sensitive code paths specifically

Reentrancy is not exotic. It is a mechanical property of the EVM's execution model. Understanding the model makes the prevention mechanical too.

If you are building a protocol or product that handles user funds on-chain, [Clixo](https://clixo.sh/services/smart-contract-development) ships contract systems with reentrancy prevention and full security analysis built into the engineering process.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
