Skip to main content

Overview

A reentrancy attack is a serious vulnerability where a function calls an external contract, allowing that contract to make repeated calls back before execution completes.
The DAO hack in 2016 exploited a reentrancy vulnerability, resulting in approximately $60 million USD in losses.

How It Works

The basic mechanism: Contract A calls Contract B. The exploit allows B to call back into A before A finishes execution.

Attack Sequence

Step-by-Step Example

  1. User initiates withdrawal from bank contract holding 10 ETH
  2. Bank verifies balance equals 10 ETH
  3. Bank begins transferring 10 ETH to user
  4. Attacker’s contract receives funds and immediately triggers another withdrawal
  5. Balance hasn’t been updated yet, so contract allows another 10 ETH withdrawal
  6. Cycle repeats until bank is drained

Vulnerable Code

// VULNERABLE: State updated after external call
function withdraw(uint256 amount) public {
    require(balances[msg.sender] >= amount);

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

    // State update happens too late
    balances[msg.sender] -= amount;
}

Attacker Contract

contract Attacker {
    Bank public bank;

    constructor(address _bankAddress) {
        bank = Bank(_bankAddress);
    }

    receive() external payable {
        if (address(bank).balance >= 10 ether) {
            bank.withdraw(10 ether);  // Re-enter!
        }
    }

    function attack() public payable {
        bank.deposit{value: 10 ether}();
        bank.withdraw(10 ether);
    }
}

Safe Patterns

Checks-Effects-Interactions

// SAFE: State updated BEFORE external call
function withdraw(uint256 amount) public {
    require(balances[msg.sender] >= amount);

    // Update state FIRST
    balances[msg.sender] -= amount;

    // External call LAST
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

Reentrancy Guard

// SAFE: Mutex lock prevents re-entry
bool private locked;

modifier noReentrant() {
    require(!locked, "Reentrant call");
    locked = true;
    _;
    locked = false;
}

function withdraw(uint256 amount) public noReentrant {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

Reentrancy Variants

VariantDescription
Single-functionRe-entering the same function
Cross-functionRe-entering a different function that shares state
Cross-contractRe-entering via another contract in the same protocol
Read-onlyExploiting view functions during reentrancy

API Detection

{
  "issues": [
    {
      "tag": "reentrancy",
      "severity": "high",
      "description": "External call before state update in withdraw()",
      "location": "withdraw(uint256)",
      "variant": "single-function"
    }
  ]
}

Prevention Checklist

  • Follow Checks-Effects-Interactions pattern
  • Update all state before making external calls
  • Use reentrancy guards (e.g., OpenZeppelin ReentrancyGuard) on sensitive functions
  • Use .call{value: ...}("") for ETH transfers (not transfer()/send())
  • Always check the boolean return value from .call and handle failures explicitly
transfer() and send() are deprecated due to their hardcoded 2300 gas limit, which can fail after gas cost changes (EIP-1884). Use .call{value: ...}("") instead.