May 2, 2026

Smart Contract CTF: Four Bugs That Already Drained Mainnet

Four bug classes the Solidity compiler can't fix. picoCTF 2026 ships each one as a primitive; mainnet already paid over $500M for the lessons. The map from CTF challenge to attacker calldata to defensive grep.

The chain remembers every exploit you'll ever practice on

On June 17, 2016, an attacker sent transaction 0x0ec3f248…3790b to The DAO, a 2016 venture-fund-as-smart-contract that was, at the time, holding around 12% of all the Ether in circulation. The transaction is still there. You can read it. Block 1,718,497, ~3.6 million ETH siphoned out at a rate the network had not seen before, valued near $60 million on the day. Phil Daian published the post-mortem twenty-four hours later. The Ethereum Foundation hard-forked the chain at block 1,920,000 to roll the funds back, and the un-forked legacy chain is the thing we now call Ethereum Classic.

Open the picoCTF 2026 challenge reentrance. The contract is twenty lines. The bug is one line. It is the same bug The DAO shipped, in the simplest possible host function. Solve the challenge and you have, mechanically, walked the same path as the exploit that forced Ethereum to fork itself in half.

Smart-contract security is the only domain where the entire bug history sits on a public, immutable ledger.

Every reentrancy call, every overflow, every front-run is decoded calldata you can pull from etherscan tonight. picoCTF 2026 added four Solidity challenges to its Web3 track. Each one is a different bug class. Each one is the same primitive that drained mainnet, taught one bug per contract.

The four bugs:

Reentrancy

$60M+

The DAO 2016, Lendf.Me 2020, Cream 2021, Curve 2023

Integer underflow

10^58 BEC

BatchOverflow 2018 wave, plus a 2023 unchecked footgun

Access control

513,774 ETH

Parity multi-sig 2017 (still frozen), Audius 2022

Front-running

$1.5B+

Cumulative MEV per Flashbots REV, Sept 2022 to June 2024

Three of those four show up in Slither's detector list. The fourth one does not, because front-running is not a bug in any contract. It is a bug in the assumption that anything you put on chain is private until it is mined. Reentrancy, overflow, and access-control all live inside the bytecode and a static analyzer can argue about them. The mempool has no source code to scan.

The picoCTF challenges are not literal clones of the mainnet hacks. They are pedagogical reductions: the bug primitive isolated, the function it lives in simplified, the multi-contract proxy soup stripped out. When I started this post I wanted to call them clones. They're not. They are the same bug, the smallest host. That distinction matters because a careful reader will diff VulnBank.withdraw against The DAO's splitDAO and notice the function names are different. The bug primitive is identical. That is what the challenges teach.

Bug 1: Reentrancy paid for a hard fork

The picoCTF challenge is reentrance. Here is the bug, condensed from the challenge contract:

// VulnBank.sol
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool ok, ) = msg.sender.call{value: amount}(""); // 1. send
require(ok);
balances[msg.sender] -= amount; // 2. update
}

Read it twice. The check passes. The transfer happens. Then the balance is decremented. Here is the part that breaks the mental model of anyone coming from a normal payments stack: in Solidity, sending ETH means calling the recipient. Every account can be a contract, and if the recipient is a contract, its special receive() function (the fallback that runs when ETH lands and no other function was specified) executes synchronously, inside the same transaction, before control returns to the sender. The recipient can do anything in that fallback, including call back into withdraw a second time, while the first call has not yet reached line 6 to update the balance. The check on the second call still passes. The transfer happens again. Repeat until the contract is empty.

The defensive pattern is unfashionably old: checks-effects-interactions, or CEI. Validate inputs (checks). Update internal state (effects). Then, last, talk to the outside world (interactions). One reordering kills the bug. The DAO violated CEI in 2016 and the entire Solidity ecosystem has been writing post-mortems about it since.

The attacker contract is fifteen lines:

// Attacker.sol
contract Attacker {
IVulnBank public target;
uint256 public stake;
constructor(address _t) { target = IVulnBank(_t); }
function attack() external payable {
stake = msg.value;
target.deposit{value: msg.value}();
target.withdraw(msg.value);
}
receive() external payable {
if (address(target).balance >= stake) {
target.withdraw(stake);
}
}
}

Deploy with forge create, fund the contract, call attack(), watch the vault drain. The full pwntools-style script lives on the picoCTF challenge page; the shape above is the whole bug.

The same bug paid for the chain you are using

The DAO was a 2016 venture-fund-as-smart-contract that raised roughly 12% of all ETH then in circulation. The reentered function was not withdraw; it was splitDAO, which internally called withdrawRewardFor, which called recipient.call.value(reward)() before zeroing balances[msg.sender] and decrementing totalSupply. Same primitive, longer call chain. The picoCTF reduction is honest about what it is teaching.

Three things happened next. First, the Ethereum Foundation hard-forked the chain at block 1,920,000 (later documented in EIP-779) and reverted the attacker's balance. Second, a meaningful fraction of the community refused to fork and kept running the original chain, which became Ethereum Classic. Third, the Solidity world spent the next decade re-discovering reentrancy in new wrappers.

Here is what makes this post different from the other ten thousand "Top Solidity Bugs" articles. The exploit calldata is still on chain, decade-old and immutable. Pull the exploit transaction up on etherscan, scroll to the Input Data field, and the first four bytes are the function selector. Decode them with cast:

$ cast 4byte 0x82661dc4
splitDAO(uint256,address)

That is the function the attacker re-entered. The proof of the bug is not in any blog post; it is the first four bytes of an immutable transaction that forty years from now will still be there. Every other hack in this post has the same property. The picoCTF challenges teach the primitive; etherscan keeps the receipt.

Reentrancy was not solved by a fix. It was solved by accepting that no engineer will remember CEI on every external call, and so a guard variable has to do the remembering for them.

Three more receipts that all teach a slightly different shape of the bug:

  • Lendf.Me, April 2020, $25M. The token was imBTC, an ERC-777 wrapper. ERC-777 is a token standard that, unlike the more common ERC-20, fires a tokensReceived callback on every recipient when tokens arrive (think of it as a webhook firing inside the transfer itself). It does this before the protocol that owns the recipient updates its accounting. The attacker re-entered between supply() and withdraw() on dForce's lending protocol, inflating recorded collateral. dForce post-mortem here. (The attacker leaked their IP via an iMessage push notification two days later and returned essentially all the money. I am not making that up.)
  • Cream Finance AMP, August 30, 2021, $18.8M. AMP is also ERC-777-shaped. Cream's borrowFresh sent the loaned token before updating accountBorrows; the attacker re-entered borrow() on a different market mid-transfer. Cream's own post-mortem calls it cross-function reentrancy, which is the reentrancy-no-eth Slither detector. (Side note: a different Cream hack on October 27, 2021 lost $130M; that one was flash-loan oracle manipulation, not reentrancy. The internet will tell you they are the same incident. They are not.)
  • Curve Finance, July 30, 2023, ~$70M (around $52M net after whitehat returns). The most interesting of the four. Curve's pools were written in Vyper (a Python-flavored alternative EVM language; same target bytecode, different source language and compiler) and used the @nonreentrant("lock") decorator to guard against reentrancy. The bug was in the Vyper compiler. Three specific releases (0.2.15, 0.2.16, and 0.3.0) allocated a fresh storage slot per function-occurrence instead of one shared slot per named lock, so two functions tagged with the same key got two independent locks. Single-function reentrancy was blocked. Cross-function reentrancy was wide open. Vyper's post-mortem walks the compiler diff. Pools paired with native ETH (alETH/ETH, msETH/ETH, pETH/ETH, CRV/ETH) were drained; WETH-paired pools were safe.
Key insight: The Curve hack is the clearest answer to why the audit industry exists. Every contract had a guard. Every guard was on the function that needed one. The compiler that turned that source into bytecode produced something that did not, in fact, guard. Reading the Solidity source did not catch it. Reading the deployed storage layout did. Auditors who run the compiled output through Echidna fuzzing instead of squinting at the source caught it years before the hack.

The defense has been the same since 2016. Validate, then update state, then talk to the outside. If you can't convince yourself a function is CEI-clean by reading it, wrap it in OpenZeppelin's nonReentrant modifier and let a single storage variable do the remembering. The current OZ implementation uses a uint256 two-state lock (NOT_ENTERED = 1, ENTERED = 2) instead of the bool you might remember from older code. The reason is per-call gas cost: writing a non-zero value to a storage slot that was already non-zero costs about 5,000 gas, while writing to a slot that was zero costs 20,000. Keeping the lock in the non-zero range pays the lower price every call. Run Slither's reentrancy-eth and reentrancy-no-eth detectors in CI. They would have caught The DAO, Lendf.Me, and Cream AMP cold.

Bug 2: Integer underflow, when the require lies to you

The picoCTF challenge is smart-overflow. Here is the bug:

// IntOverflowBank.sol (pragma solidity ^0.6.12)
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] - amount >= 0); // tautology on uint256
balances[msg.sender] -= amount;
balances[to] += amount;
}

The check looks fine. It is not fine. Both operands are uint256, which means subtraction wraps at zero. With a balance of 0 and an amount of 1, the subtraction underflows to 2^256 - 1, which is a number with 78 digits. The expression 2^256 - 1 >= 0 is, of course, true. The require never trips. The next line silently sets your balance to the same astronomical number. Win condition met.

# Trigger the underflow with one cast send.
cast send $CONTRACT 'transfer(address,uint256)' \
0x0000000000000000000000000000000000000001 1 \
--private-key $KEY --rpc-url $RPC
# Read the new (huge) balance.
cast call $CONTRACT 'balances(address)(uint256)' $(cast wallet address --private-key $KEY) --rpc-url $RPC

Slither's tautology detector flags any >= 0 comparison against an unsigned integer as a vacuous assertion: the comparison is true for every value the type can hold, regardless of what arithmetic produced the LHS. The require above gets caught even though the underflow is the real story. Slither has known about this bug class since 2018. The picoCTF challenge ships pragma solidity ^0.6.12, which predates checked arithmetic by default, which is the language-level fix.

The 2018 wave

The same primitive, scaled up. April 22, 2018: PeckShield disclosed BatchOverflow (CVE-2018-10299) in the Beauty Ecosystem Coin (BEC) ERC-20 contract. The vulnerable line, line 257 of the deployed contract at 0xC5d105…F793d:

uint256 amount = uint256(cnt) * _value;

A multiplication overflow instead of a subtraction underflow, but the same weakness class as the picoCTF challenge: SWC-101 (Smart Contract Weakness Classification entry 101, the Solidity-world cousin of a CVE category). With cnt = 2 and _value = 2^255, the product wraps to zero. The supply check balances[sender] >= amount passed at zero, then the function happily credited each receiver with 2^255 tokens. Exploit transaction 0xad89ff16… minted on the order of 10^58 BEC. Major exchanges halted ERC-20 deposits within hours. PeckShield identified more than a dozen other ERC-20s shipping the same copy-pasted batchTransfer function. Two days later, ProxyOverflow (CVE-2018-10376) showed up in SmartMesh and at least seven other tokens.

And then, on December 16, 2020, Solidity 0.8.0 shipped. Arithmetic became checked by default. Overflow and underflow now revert with Panic(0x11) unless the developer explicitly opts out via an unchecked { … } block. OpenZeppelin's SafeMath was deprecated three months later. The bug class went from shipping in production every quarter to nearly extinct in a single compiler release. Auditors had been begging for this since 2017.

Heads up: The class is not actually extinct. On March 15, 2023, Poolz Finance lost ~$665k to an unchecked block used for gas savings. The summing loop wrapped a user-supplied array into a value that the protocol credited. Pragma 0.8, opt-out, dead. Whenever you see unchecked in a contract you audit, treat it like memcpy in a C codebase. It is fine when the developer is right. It is fine for nine of every ten cases. The tenth is going to be the bug.

For the analogous primitive in C, see the integer-truncation cousin discussed in the Buffer Overflow guide. The trust model is the same: the type system is doing math you didn't check, the check you wrote did not say what you thought it said, and the silence is the bug.

Bug 3: Access control is one address comparison, broken three ways

The picoCTF challenge is access-control. The writeup intentionally does not pin a single bug, because the challenge is the category lecture. The four flavors you might find inside any given contract:

  1. Missing modifier. A function that should be onlyOwner isn't. Anyone calls it.
  2. tx.origin instead of msg.sender. tx.origin is whoever signed the original transaction; msg.sender is the immediate caller. If you check tx.origin == owner, an attacker contract can phish the owner into calling it and the owner's call will pass the check. (See the Cookies and JWTs post for the cousin pattern in web auth.)
  3. Re-callable initializer. An upgradeable contract's initialize() is supposed to run once. If the initializer modifier is missing, broken, or storage-collided with another slot, you call it again and become the new owner.
  4. Public setOwner. Surprisingly common. A function literally named setOwner(address) with no guard, sometimes with a // TODO: add modifier comment that nobody followed up on.

The defense is one OpenZeppelin import and one annotation:

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyContract is Ownable {
constructor() Ownable(msg.sender) {}
function rescue() external onlyOwner { /* ... */ }
}

Parity, twice in four months, never the same way

Two different Parity multi-sig wallet incidents teach the entire access-control category. Quick scaffold first. delegatecall is the Solidity primitive that lets contract A run contract B's code in A's own storage, the way a dynamic loader runs a shared library against your process memory. The wallet contracts in this story are thin proxies: they hold the funds, and delegatecall out to a shared library contract that holds the actual wallet logic.

The first incident, on July 19, 2017, lost $30M. The function initWallet on the shared library was unguarded. Attacker called it via delegatecall from a victim wallet, overwrote the owner array to [attacker], and drained the wallet at leisure. OpenZeppelin's post-mortem has the line-by-line.

The second incident, on November 6, 2017, is the more instructive one because it killed more money, and the mechanism is the most surprising thing in this whole post.

Parity, after the first hack, redeployed a fixed wallet library at 0x863df6…907b4. Every multisig proxy out there pointed at it via delegatecall. They forgot one thing. They forgot to call initWallet on the library itself. The library was sitting there, deployed, with empty owner storage. A user named devops199 wandered by, called initWallet([devops199], 1, …), became the library's sole owner. Then, possibly wondering what the kill() function did, called it. The library executed selfdestruct, an EVM opcode that erases a contract's bytecode and returns its balance to a target address. The library's code was gone. And every multisig wallet on Earth that delegated into the library now found itself calling out to an empty contract, which under delegatecall is a no-op that returns success and changes nothing.

513,774 ETH frozen in 587 wallets. Devops199's GitHub comment, in its entirety, was "I accidentally killed it."

The funds are still frozen. Read the original GitHub issue; it is a perfect document. Parity's post-mortem is the only response a developer can write to that kind of mistake. Slither has two detectors that would have caught the configuration: unprotected-upgrade (an initializer reachable on the implementation contract, not just the proxy) and suicidal (a selfdestruct with insufficient guards). Both detectors existed in 2017 in the form Trail of Bits eventually packaged. Nobody was running them on a wallet library, because nobody thought of the library as code that needed auditing.

The pattern showed up again in 2022. On July 23, Audius lost ~$6M (18.56M AUDIO) to a storage collision in their proxy admin. The proxy stored its admin address at slot 0; OpenZeppelin's Initializable stored its _initialized and _initializing flags at the same slot. The proxy admin's last two bytes happened to be nonzero, which made the implementation believe it was already initialized but still allowed a re-initialization through the contract's logic. Attacker called initialize() on the Governance contract, became guardian, and pushed a malicious proposal that drained the treasury.

Note: tx.origin authentication, despite getting its own SWC entry (SWC-115) and showing up in every Solidity textbook, has no famous mainnet hack. It is a teaching pattern, mostly because the moment you write require(tx.origin == owner) some auditor or compiler warning catches it before you ship. Treat it like the C strcpy rule: the function exists for historical reasons, you should not be calling it, and the lint that catches it is doing real work.

Bug 4: Front-running, the bug class your linter can't catch

The picoCTF challenge is front-running. A contract has an unlock(string) function. A "Victim Bot" submits the correct pre-image with a low gas price. You watch the mempool, extract the pre-image from the pending transaction's calldata, resubmit with a higher gas price, get mined first, get the flag.

# Watch pending transactions for the victim's call.
from web3 import Web3
w3 = Web3(Web3.HTTPProvider('http://HOST:PORT'))
# unlock(string) selector = first 4 bytes of keccak256('unlock(string)')
UNLOCK = bytes.fromhex('a96ce7aa')
for tx_hash in w3.eth.filter('pending').get_new_entries():
tx = w3.eth.get_transaction(tx_hash)
if tx and tx['to'] and tx['input'].startswith(UNLOCK):
# ABI-decoded string lives in the calldata after the selector
print('victim calldata:', tx['input'].hex())

Stop and notice what the contract did wrong. Nothing. The contract is correct. The hash check is correct. The signature is correct. The bug is not in any line of Solidity. The bug is in the assumption that whatever you typed into a transaction is private until it gets mined, and Ethereum was specifically designed to falsify that assumption.

Every transaction broadcast to a node enters the mempool, a public peer-to-peer waiting room visible to every other node. Anyone can read the calldata. Anyone can see your gas price. Validators (post-Merge, since September 2022; miners pre-Merge) order the transactions inside a block however they want, and the dominant strategy is gas-price descending because they collect the fees. Pay more, land first.

This is the threat model Daian, Goldfeder, Kell, Li, Zhao, Bentov, Breidenbach, and Juels formalized in 2019 as MEV (Maximal Extractable Value, originally Miner Extractable Value):

Like high-frequency traders on Wall Street, these bots exploit inefficiencies in DEXes, paying high transaction fees and optimizing network latency to frontrun, i.e., anticipate and exploit, ordinary users' DEX trades.Daian et al., "Flash Boys 2.0", 2019

MEV grew up. The Flashbots REV dashboard (Realized Extractable Value, the subset of MEV actually captured by validators on chain) puts cumulative REV at over $1.5B between the September 2022 Merge and June 2024. The categories are five, in roughly the order of how nasty they are:

  • Front-running. The picoCTF case. See a profitable pending tx, copy it, pay more, land first.
  • Back-running. Submit immediately aftera known pending tx that will move price (a large oracle update, a big swap), capture the resulting arbitrage. Doesn't harm the original tx; it just eats the inefficiency the tx creates.
  • Sandwich. Front-run buy + victim swap + back-run sell. Inflate the victim's execution price, pocket the difference. The most actively-extracted shape on Ethereum; EigenPhi shows thousands per day with the front-run buy and back-run sell tx hashes side by side.
  • JIT (just-in-time) liquidity. A liquidity provider (LP, the actor who deposits tokens into a pool to earn trading fees) mints a tightly-targeted Uniswap v3 position right before a large swap, then burns it right after, capturing nearly all the fees that swap generates.
  • Time-bandit. A validator with sufficient stake re-orgs past blocks to recapture MEV they missed. Rare on PoS Ethereum because the economics are bad. Common in research papers.

What does Slither flag in the front-running contract? Nothing. Mythril traces every reachable path; finds nothing. Echidna fuzzes invariants; finds nothing. The contract is correct.

Key insight: Reentrancy, integer overflow, and access control are intra-contract invariants. The bug is something a static analyzer can prove or a fuzzer can falsify by hammering the contract's own code. Front-running is an inter-transaction ordering bug. The contract's inputs are public for ~12 seconds before they commit, and a profit-maximizing third party gets to interleave their own transactions in between. There is no source-code patch. The fix lives at the protocol-design layer: commit-reveal schemes, batch auctions, encrypted mempools, threshold cryptography. This is why most of Flashbots is research, not tooling.

The canonical defense is commit-reveal. Phase 1: every participant submits commit = keccak256(abi.encodePacked(value, salt, msg.sender)). The contract stores only the hash. Pre-image resistance hides the value. Phase 2, in a later block: participants submit the actual (value, salt); the contract recomputes the hash and verifies the match. EIP-5732 standardizes the commit interface. The classic deployment was the original ENS Vickrey auction registrar, 2017 to 2019. Modern DeFi protocols use it for sealed-bid auctions, governance proposals, and any flow where the input value cannot be public until commitment.

Two practical wrinkles for the picoCTF challenge. w3.eth.filter('pending') works on Geth and Erigon nodes that expose the mempool, which the challenge infra does. It does not work against Infura or most public RPCs, which strip pending-transaction visibility for sound business reasons. And the filter is, per the web3.py docs themselves, "notoriously unreliable" in production; real searcher bots use WebSocket newPendingTransactions subscriptions, often combined with MEV-Boost to bypass the public mempool entirely. MEV-Boost is off-protocol middleware that runs a sealed-bid auction between block-builders and submits the winner to the validator, replacing the public mempool as the primary tx-ordering venue. About 90% of post-Merge Ethereum blocks are built through it (live share tracked at mevboost.pics).

The audit industry is the patch

Three of the four bugs are catchable at audit time. Slither runs in seconds, costs nothing, and ships detectors for every primitive in this post except front-running. Foundry invariant tests run in minutes and catch what Slither misses (the accounting drift that doesn't look wrong line by line). OpenZeppelin's contracts library ships audited primitives for the defenses. None of this is exotic. None of it is new. And the same bugs keep shipping anyway.

The minimum grep list before any contract leaves your laptop:

BugSlither detectorDefensepicoCTF receiptMainnet receipt
Reentrancyreentrancy-eth, reentrancy-no-ethCEI; OpenZeppelin nonReentrantreentranceDAO 2016, Curve 2023
UnderflowtautologyPragma ^0.8.0; audit every uncheckedsmart-overflowBatchOverflow 2018, Poolz 2023
Access controlunprotected-upgrade, suicidal, tx-originOpenZeppelin Ownable, Initializableaccess-controlParity 2017, Audius 2022
Front-runningnone (not catchable)Commit-reveal; private mempool; batch auctionfront-running$1.5B+ MEV (cumulative)

The audit toolchain is small and free. Foundry is the Rust-based Solidity development kit by Paradigm; its forge builds and tests, its cast talks to live contracts, and its anvil spins up a local fork for exploit replay. Slither is the static analyzer Trail of Bits maintains. Mythril (also from ConsenSys) does symbolic execution of bytecode and finds path-dependent bugs that Slither's pattern-matching misses. Echidna is a property-based fuzzer that hammers an invariant test until it falsifies it.

# Foundry installer (Rust-based, supersedes Hardhat for security work).
curl -L https://foundry.paradigm.xyz | bash && foundryup
# Slither: pip install + run against any Foundry project.
pip install slither-analyzer
slither . --foundry-out-directory out
# Optional: Mythril for symbolic execution, Echidna for property fuzzing.
pip install mythril
brew install echidna # or grab a static binary

Pair it with one Foundry invariant test per privileged value (total supply, sum of balances, sum of borrows). Skipping either leaves bugs uncovered. Slither knows the shape of bad code. Foundry hammers the contract with random call sequences and asks whether the invariant holds at every step. Slither will never catch an automated market maker's (AMM's) accounting drifting after 200 swaps. Foundry will never catch a deprecated tx.origin pattern. Run both.

// test/Vault.invariant.t.sol
contract VaultInvariantTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
targetContract(address(vault));
}
function invariant_totalSupplyConstant() public view {
assertEq(vault.totalSupply(), INITIAL_SUPPLY);
}
}

For the deeper audits, the market exists. A typical DeFi engagement at Trail of Bits, OpenZeppelin, or ConsenSys Diligence runs $25K to $100K; bridges and Layer-1 chains push past $200K. Bug bounties on Immunefi for live DeFi protocols regularly post seven-figure top tiers. The same skill set that gets you through the four picoCTF Solidity challenges is the entry-level skill set for this market. That is not a small thing.

Heads up: The SWC Registry (smartcontractsecurity.github.io/SWC-registry) is the Solidity equivalent of the CVE database, and it stopped getting meaningful updates around 2020. Two newer references replace it: OWASP's SCSVS (Smart Contract Security Verification Standard), and the EEA EthTrust Security Levels. If you cite SWC numbers in a 2026 audit report, expect a junior auditor to ask why you are using the legacy registry.

Where to start

Open a terminal. Install Foundry. Pick one challenge from the picoCTF 2026 Web3 track: reentrance if you have ever heard the word reentrancy and want to know what it actually feels like. smart-overflow if you have written C and want to see what unchecked arithmetic looks like in a language that pretends to be safe. access-control if you have ever audited an onlyOwner modifier somewhere. front-running if you want to see a category of attack that no static analyzer is ever going to catch. The 2022 Solana cousin solfire is there too if you want to see the same threat model on a different chain.

Then, when you have shells from those, pick one real-world hack from the receipts above. Pull its actual exploit transaction off etherscan. Decode the calldata. Find the function signature in the contract's source. Diff what the attacker did against what the contract expected. The whole game is in that diff. I read the Phil Daian DAO post-mortem before I had written a line of Solidity, and it taught me more about how distributed systems fail than the docs did.

The DAO drained $60 million in 2016. Lendf.Me lost $25 million in 2020 (returned, after the attacker leaked their IP). Cream lost $18.8 million on the AMP reentrancy in 2021. Curve gave up around $70 million in 2023 to a Vyper compiler bug. Parity froze 513,774 ETH that has never moved, and never will. The bugs are public. The fixes are public. The losses are public. The picoCTF 2026 track ships you the primitives. There is no excuse to write the next one.

For the cousin patterns: the Python for CTF post for the web3.py and pwntools mental model, the SQL Injection post for the trust-the-input failure mode that drives front-running, the Cookies and JWTs post for the auth analogy on access control, and the Buffer Overflow guide for the integer-truncation cousin in C. If you are starting from zero on picoCTF, the Beginner's Guide gets the rest of the workflow set up.

Pull etherscan up on a second monitor. The exploits are not metaphors. They are right there.