Back to Blog

In 2016, a single vulnerability drained $60 million from The DAO—a decentralized venture fund that held 14% of all Ether in existence at the time. The attack was elegant in its simplicity: call a function, receive funds, call it again before the first transaction completes. This is reentrancy, and nearly a decade later, it remains one of the most exploited vulnerabilities in smart contracts.

What Is Reentrancy?

Reentrancy occurs when a contract makes an external call to another contract before updating its own state. The called contract can then “re-enter” the original function and execute it again with stale state data.

Think of it like this: you withdraw $100 from an ATM. Before the ATM updates your balance, you quickly start another withdrawal. The second withdrawal still sees your original balance and dispenses another $100. Repeat until empty.

The Classic Attack Pattern

Here’s a simplified vulnerable contract:

contract VulnerableVault {
    mapping(address => uint256) public balances;

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

        // DANGER: External call before state update
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");

        // State update happens AFTER the external call
        balances[msg.sender] = 0;
    }
}

An attacker deploys a contract with a receive() function that immediately calls withdraw() again:

contract Attacker {
    VulnerableVault public vault;

    function attack() external payable {
        vault.deposit{value: 1 ether}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw(); // Re-enter before balance is zeroed
        }
    }
}

Each re-entry, the attacker’s balance still shows 1 ETH because the state update (balances[msg.sender] = 0) hasn’t executed yet.

Why This Still Happens

You might think that after The DAO, developers would have learned. But reentrancy attacks keep happening:

  • Curve Finance (2023): $70M stolen due to a reentrancy bug in Vyper’s compiler
  • Fei Protocol (2022): $80M drained through reentrancy in a flash loan
  • Grim Finance (2021): $30M lost to cross-contract reentrancy

The reason? As DeFi grows more complex, so do the attack vectors. Modern protocols involve dozens of interconnected contracts, each a potential entry point for reentrancy.

Defense Patterns That Actually Work

1. Checks-Effects-Interactions (CEI)

The gold standard. Always update state before making external calls:

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

    // EFFECT: Update state first
    balances[msg.sender] = 0;

    // INTERACTION: External call last
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success, "Transfer failed");
}

2. Reentrancy Guards

OpenZeppelin’s ReentrancyGuard is battle-tested and simple:

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

contract SecureVault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        // Now protected against reentrancy
    }
}

Under the hood, it uses a mutex that prevents nested calls to the same function.

3. Pull Over Push

Instead of sending funds to users, let them withdraw:

mapping(address => uint256) public pendingWithdrawals;

function allowWithdrawal(address user, uint256 amount) internal {
    pendingWithdrawals[user] += amount;
}

function withdraw() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    pendingWithdrawals[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
}

This pattern eliminates most reentrancy risk because the state is always updated before any transfer.

The Subtler Variants

Cross-Function Reentrancy

An attacker might re-enter a different function that shares state:

function withdraw() external {
    uint256 balance = balances[msg.sender];
    (bool success, ) = msg.sender.call{value: balance}("");
    balances[msg.sender] = 0;
}

function transfer(address to, uint256 amount) external {
    // This reads stale balance during reentrancy
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

If an attacker re-enters transfer() before withdraw() updates the balance, they can transfer tokens they’ve already withdrawn.

Solution: Apply nonReentrant to all functions that modify shared state, or use a global lock.

Cross-Contract Reentrancy

Modern DeFi protocols span multiple contracts. An attacker might:

  1. Call Contract A
  2. Contract A calls Contract B
  3. Contract B calls back to Contract A

This is harder to prevent because you don’t control Contract B’s behavior.

Solution: Treat all external calls as potentially malicious. Update your state before calling any external contract, even ones you trust.

Read-Only Reentrancy

A newer variant where the attacker doesn’t modify state but manipulates read operations:

function getPrice() public view returns (uint256) {
    return totalAssets / totalShares;
}

function deposit() external payable {
    uint256 shares = msg.value * totalShares / totalAssets;
    // External call before updating totalAssets
    externalProtocol.notifyDeposit(msg.sender);
    totalAssets += msg.value;
}

If externalProtocol calls getPrice() during the callback, it sees stale totalAssets—potentially enabling price manipulation.

Auditing for Reentrancy

When we audit contracts at CyberPal, we follow a systematic approach:

  1. Map all external calls: Every call, transfer, send, and external contract interaction
  2. Trace state changes: What state is modified before vs. after each call?
  3. Identify shared state: Which functions read/write the same variables?
  4. Test cross-contract flows: How do interactions with other protocols affect invariants?
  5. Verify guards: Are reentrancy protections applied consistently?

We use automated tools (Slither, Mythril) as a first pass, but these can miss complex reentrancy patterns. Manual review remains essential.

Key Takeaways

  1. Always follow CEI: Checks first, effects (state changes) second, interactions (external calls) last
  2. Use reentrancy guards: They’re cheap and eliminate entire classes of bugs
  3. Think cross-function: One function’s external call can affect another function’s logic
  4. Audit external integrations: Third-party protocols can introduce unexpected callbacks
  5. Test aggressively: Use fuzzing and formal verification to catch edge cases

Reentrancy has been known for nearly a decade, yet it continues to drain millions from DeFi protocols. The difference between a secure contract and a vulnerable one often comes down to the order of three lines of code. When those three lines are worth $60 million, getting them right matters.


Need a reentrancy review of your smart contracts? Get in touch for a confidential assessment.

Ready to Secure Your Project?

Whether you're preparing for launch or strengthening an existing protocol, we're here to help. Get in touch for a confidential conversation about your security needs.