3.8.10 Case-Study Walkthroughs
The first nine subsections of Section 3.8 covered specific vulnerability classes in isolation. This subsection closes the section with four case studies showing how those classes interact in real production code. Each case is framed from the developer's perspective: if I had been writing this code, where would the bug have been most catchable?
These are not the deepest case studies in the book — Section 3.10 walks through the marquee historical exploits (DAO, Parity, bZx, Wormhole, etc.) with full attack reconstructions and root-cause analysis. The four cases here are chosen for different reasons:
- They span the prior subsections, drawing on multiple vulnerability classes at once
- They are not in Section 3.10's planned list, so they extend the book's case coverage without overlap
- They are developer-instructive — the bug is the kind a working developer might plausibly write, not an exotic edge case
- They are recent enough (2021–2023) to demonstrate that mature, audited protocols still get hit by these patterns
Each case follows a tighter template than Section 3.10 will: What happened → The bug → Which 3.8 sections cover the underlying class → What test would have caught it. The point is not to relitigate the incident but to draw connections between the abstract vulnerability classes and the concrete production failures.
Case 1: Compound COMP Distribution Bug (September 2021)
What happened. Compound Labs deployed Proposal 062, an upgrade to the COMP token distribution logic. A few hours after activation, users began accidentally receiving enormous quantities of COMP — far more than the protocol intended to distribute. By the time the issue was identified and stopped, approximately 280,000 COMP (worth about $80M at the time) had been distributed in error. Some recipients returned the tokens voluntarily; others did not.
The bug. The new distribution logic used the wrong comparison operator. Roughly:
// Vulnerable: ">" should have been ">="
if (comptroller.compAccrued(user) > comptroller.compSupplyState(market).index) {
distributeReward(user);
}
The intended logic was "if the user has accrued at least the current reward index, distribute." The deployed logic was "if the user has accrued strictly more." Under specific conditions — which occurred for many users — the off-by-one in the comparison caused the distribution amount to be computed against a stale baseline, producing rewards orders of magnitude larger than intended.
The actual code was more complex (the distribution involved a delta calculation between the user's index and the market's), and the bug was deeper than a simple operator swap — but the conceptual root cause was an arithmetic-and-state-comparison logic error that survived audit.
The classes from Section 3.8 in play.
- 3.8.3 Arithmetic & Precision — the underlying mechanic was a precision/comparison error around index updates and delta calculations. The kind of bug that fuzzing with mismatched index/state values would have found.
- 3.8.4 Access Control Failures — the upgrade process itself was governance-controlled, but the post-deployment behavior wasn't constrained by any rate limit or sanity check on distribution amounts. A simple "no single user can receive more than X% of the daily emission" check would have bounded the damage.
- 3.8.6 Denial of Service (inverse case) — the protocol could not pause distribution while the bug was investigated; the only fix was a governance proposal that took 7 days to enact, during which more COMP was distributed.
What test would have caught it. A property-based test asserting an invariant — "no user can receive more COMP per block than the per-block emission rate divided by the user's market share" — would have flagged the bug immediately. The invariant is straightforward to express; running it against the upgraded code with fuzzed user-share inputs would have produced failure cases within seconds.
function testInvariant_distributionBoundedByEmissionRate(
uint256 userShareBps,
uint256 marketAccrued
) public {
vm.assume(userShareBps <= 10_000);
// Compute expected upper bound
uint256 maxExpectedDistribution = (emissionRate * userShareBps) / 10_000;
uint256 actualDistribution = comptroller.distributeReward(user);
assertLe(actualDistribution, maxExpectedDistribution,
"user received more than their market share allows");
}
The lesson: invariant tests catch operator-direction bugs that unit tests miss. A unit test would verify "user X with conditions Y receives Z." An invariant test verifies "no matter what X and Y are, the result respects bound Z."
Cross-reference: Section 3.4.6 (Invariant Analysis) and Section 4.8 (Fuzzing) cover invariant testing in depth. Section 3.7.5 (Defensive Patterns) covers the pause mechanisms whose absence delayed the fix.
Case 2: Sushi MISO Auction Front-Running (July 2021)
What happened. SushiSwap's MISO auction platform held a token sale for the BitDAO project. Shortly before the sale was to settle, the project's address was modified via an inadvertently public function. The attacker — who turned out to be a white-hat in this case — was able to call commitEth on the sale and have the deposited ETH credited to themselves as the project's beneficiary. The auction's economic model treated whoever was set as the beneficiary as entitled to the raised funds. The amount at risk was approximately $350M.
The bug. The MISO setAuctionWallet function — which set the address that would receive auction proceeds — had no access control. Anyone could call it, change the recipient address to their own, and then wait for the auction to settle and claim the funds.
// Vulnerable: no access control
function setAuctionWallet(address payable _wallet) external {
require(_wallet != address(0), "auction wallet cannot be the zero address");
auctionWallet = _wallet;
emit AuctionWalletUpdated(_wallet);
}
The function was intended to be admin-only. The intended access control was either omitted from the implementation or removed during a refactor. The bug went undetected through audit and went live to a live sale.
The exploit pattern is the cleanest possible illustration of "missing modifier on a privileged function" (3.8.4). The function does exactly what its name says — it sets the auction wallet — and the only thing missing is the constraint on who can call it.
The classes from Section 3.8 in play.
- 3.8.4 Access Control Failures — the canonical "missing modifier on privileged function" pattern. A single
onlyOwnermodifier would have prevented the entire incident. - 3.8.7 Front-running & MEV Exposure — the attack itself was front-runnable in the sense that once the attacker (or anyone) discovered the missing access control, they could rush to exploit it before the sale finished settling.
What test would have caught it. The standard access-control test pattern from Section 3.8.4:
function testAccessControl_setAuctionWalletRejectsAttacker() public {
address attacker = makeAddr("attacker");
vm.prank(attacker);
vm.expectRevert();
auction.setAuctionWallet(payable(attacker));
}
function testAccessControl_setAuctionWalletAcceptsAdmin() public {
vm.prank(admin);
auction.setAuctionWallet(payable(newWallet));
assertEq(auction.auctionWallet(), newWallet);
}
These two tests, applied to every state-changing function in the contract, would have surfaced the missing modifier immediately. The pattern is mechanical:
- Enumerate every external function
- For each, write one positive test (legitimate caller succeeds) and one negative test (other addresses revert)
- If the negative test passes without expectations, the function is unguarded
The MISO incident was fortunate. A white-hat performed the exploit, identified the bug to Sushi, and returned the funds. The same pattern in unfortunate hands would have been a $350M loss.
The lesson: the most basic access control tests are the ones developers most often skip. Verifying "the owner can call this" without verifying "other users cannot call this" is half the test. The negative test is the one that catches missing modifiers.
Cross-reference: Section 3.7.3 (Access & Authorization Patterns) covers the patterns; Section 3.8.4 covers the failure mode.
Case 3: Curve Finance Vyper Reentrancy (July 2023)
What happened. Several Curve Finance pools using specific versions of the Vyper compiler (0.2.15, 0.2.16, and 0.3.0) were exploited in a coordinated attack across multiple pools. The reentrancy guard, intended to prevent recursive calls, was non-functional due to a compiler bug. Total losses were approximately $73M across the alETH/ETH, msETH/ETH, pETH/ETH, and CRV/ETH pools.
The bug. Vyper's @nonreentrant decorator was intended to provide the same protection as OpenZeppelin's ReentrancyGuard in Solidity — a single storage slot tracks whether the contract is mid-execution, and any reentry reverts. In the affected Vyper versions, the decorator's implementation had a subtle bug: the storage slot for the lock could be overwritten by other storage writes, effectively disabling the guard.
The application code was correct. The contracts used @nonreentrant consistently. The bug was entirely in the compiler — the generated bytecode did not correctly implement the lock the source code requested. The reentrancy guard appeared to be in place, would pass Slither's reentrancy-eth detector, and would fail a manual review only if the reviewer happened to look at the bytecode rather than the source.
The attack itself was a classic reentrancy: during a remove_liquidity call, the pool's ETH transfer to the recipient triggered a callback into the recipient's contract, which then called back into the pool's remove_liquidity function. Because the guard was broken, the second call succeeded, and the recursive sequence drained the pool.
The classes from Section 3.8 in play.
- 3.8.2 The Reentrancy Family — direct reentrancy in classic form. The defense (a reentrancy guard) was present in source but broken in bytecode.
- 3.8.1 Solidity Language Pitfalls — though the bug was in Vyper, not Solidity, the conceptual issue is the same: language-level features that developers trust to "just work" can fail in ways the source code doesn't reveal. Developers using high-level abstractions must understand what those abstractions compile to.
What test would have caught it. This is the hardest case in the section — the application code was correct. No test of the application contract alone would have caught the bug; the test would have used the same broken @nonreentrant decorator and would have shown the guard "working" against a reentrant attacker.
The test that would have caught it is a bytecode-level property test, something like:
# Pseudo-code: verify that @nonreentrant compiles to actual storage-slot lock
def test_nonreentrant_decorator_produces_storage_lock():
bytecode = compile_vyper_contract(test_contract_source)
storage_writes = extract_sstore_operations(bytecode)
# Find the SSTORE for the lock slot before any external call
lock_slot = compute_nonreentrant_slot()
pre_call_writes = filter_writes_before_call_opcodes(storage_writes)
assert lock_slot in [w.slot for w in pre_call_writes], \
"nonreentrant guard did not produce storage write before external call"
This kind of compiler-level test is not realistic to expect every project to write. The practical lesson is different:
- Pin compiler versions. The affected Vyper versions were specific (0.2.15, 0.2.16, 0.3.0); pools using other versions were not affected. Pin and review compiler upgrades explicitly.
- Subscribe to compiler security advisories. Vyper's bug was known to the Vyper team months before the exploit; the fix shipped in Vyper 0.3.1. Curve's affected pools were deployed with old compiler versions and never upgraded.
- Verify deployed bytecode against expected behavior using formal methods where the stakes warrant. Section 4.9 covers formal verification; for high-TVL pools, the cost of formal verification of reentrancy properties at the bytecode level is justifiable.
The lesson: trusting your compiler is a real assumption that should be examined. For most contracts, the assumption is safe — Solidity and Vyper are battle-tested. For contracts holding tens of millions in TVL, the assumption deserves explicit verification.
Cross-reference: Section 3.8.2 covers reentrancy; Section 3.4.7 and Section 4.9 cover formal verification approaches that catch compiler-level issues.
Case 4: Wintermute Profanity Vanity-Address Compromise (September 2022)
What happened. Wintermute, a major crypto market-maker, lost approximately $160M from its DeFi vault when an attacker compromised the private key of a "vanity address" — an EOA whose address starts with a recognizable prefix (in this case, leading zeros for gas efficiency). The vanity address had been generated using a tool called Profanity, which had a known weakness in its random number generation.
The bug. The Profanity tool generated private keys using a deterministic process that started from a 32-bit seed and iterated to find addresses matching the desired pattern. With only 2^32 possible seeds, the entire space of keys generated by Profanity was brute-forceable — an attacker willing to spend the compute could reproduce any Profanity-generated private key. The attacker did exactly that for Wintermute's vault's signer.
The Wintermute vault itself was a Gnosis Safe multi-sig — a well-audited, battle-tested contract. The attack did not exploit a smart contract bug at all. The attack exploited the fact that one of the Safe's signing keys had been generated with a flawed key-generation tool, making the private key recoverable.
Once the attacker had the private key, they signed a transaction that transferred the vault's contents. The Safe correctly verified the signature; the signature was valid; the transaction executed. Every layer of the smart contract worked exactly as designed. The compromise was at the cryptographic-key level, several layers below the contract.
The classes from Section 3.8 in play.
- 3.8.4 Access Control Failures — though no code-level bug existed, the outcome is the same as if access control had been bypassed: an unauthorized actor performed a privileged operation. The failure mode is in the same category even though the mechanism is different.
- 3.8.8 Signature & Replay Issues — the attack ultimately came down to a signature being accepted from a compromised key. No signature-mechanics bug existed; the key itself was compromised. This is a useful reminder that signature verification is only as strong as the keys.
What test would have caught it. No on-chain test could have caught this — the contract worked correctly. The defenses are operational rather than code-level:
- Do not use vanity-address generators with known cryptographic weaknesses. Profanity's flaw was public before the Wintermute incident; the tool had been deprecated by its maintainer for security reasons. Wintermute used it anyway.
- Distribute signing power across multiple addresses with independent key generation. A 2-of-3 Safe with three independently-generated keys (e.g., one hardware wallet, one paper wallet, one institutional custody) would have required compromising at least two independent key generation paths.
- Rate-limit large withdrawals. Section 3.7.5 covers rate limits as defensive patterns. A vault with a per-hour outflow limit would have lost $5M instead of $160M before the compromise was detected.
- Monitor for unusual transactions in real time. A monitoring bot that flagged "vault is transferring all assets at once" would have allowed a faster response — though in this case the loss completed in a single block.
The lesson: smart contract security depends on cryptographic assumptions that the contract cannot verify. No amount of on-chain validation can detect a weak private key. Operational security around key generation, storage, and use is part of the security perimeter — and is often the weakest part, since contract-level testing rarely exercises it.
Cross-reference: Section 3.7.5 (Defensive Patterns) covers rate limits and circuit breakers; Section 2.5 (User Authentication and Access Control) covers key management at the broader operational level.
Synthesis: What These Cases Have in Common
Four cases, four very different incidents. The patterns they share are instructive:
1. The bug was simple; the consequences were enormous. None of these failures required sophisticated attacks. A wrong comparison operator, a missing modifier, a compiler version mismatch, a known-broken key generator. The pre-existing technical knowledge to prevent each of them was widely available.
2. Each failure crossed a system boundary. Compound's bug was in arithmetic + governance; Sushi's was in code + deployment; Curve's was in compiler + contract; Wintermute's was in key generation + on-chain. None of these failures lived purely within "the contract code." They all involved the seam between layers of the system, where each layer assumed the next was correct.
3. Audits did not catch them. Compound's contracts had been audited. The MISO auction had been audited. The Curve pools had been audited. Wintermute used audited contracts. Audits catch many bugs; these specific bugs were missed by experienced reviewers. Audits are necessary but insufficient.
4. The defenses were operational as often as they were code-level. The Compound bug needed an invariant test that didn't exist; the Sushi bug needed a negative access-control test that didn't exist; the Curve bug needed compiler-version discipline; the Wintermute bug needed key-generation discipline. The development practices around the code matter as much as the code.
5. The catastrophic case for each was already known. Operator-direction bugs in arithmetic were documented before Compound. Missing access control was documented before MISO. Vyper had published the reentrancy fix before the Curve exploit. Profanity's key generation had been flagged before Wintermute. The information was available; the discipline to apply it was not.
The takeaway for a working developer: most production exploits are not novel cryptographic breakthroughs. They are well-understood bug classes applied to systems whose authors did not apply the known defenses. The work of writing secure smart contracts is largely the work of applying defenses that already exist, consistently and completely, across the entire codebase.
Cross-References
- Section 3.7 — Patterns covers the constructive defenses for each vulnerability class
- Section 3.8.1–3.8.9 — Each vulnerability class has its own dedicated treatment in the prior subsections
- Section 3.10 — Past Exploits covers the marquee historical incidents (DAO, Parity, bZx, Poly Network, Ronin, Nomad, Wormhole, Euler) in full case-study form
- Section 3.4 — Testing approaches: unit tests, integration tests, invariant tests, formal verification
- Section 3.7.5 — Defensive patterns that contain damage even when vulnerabilities exist
- Section 2.9 — Incident response, which becomes the relevant chapter when these cases happen in your protocol
Closing the Section
Section 3.8 catalogs vulnerabilities. The list is long; the consequences are real; the defenses are well-understood. None of this material is new to the security community, but each new contract is a fresh opportunity to apply or skip these defenses.
The most valuable habit a developer can build is reading their own code with an attacker's mindset. For every function, ask: who can call this? what state does it depend on? what happens if the external call reverts? what happens if the parameters are at edge values? what happens if the same transaction calls this twice? These questions, applied consistently, surface most of the bugs in this section before they reach an audit, let alone production.
The next section (3.9) covers the audit process from the developer's perspective — what to do before, during, and after an external audit. Section 3.10 walks through the historical case studies in depth. Section 3.11 covers advanced contract security (MEV, flash loans, governance, L2 considerations). Section 3.12 closes Book 3 with emerging trends.