Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 2 β€” Module 8: DeFi Security

Difficulty: Advanced

Estimated reading time: ~45 minutes | Exercises: ~4-5 hours


πŸ“š Table of Contents

DeFi-Specific Attack Patterns

Invariant Testing with Foundry

Reading Audit Reports

Security Tooling & Audit Preparation


πŸ“‹ Quick Reference: Fundamentals You Already Know

DeFi protocols lost over $3.1 billion in the first half of 2025 alone. Roughly 70% of major exploits in 2024 hit contracts that had been professionally audited. The OWASP Smart Contract Top 10 (2025 edition) ranks access control as the #1 vulnerability for the second year running, followed by reentrancy, logic errors, and oracle manipulation β€” all patterns you’ve encountered throughout Part 2.

This module focuses on the DeFi-specific attack patterns and defense methodologies that go beyond general Solidity security. You already know CEI, reentrancy guards, and access control. Here we cover: read-only reentrancy in multi-protocol contexts, the full oracle/flash-loan manipulation taxonomy, invariant testing as the primary DeFi bug-finding tool, how to read audit reports, and security tooling for protocol builders.

These patterns should be second nature. This box is a refresher, not a learning section.

Checks-Effects-Interactions (CEI): Validate β†’ update state β†’ make external calls. The base defense against reentrancy.

Reentrancy guards: nonReentrant modifier (OpenZeppelin or transient storage variant from Part 1). Apply to all state-changing external functions. For cross-contract reentrancy, consider a shared lock.

Access control: OpenZeppelin AccessControl (role-based) or Ownable2Step (two-step transfer). Timelock all admin operations. Use initializer modifier on upgradeable contracts. Multisig threshold should scale with TVL.

Input validation: Validate every parameter of every external/public function. Never pass user-supplied addresses to call() or delegatecall() without validation. Check for zero addresses, zero amounts.

If any of these feel unfamiliar, review Part 1 and the OpenZeppelin documentation before proceeding.


⚠️ DeFi-Specific Attack Patterns

⚠️ Read-Only Reentrancy

The most subtle reentrancy variant. No state modification needed β€” just reading at the wrong time.

The pattern: A contract’s view function reads state that is inconsistent during another contract’s external call. A lending protocol reading a pool’s getRate() during a join/exit operation gets a manipulated price because the pool has transferred tokens but hasn’t updated its accounting yet.

// Balancer pool during join (simplified):
function joinPool() external {
    // 1. Transfer tokens from user to pool
    token.transferFrom(msg.sender, address(this), amount);
    // 2. External callback (e.g., for hooks or nested calls)
    // At this point, pool has more tokens but hasn't minted BPT yet
    // getRate() returns an inflated rate
    // 3. Mint BPT to user
    _mint(msg.sender, shares);
    // 4. Update internal accounting
}

If a lending protocol calls pool.getRate() during step 2, it gets an inflated price. The attacker deposits the overpriced BPT as collateral and borrows against it.

Real-world impact: Multiple protocols have been hit by read-only reentrancy through Balancer and Curve pool interactions. The Sentiment protocol lost ~$1M in April 2023 to exactly this pattern. See also the Balancer read-only reentrancy advisory.

πŸ” Deep Dive: Read-Only Reentrancy β€” Numeric Walkthrough

Let’s trace exactly how the Sentiment/Balancer exploit works with concrete numbers.

Setup:

  • Balancer pool: 1,000 WETH + 1,000,000 USDC (BPT total supply: 10,000)
  • getRate() = totalPoolValue / BPT supply = ($2M + $1M) / 10,000 = $300 per BPT
  • Lending protocol accepts BPT as collateral, reads pool.getRate() for valuation
  • Attacker holds 100 BPT (worth $30,000 at fair rate)
Step 1: Attacker calls joinPool() to add 500 ETH ($1M) to the Balancer pool
────────────────────────────────────────────────────────────────────────────

  Inside joinPool():
    β‘  Pool receives 500 ETH from attacker via transferFrom
       Pool balances now: 1,500 ETH + 1,000,000 USDC
       BUT BPT not yet minted β€” still 10,000 BPT outstanding

    β‘‘ Pool makes an external callback (e.g., ETH receive hook, or nested call)

    ─── DURING THE CALLBACK (between β‘  and β‘’) ───────────────────────

       Pool state is INCONSISTENT:
         Real pool value: (1,500 Γ— $2,000) + $1,000,000 = $4,000,000
         BPT supply: 10,000 (unchanged β€” new BPT not minted yet!)

         getRate() = $4,000,000 / 10,000 = $400 per BPT  ← inflated 33%!

       The attacker's callback:
         β†’ Deposit 100 BPT into lending protocol as collateral
         β†’ Lending protocol reads getRate() β†’ sees $400/BPT
         β†’ Collateral valued at: 100 Γ— $400 = $40,000

         At 150% collateralization, attacker borrows: $40,000 / 1.5 = $26,667
         Fair value of 100 BPT: 100 Γ— $300 = $30,000
         Fair borrowing capacity: $30,000 / 1.5 = $20,000

         Excess borrowed: $26,667 - $20,000 = $6,667 stolen

    ───────────────────────────────────────────────────────────────────

    β‘’ Pool mints new BPT to attacker β€” getRate() returns to normal
       BPT minted β‰ˆ 10,000 Γ— (√1.5 - 1) β‰ˆ 2,247  (single-sided join penalty)
       New BPT supply β‰ˆ 12,247 β†’ getRate() β‰ˆ $4M / 12,247 β‰ˆ $327
       (Higher than $300 because single-sided join adds value unevenly)

Step 2: Attacker walks away with $6,667 excess borrow
──────────────────────────────────────────────────────
  The 100 BPT collateral is worth $30,000 at fair price
  but backs $26,667 in debt β€” protocol is under-collateralized.
  If BPT price dips even slightly, the position becomes bad debt.

  Scale this up 100Γ—: 10,000 BPT + larger join β†’ $666,700 stolen.
  That's how Sentiment lost ~$1M.

Why nonReentrant on the lending protocol doesn’t help: The lending protocol’s deposit() isn’t being reentered β€” it’s called for the first time during the callback. It’s the Balancer pool that’s in a reentrant state. The lending protocol is just an innocent bystander reading a corrupted view function.

The fix: Before reading getRate(), verify the pool isn’t mid-transaction:

// Call a state-modifying function on Balancer Vault that reverts if locked
// manageUserBalance with empty array is a no-op but checks the reentrancy lock
IVault(balancerVault).manageUserBalance(new IVault.UserBalanceOp[](0));
// If we reach here, the vault isn't in a reentrant state β€” safe to read
uint256 rate = pool.getRate();

Defense:

  • Never trust external view functions during your own state transitions
  • Check reentrancy locks on external protocols before reading their rates (Balancer V2 Vault pools have a getPoolTokens that reverts if the vault is in a reentrancy state β€” use it)
  • Use time-delayed or externally-sourced rates instead of live pool calculations

⚠️ Cross-Contract Reentrancy in DeFi Compositions

When your protocol interacts with multiple external protocols, reentrancy can occur across trust boundaries:

Your Protocol β†’ Aave (supply) β†’ aToken callback β†’ Your Protocol (read stale state)
Your Protocol β†’ Uniswap (swap) β†’ token transfer β†’ receiver fallback β†’ Your Protocol

Defense: Apply nonReentrant globally (not per-function) when your protocol makes external calls that could trigger callbacks. For protocols that interact with many external contracts, a single transient storage lock covering all entry points is the cleanest approach.

πŸ“‹ Price Manipulation Taxonomy

This consolidates oracle attacks from Module 3 with flash loan amplification from Module 5:

Category 1: Spot price manipulation via flash loan

  • Borrow β†’ swap on DEX β†’ manipulate price β†’ exploit protocol reading spot price β†’ swap back β†’ repay
  • Cost: gas only (flash loan is free if profitable)
  • Defense: never use DEX spot prices, use Chainlink or TWAP
  • Real example: Polter Finance (2024) β€” flash-loaned BOO tokens, drained SpookySwap pools, deposited minimal BOO valued at $1.37 trillion

Category 2: TWAP manipulation

  • Sustain price manipulation across the TWAP window
  • Cost: capital Γ— time (expensive for deep-liquidity pools with long windows)
  • Defense: minimum 30-minute window, use deep-liquidity pools, multi-oracle

Category 3: Donation/balance manipulation

  • Transfer tokens directly to a contract to inflate balanceOf-based calculations
  • Affects: vault share prices (Module 7 inflation attack), reward calculations, any logic using balanceOf
  • Defense: internal accounting, virtual shares/assets

Category 4: ERC-4626 exchange rate manipulation

  • Inflate vault token exchange rate, use overvalued vault tokens as collateral
  • Venus Protocol lost 86 WETH in February 2025 to exactly this attack
  • Resupply protocol exploited via the same vector in 2025
  • Defense: time-weighted exchange rates, external oracles for vault tokens, rate caps, virtual shares

Category 5: Governance manipulation via flash loan

  • Flash-borrow governance tokens, vote on malicious proposal, return tokens
  • Defense: snapshot-based voting (power based on past block), timelocks, quorum requirements
  • Most modern governance (OpenZeppelin Governor, Compound Governor Bravo) already uses snapshot voting

πŸ” Deep Dive: Flash Loan Attack P&L Walkthrough

Scenario: A lending protocol uses Uniswap V2 spot prices for collateral valuation. An attacker exploits this with a flash loan.

Setup:

  • Uniswap V2 ETH/USDC pool: 1,000 ETH + 2,000,000 USDC (spot price = $2,000/ETH)
  • Lending protocol: 500,000 USDC available to borrow, requires 150% collateralization
  • Attacker starts with: 0 capital (uses flash loan)

The key insight: The attacker needs to inflate the ETH price on Uniswap, so they buy ETH with USDC. Flash-borrowing USDC and swapping it into the pool pushes the ETH/USDC ratio up.

Step 1: Flash borrow 1,500,000 USDC from Balancer (0 fee)
─────────────────────────────────────────────────────────
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Attacker: 1,500,000 USDC (borrowed)             β”‚
  β”‚  Cost so far: 0 (flash loan is free if repaid)   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 2: Swap 1,500,000 USDC β†’ ETH on Uniswap V2
─────────────────────────────────────────────────
  Pool before: 1,000 ETH / 2,000,000 USDC  (k = 2,000,000,000)
  New USDC in pool: 2,000,000 + 1,500,000 = 3,500,000
  New ETH in pool:  2,000,000,000 / 3,500,000 = 571 ETH  (k preserved)
  ETH received: 1,000 - 571 = 429 ETH
  New spot price: 3,500,000 / 571 = $6,130/ETH  ← inflated 3Γ—!

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Attacker: 429 ETH                               β”‚
  β”‚  Uniswap spot: $6,130/ETH (was $2,000)           β”‚
  β”‚  Real market price: still ~$2,000/ETH             β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 3: Deposit 100 ETH as collateral into lending protocol
───────────────────────────────────────────────────────────
  Protocol reads Uniswap spot: 100 Γ— $6,130 = $613,000 collateral value
  At 150% collateralization: can borrow up to $613,000 / 1.5 = $408,667
  Attacker borrows: 400,000 USDC

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Attacker: 329 ETH + 400,000 USDC                β”‚
  β”‚  Lending position: 100 ETH collateral / 400k debtβ”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: Swap 329 ETH β†’ USDC on Uniswap (reverse the manipulation)
──────────────────────────────────────────────────────────────────
  Pool before: 571 ETH / 3,500,000 USDC
  New ETH in pool: 571 + 329 = 900
  New USDC in pool: 2,000,000,000 / 900 = 2,222,222
  USDC received: 3,500,000 - 2,222,222 = 1,277,778 USDC

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Attacker: 400,000 + 1,277,778 = 1,677,778 USDC β”‚
  β”‚  Uniswap spot recovering toward ~$2,222/ETH      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 5: Repay flash loan: 1,500,000 USDC
────────────────────────────────────────

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  ATTACKER P&L:                                    β”‚
  β”‚  USDC in hand: 1,677,778                         β”‚
  β”‚  Flash loan repay: -1,500,000                    β”‚
  β”‚  Net profit: +177,778 USDC                       β”‚
  β”‚                                                   β”‚
  β”‚  Plus: 100 ETH locked as collateral, 400k debt   β”‚
  β”‚  Attacker walks away β€” never repays the loan.    β”‚
  β”‚  After price normalizes: 100 ETH = $200,000      β”‚
  β”‚  but debt = $400,000 β†’ protocol has $200k bad debtβ”‚
  β”‚                                                   β”‚
  β”‚  Total value extracted: ~$178k (kept) + ~$200k   β”‚
  β”‚  (bad debt absorbed by protocol/depositors)       β”‚
  β”‚  Attacker cost: gas only                          β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why this works: The lending protocol trusts Uniswap’s instantaneous spot price as the truth. But spot price is just the ratio of reserves β€” trivially manipulable with enough capital. The attacker has unlimited capital via flash loans. The entire attack β€” borrow, swap, deposit, borrow, swap back, repay β€” executes atomically in a single transaction.

Why Chainlink prevents this: Chainlink prices come from off-chain aggregation of multiple exchanges. A swap on one Uniswap pool doesn’t affect the Chainlink price. Even TWAP oracles resist this because the manipulation must be sustained across the averaging window (expensive for deep-liquidity pools).

⚠️ Frontrunning and MEV

Sandwich attacks: Attacker sees your pending swap in the mempool. They front-run (buy before you, pushing price up), your swap executes at the worse price, they back-run (sell after you, profiting from the difference).

Defense: slippage protection (amountOutMin in Uniswap swaps), private transaction submission (Flashbots Protect, MEV Blocker), deadline parameters.

Just-In-Time (JIT) liquidity: Specific to concentrated liquidity AMMs. An attacker adds concentrated liquidity right before a large swap (capturing fees) and removes it right after. Not a vulnerability per se, but reduces fees going to passive LPs.

Liquidation MEV: When a position becomes liquidatable, MEV searchers race to execute the liquidation (and capture the bonus). For protocol builders: ensure your liquidation mechanism is MEV-aware and that the bonus isn’t so large it incentivizes price manipulation to trigger liquidations.

⚠️ Precision Loss and Rounding Exploits

Integer division in Solidity always truncates (rounds toward zero). In DeFi, this creates two distinct classes of vulnerability:

Class 1: Silent reward loss (truncation to zero)

When a reward pool distributes rewards proportionally, the accumulator update divides reward by total staked:

// VULNERABLE: unscaled accumulator
rewardPerTokenStored += rewardAmount / totalStaked;
// If totalStaked = 1000e18 and rewardAmount = 100 wei:
//   100 / 1000e18 = 0  ← TRUNCATED! Rewards lost forever.

This isn’t a one-time bug β€” it compounds. Every small reward distribution that truncates is value permanently stuck in the contract. Over time, this can represent significant losses, especially for tokens with small decimal precision or high-value-per-unit tokens.

The fix β€” scale before dividing:

// SAFE: scaled accumulator (Synthetix StakingRewards pattern)
uint256 constant PRECISION = 1e18;
rewardPerTokenStored += rewardAmount * PRECISION / totalStaked;
// Example: 10_000 * 1e18 / 5000e18 = 1e22 / 5e21 = 2  (preserved, not truncated!)

// When calculating earned:
earned = staked[account] * (rewardPerToken - paid) / PRECISION;
// Example: 5000e18 * 2 / 1e18 = 10_000  (full reward recovered)

This is the standard pattern used by Synthetix StakingRewards, Convex, and virtually every production reward distribution contract.

Class 2: Rounding direction exploits

In share-based systems (vaults, lending), rounding direction matters:

  • Deposits: round shares DOWN (give user fewer shares β†’ protects vault)
  • Withdrawals: round assets DOWN (give user fewer tokens β†’ protects vault)

If rounding favors the user in either direction, they can extract value through repeated small operations:

Deposit 1 wei β†’ receive 1 share (should be 0.7, rounded UP to 1)
Withdraw 1 share β†’ receive 1 token (should be 0.7, rounded UP to 1)
Repeat 1000 times β†’ extract ~300 wei from vault

At scale (or with low-decimal tokens like USDC with 6 decimals), this becomes significant.

The fix β€” always round against the user:

// ERC-4626 standard: deposit rounds shares DOWN, withdraw rounds assets DOWN
function convertToShares(uint256 assets) public view returns (uint256) {
    return assets * totalSupply() / totalAssets(); // rounds down (fewer shares)
}

function convertToAssets(uint256 shares) public view returns (uint256) {
    return shares * totalAssets() / totalSupply(); // rounds down (fewer assets)
}

For mulDiv with explicit rounding: use OpenZeppelin’s Math.mulDiv(a, b, c, Math.Rounding.Ceil) when rounding should favor the protocol.

Where precision loss appears in DeFi:

Protocol TypeWhere Truncation HitsImpact
Reward poolsreward / totalStaked accumulatorRewards silently lost
Vaults (ERC-4626)Share/asset conversionsValue extraction via repeated small ops
Lending (Aave, Compound)Interest index updatesInterest can be rounded away for small positions
AMMsFee collection and distributionLP fees lost to rounding
CDPs (MakerDAO)art * rate debt calculationDust debt that can’t be fully repaid

Real-world examples:

  • Multiple vault protocols have had audit findings for incorrect rounding direction in deposit()/mint()/withdraw()/redeem()
  • Aave V3 uses WadRayMath (1e27 scale factor) specifically to minimize precision loss in interest calculations
  • MakerDAO’s Vat tracks debt as art * rate (both in RAY = 1e27) to preserve precision across stability fee accruals

⚠️ Access Control Vulnerabilities

Access control is the #1 vulnerability in the OWASP Smart Contract Top 10 (2024 and 2025). It’s devastatingly simple and the most common cause of total fund loss in DeFi.

Pattern 1: Missing initializer guard (upgradeable contracts)

Upgradeable contracts use initialize() instead of constructor() (constructors don’t run in proxy context). If initialize() can be called more than once, anyone can re-initialize and claim ownership:

// VULNERABLE: no initialization guard
function initialize(address owner_) external {
    owner = owner_;  // Can be called repeatedly β€” attacker overwrites owner
}
// SAFE: OpenZeppelin Initializable pattern
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

function initialize(address owner_) external initializer {
    owner = owner_;  // initializer modifier ensures this runs only once
}

Critical subtlety: Even with initializer, the implementation contract itself (not the proxy) can be initialized by anyone if you don’t call _disableInitializers() in the constructor. This is a common pattern found in multiple audits β€” an attacker calls initialize() directly on the implementation contract (bypassing the proxy), becomes the owner of the implementation, and then uses selfdestruct or other privileged functions to compromise the system. The Parity wallet freeze ($150M, 2017) is the most famous example: an unprotected initWallet() on the library contract allowed an attacker to take ownership and self-destruct it, permanently freezing all dependent wallets.

// PRODUCTION PATTERN: disable initializers on implementation
constructor() {
    _disableInitializers();  // Prevents anyone from initializing the implementation
}

Pattern 2: Unprotected critical functions

Functions that move funds, change parameters, or pause the protocol must have access control. The pattern is simple, but forgetting it on even one function is catastrophic:

// VULNERABLE: anyone can drain the vault
function emergencyWithdraw() external {
    token.transfer(owner, token.balanceOf(address(this)));
}

// SAFE: owner-only access
function emergencyWithdraw() external {
    require(msg.sender == owner, "not owner");
    token.transfer(owner, token.balanceOf(address(this)));
}

For protocols with multiple roles (admin, guardian, strategist), use OpenZeppelin’s AccessControl with named roles instead of simple owner checks.

Pattern 3: Missing function visibility

In older Solidity versions (< 0.8.0), functions without explicit visibility defaulted to public. Modern Solidity requires explicit visibility, but the lesson still applies: always review that internal helper functions aren’t accidentally external or public.

The OWASP Smart Contract Top 10 access control patterns:

  1. Unprotected initialize() β€” re-initialization overwrites owner
  2. Missing onlyOwner / role checks on critical functions
  3. tx.origin used for authentication (phishable via intermediate contract)
  4. Incorrect role assignment in constructor/initializer
  5. Missing two-step ownership transfer (single-step transfer to wrong address = permanent lockout)

Defense checklist:

  • Every initialize() uses initializer modifier (OpenZeppelin Initializable)
  • Implementation contracts call _disableInitializers() in constructor
  • Every fund-moving function has appropriate access control
  • Ownership transfer uses two-step pattern (Ownable2Step)
  • Never use tx.origin for authentication
  • All roles assigned correctly in initializer, verified in tests

⚠️ Composability Risk

DeFi’s composability means your protocol interacts with others in ways you can’t fully predict:

  • Your vault accepts aTokens as collateral β†’ aTokens interact with Aave β†’ Aave interacts with Chainlink β†’ Chainlink relies on external data providers
  • A flash loan from Balancer funds an operation on your protocol that calls a Curve pool that triggers a reentrancy via a Vyper callback

Defense:

  • Document every external dependency and its assumptions
  • Consider what happens if any dependency fails, returns unexpected values, or is malicious
  • Use interface types (not concrete contracts) and validate return values
  • Implement circuit breakers that pause the protocol if unexpected conditions are detected

🎯 Build Exercise: Security Exploits and Defenses

Workspace: workspace/src/part2/module8/exercise1-reentrancy/ β€” starter files: ReentrancyAttack.sol, DefendedLending.sol, tests: ReadOnlyReentrancy.t.sol

Exercise 1: Read-only reentrancy exploit. Build a mock vault whose getSharePrice() returns an inflated value during a deposit() that makes an external callback. Build a lending protocol that reads this value. Show how an attacker can deposit during the callback to get overvalued collateral. Fix it by checking the vault’s reentrancy state.

Workspace: workspace/src/part2/module8/exercise2-oracle/ β€” starter files: OracleAttack.sol, SecureLending.sol, tests: OracleManipulation.t.sol

Exercise 2: Oracle manipulation exploit. Build a vulnerable lending protocol that reads AMM spot prices. Execute a flash loan attack: flash-borrow tokens, swap on the AMM to manipulate the price, deposit collateral into the lending protocol (now overvalued), borrow against the inflated collateral, swap back to restore the price, and repay the flash loan keeping the profit. Then fix the lending protocol to use Chainlink and verify the attack fails.

Workspace: workspace/src/part2/module8/exercise3-invariant/ β€” starter files: BuggyVault.sol, VaultHandler.sol, tests: VaultInvariant.t.sol

Exercise 3: Invariant testing. Write a handler contract and invariant tests for BuggyVault β€” a share-based vault with a subtle ordering bug in withdraw(). Implement the handler’s deposit() and withdraw() with actor management and ghost variable tracking, then write solvency and fairness invariants that automatically find the bug through random call sequences. (See the Invariant Testing section below for full details.)

Workspace: workspace/src/part2/module8/exercise4-precision-loss/ β€” starter files: RoundingExploit.sol, DefendedRewardPool.sol, tests: PrecisionLoss.t.sol

Exercise 4: Precision loss exploit. A reward pool distributes tokens proportionally, but uses an unscaled accumulator (reward / totalStaked). When totalStaked is large, rewards truncate to zero. Exploit this by staking a tiny amount (1 wei) when you’re the only staker to capture 100% of rewards. Then fix the pool using the Synthetix scaled-accumulator pattern (reward * 1e18 / totalStaked).

Workspace: workspace/src/part2/module8/exercise5-access-control/ β€” starter files: AccessControlAttack.sol, DefendedVault.sol, tests: AccessControl.t.sol

Exercise 5: Access control exploit. A vault has two bugs: initialize() can be re-called to overwrite the owner, and emergencyWithdraw() has no access control. Exploit both to drain user funds in a single transaction. Then build a defended version with initialization guards and proper owner checks.

πŸ’Ό Job Market Context

What DeFi teams expect you to know about attack patterns:

  1. β€œWalk me through a read-only reentrancy attack.”

    • Good answer: Explains that a view function reads inconsistent state during an external call’s callback
    • Great answer: Gives the Balancer BPT / Sentiment example β€” pool has received tokens but hasn’t minted BPT yet, so getRate() is inflated. Mentions that the defense is checking the vault’s reentrancy lock before reading the rate, and that this class of bug is extremely common in DeFi compositions
  2. β€œHow would you prevent price manipulation in a lending protocol?”

    • Good answer: Use Chainlink instead of spot prices, add staleness checks
    • Great answer: Describes the full taxonomy β€” spot manipulation (flash loan + swap), TWAP manipulation (capital Γ— time), donation attacks (balanceOf inflation), ERC-4626 exchange rate attacks. Explains that defense is layered: primary oracle + TWAP fallback + rate caps + circuit breakers. Mentions that even β€œsafe” oracles like Chainlink need staleness checks, L2 sequencer checks, and zero-price validation
  3. β€œWhat’s the most underestimated attack vector in DeFi right now?”

    • Strong answer: Composability risk / cross-protocol interactions. Any time your protocol reads state from another protocol, you inherit their entire attack surface. Read-only reentrancy is one example, but there’s also governance manipulation, oracle dependency chains, and the risk of external protocol upgrades changing behavior. The defense is documenting every external dependency and its failure modes

Interview red flags:

  • ❌ Only knowing about classic reentrancy (state-modifying) but not read-only reentrancy
  • ❌ Saying β€œjust use Chainlink” without mentioning staleness checks, L2 sequencer, or multi-oracle patterns
  • ❌ Not knowing about flash-loan-amplified attacks (thinking flash loans are just for arbitrage)

Pro tip: In security-focused interviews, employers care less about memorizing every exploit and more about your systematic thinking. Show that you have a mental taxonomy of attack classes and can map any new vulnerability into it. That’s what separates a protocol engineer from a developer.

πŸ“‹ Summary: DeFi-Specific Attack Patterns

βœ“ Covered:

  • Read-only reentrancy β€” the subtle variant where view functions read inconsistent state during callbacks
  • Cross-contract reentrancy β€” trust boundary violations across multi-protocol compositions
  • Price manipulation taxonomy β€” 5 categories from spot manipulation to governance attacks
  • Frontrunning / MEV β€” sandwich attacks, JIT liquidity, liquidation MEV
  • Precision loss β€” truncation-to-zero in reward accumulators, rounding direction exploits in share-based systems
  • Access control β€” OWASP #1: missing initializer guards, unprotected critical functions, Wormhole-style implementation initialization
  • Composability risk β€” the cascading dependency problem in DeFi

Key insight: Most DeFi exploits are compositions of known patterns. The attacker combines a flash loan (free capital) with a price manipulation (create mispricing) and a protocol assumption violation (exploit the mispricing). Thinking in attack chains, not isolated vulnerabilities, is what separates effective security reviewers.

Next: Invariant testing β€” the most powerful methodology for finding the multi-step bugs that unit tests miss.


πŸ’‘ Invariant Testing with Foundry

πŸ’‘ Concept: Why Invariant Testing Is the Most Powerful DeFi Testing Tool

Unit tests verify specific scenarios you think of. Fuzz tests verify single functions with random inputs. Invariant tests verify that properties hold across random sequences of function calls β€” finding edge cases no human would think to test.

For DeFi protocols, invariants encode the fundamental properties your protocol must maintain:

  • β€œTotal supply of shares equals sum of all balances” (ERC-20)
  • β€œSum of all deposits minus withdrawals equals total assets” (Vault)
  • β€œNo user can withdraw more than they deposited plus their share of yield” (Vault)
  • β€œA position with health factor > 1 cannot be liquidated” (Lending)
  • β€œTotal borrowed ≀ total supplied” (Lending)
  • β€œEvery vault has collateral ratio β‰₯ minimum OR is being liquidated” (CDP)

πŸ”§ Foundry Invariant Testing Setup

import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";

contract VaultInvariantTest is StdInvariant, Test {
    Vault vault;
    VaultHandler handler;

    function setUp() public {
        vault = new Vault(address(token));
        handler = new VaultHandler(vault, token);

        // Tell Foundry to only call functions on the handler
        targetContract(address(handler));
    }

    // Invariant: total shares value = total assets
    function invariant_totalAssetsMatchesShares() public view {
        uint256 totalShares = vault.totalSupply();
        uint256 totalAssets = vault.totalAssets();
        if (totalShares == 0) {
            assertEq(totalAssets, 0);
        } else {
            uint256 totalRedeemable = vault.convertToAssets(totalShares);
            assertApproxEqAbs(totalRedeemable, totalAssets, 10); // Allow small rounding
        }
    }

    // Invariant: no individual can withdraw more than their share
    function invariant_noFreeTokens() public view {
        assertGe(
            token.balanceOf(address(vault)),
            vault.totalAssets()
        );
    }
}

πŸ”§ Handler Contracts: The Key to Effective Invariant Testing

The handler wraps your protocol’s functions with bounded inputs and realistic constraints:

contract VaultHandler is Test {
    Vault vault;
    IERC20 token;

    // Ghost variables: track cumulative state for invariant checks
    uint256 public ghost_totalDeposited;
    uint256 public ghost_totalWithdrawn;

    // Track actors
    address[] public actors;
    address currentActor;

    modifier useActor(uint256 actorIndexSeed) {
        currentActor = actors[bound(actorIndexSeed, 0, actors.length - 1)];
        vm.startPrank(currentActor);
        _;
        vm.stopPrank();
    }

    function deposit(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
        amount = bound(amount, 1, token.balanceOf(currentActor));
        if (amount == 0) return;

        token.approve(address(vault), amount);
        vault.deposit(amount, currentActor);
        ghost_totalDeposited += amount;
    }

    function withdraw(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
        uint256 maxWithdraw = vault.maxWithdraw(currentActor);
        amount = bound(amount, 0, maxWithdraw);
        if (amount == 0) return;

        vault.withdraw(amount, currentActor, currentActor);
        ghost_totalWithdrawn += amount;
    }
}

Ghost variables track cumulative state that isn’t stored on-chain β€” total deposited, total withdrawn, per-user totals. These enable invariants like β€œtotal deposited - total withdrawn β‰ˆ totalAssets (accounting for yield).”

Actor management simulates multiple users interacting with the protocol. The useActor modifier selects a random user from a pool and pranks as them.

Bounded inputs ensure the fuzzer generates realistic values (not amounts greater than the user’s balance, not zero addresses).

βš™οΈ Configuration

# foundry.toml
[invariant]
runs = 256          # Number of test sequences
depth = 50          # Number of calls per sequence
fail_on_revert = false  # Don't fail on expected reverts

Higher depth = longer call sequences = more likely to find complex multi-step bugs. For production, use runs = 1000+ and depth = 100+.

πŸ’» Quick Try: Invariant Testing Catches a Bug

Here’s a minimal vault with a subtle bug in withdraw(). The invariant test finds it β€” unit tests wouldn’t:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @notice Minimal vault with a subtle bug β€” can you spot it?
contract BuggyVault is ERC20("Vault", "vTKN") {
    IERC20 public immutable asset;

    constructor(IERC20 _asset) { asset = _asset; }

    function deposit(uint256 amount) external returns (uint256 shares) {
        shares = totalSupply() == 0
            ? amount
            : amount * totalSupply() / asset.balanceOf(address(this));
        asset.transferFrom(msg.sender, address(this), amount);
        _mint(msg.sender, shares);
    }

    function withdraw(uint256 shares) external returns (uint256 amount) {
        _burn(msg.sender, shares);
        // BUG: totalSupply() is now REDUCED β€” each share redeems more than it should
        amount = shares * asset.balanceOf(address(this)) / totalSupply();
        asset.transfer(msg.sender, amount);
    }
}

Now write a test that catches it:

// BuggyVaultInvariant.t.sol
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Test} from "forge-std/Test.sol";

contract BuggyVaultHandler is Test {
    BuggyVault vault;
    MockERC20 token;
    address[] public actors;
    mapping(address => uint256) public ghost_deposited;  // per-actor deposits
    mapping(address => uint256) public ghost_withdrawn;  // per-actor withdrawals

    constructor(BuggyVault _vault, MockERC20 _token) {
        vault = _vault;
        token = _token;
        for (uint256 i = 0; i < 3; i++) {
            address actor = makeAddr(string(abi.encodePacked("actor", i)));
            actors.push(actor);
            token.mint(actor, 100_000e18);
            vm.prank(actor);
            token.approve(address(vault), type(uint256).max);
        }
    }

    function deposit(uint256 amount, uint256 actorSeed) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 1e18, token.balanceOf(actor));
        if (amount == 0) return;
        vm.prank(actor);
        vault.deposit(amount);
        ghost_deposited[actor] += amount;
    }

    function withdraw(uint256 shares, uint256 actorSeed) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        uint256 bal = vault.balanceOf(actor);
        shares = bound(shares, 0, bal);
        if (shares == 0) return;
        uint256 balBefore = token.balanceOf(actor);
        vm.prank(actor);
        vault.withdraw(shares);
        uint256 balAfter = token.balanceOf(actor);
        ghost_withdrawn[actor] += (balAfter - balBefore);
    }

    function actorCount() external view returns (uint256) { return actors.length; }
}

contract BuggyVaultInvariantTest is StdInvariant, Test {
    BuggyVault vault;
    MockERC20 token;
    BuggyVaultHandler handler;

    function setUp() public {
        token = new MockERC20();
        vault = new BuggyVault(token);
        handler = new BuggyVaultHandler(vault, token);
        targetContract(address(handler));
    }

    /// @dev Fairness: no actor withdraws more than they deposited (no yield in this vault)
    function invariant_noActorProfits() public view {
        for (uint256 i = 0; i < handler.actorCount(); i++) {
            address actor = handler.actors(i);
            uint256 withdrawn = handler.ghost_withdrawn(actor);
            uint256 deposited = handler.ghost_deposited(actor);
            assertLe(
                withdrawn,
                deposited + 1e18,  // allow 1 token rounding
                "Fairness violated: actor withdrew more than deposited"
            );
        }
    }
}

Run with forge test --match-contract BuggyVaultInvariantTest. The invariant_noActorProfits test will fail. Here’s why β€” trace through a deposit/deposit/withdraw sequence:

Actor A deposits 100e18:  shares = 100e18 (first deposit)
Actor B deposits 100e18:  shares = 100e18 * 100e18 / 100e18 = 100e18
State: vault balance = 200e18, totalSupply = 200e18, A = 100e18, B = 100e18

Actor A withdraws 100 shares:
  _burn(A, 100e18)        β†’ totalSupply = 100e18
  amount = 100e18 * 200e18 / 100e18 = 200e18  ← A drains EVERYTHING!
  transfer(A, 200e18)     β†’ vault balance = 0

B has 100e18 shares backed by 0 tokens. A stole B's deposit.

The invariant catches it: A deposited 100e18 but withdrew 200e18. Since this is a no-yield vault, no actor should ever profit β€” withdrawn > deposited is a clear fairness violation.

Why not a conservation invariant? You might be tempted to check vault_balance == total_deposits - total_withdrawals. That’s a tautology β€” if the handler tracks actual token flows, deposits minus withdrawals always equals the balance by construction. The burn-before-calculate bug is a fairness bug (it redistributes value between users) not a conservation bug (no tokens are created or destroyed). Fairness invariants that track per-actor flows are the right tool here.

The fix: Calculate the amount before burning shares:

function withdraw(uint256 shares) external returns (uint256 amount) {
    amount = shares * asset.balanceOf(address(this)) / totalSupply();
    _burn(msg.sender, shares);  // burn AFTER calculating amount
    asset.transfer(msg.sender, amount);
}

This is exactly the kind of ordering bug that unit tests miss β€” you’d have to think of the exact multi-user interleaving. Invariant tests find it automatically by exploring random call sequences.

πŸ“‹ What Invariants to Test for Each DeFi Primitive

For a vault/ERC-4626:

  • Total assets β‰₯ sum of all shares Γ— share price (no phantom assets)
  • After deposit: user shares increase, vault assets increase by same amount
  • After withdrawal: user shares decrease, user receives expected assets
  • Share price never decreases (if no strategy losses reported)
  • Rounding never favors the user

For a lending protocol:

  • Total borrowed ≀ total supplied
  • No user can borrow without sufficient collateral
  • Health factor of every position β‰₯ 1 OR position is flagged for liquidation
  • Interest index only increases
  • After liquidation: position health factor improves

For an AMM:

  • k = x Γ— y (constant product) holds after every swap (minus fees)
  • LP token supply matches liquidity provided
  • Sum of all LP claim values = total pool value

For a CDP/stablecoin:

  • Every vault has collateral ratio β‰₯ minimum OR is being liquidated
  • Total stablecoin supply = sum of all vault debt
  • Stability fee index only increases

πŸ” Deep Dive: Writing Good Invariants β€” A Mental Model

Coming up with invariants can feel abstract. Here’s a systematic approach:

Step 1: Ask β€œWhat must ALWAYS be true?”

Think about your protocol from the perspective of conservation laws:

  • Conservation of value: tokens in = tokens out (no creation or destruction)
  • Conservation of accounting: internal records match actual balances
  • Conservation of solvency: the protocol can always meet its obligations

Step 2: Ask β€œWhat must NEVER happen?”

Flip it β€” think about what would be catastrophic:

  • A user withdraws more than they deposited + earned
  • Total borrowed exceeds total supplied
  • A liquidation makes the protocol less solvent
  • Share price goes to 0 (or infinity)

Step 3: Map actions to state transitions

For each function in your protocol, trace what changes:

deposit(amount):
  BEFORE: totalAssets = X,  userShares = S,  totalShares = T
  AFTER:  totalAssets = X+amount,  userShares = S+newShares,  totalShares = T+newShares
  INVARIANT: newShares ≀ amount * T / X  (rounding down protects vault)

Step 4: Add ghost variables for cumulative tracking

On-chain state only shows the current state. Ghost variables track the history:

ghost_totalDeposited += amount    // in handler's deposit()
ghost_totalWithdrawn += amount    // in handler's withdraw()

// Invariant: totalAssets β‰ˆ ghost_totalDeposited - ghost_totalWithdrawn + yieldAccrued

Step 5: Think adversarially

What if one actor calls functions in an unexpected order? What if they:

  • Deposit 0? Deposit type(uint256).max?
  • Withdraw immediately after depositing?
  • Deposit, transfer shares to another address, both withdraw?
  • Call functions during a callback?

The handler’s bound() function handles invalid inputs, but the sequence of valid calls is where real bugs hide.

Common invariant testing pitfalls:

  • Writing invariants that are too loose (always pass, catch nothing)
  • Not having enough actors (single-actor tests miss multi-user edge cases)
  • Not tracking ghost variables (can’t verify cumulative properties)
  • Setting depth too low (complex bugs need 20+ step sequences)

🎯 Build Exercise: Invariant Testing

Workspace: workspace/src/part2/module8/exercise3-invariant/ β€” starter files: BuggyVault.sol, VaultHandler.sol, tests: VaultInvariant.t.sol

Write a comprehensive invariant test suite for your SimpleLendingPool from Module 4:

  1. Handler contract with: supply(), borrow(), repay(), withdraw(), liquidate(), accrueInterest() β€” all with bounded inputs and actor management

  2. Invariants:

    • Total supplied assets β‰₯ total borrowed
    • Health factor of every borrower is either β‰₯ 1 or they have no borrow
    • Interest indices only increase
    • No user can borrow without sufficient collateral
    • Sum of all user supply balances β‰ˆ total supply (accounting for interest)
  3. Ghost variables: total deposited, total withdrawn, total borrowed, total repaid, total liquidated

  4. Run with depth = 50, runs = 500. If any invariant breaks, you have a bug β€” fix it and re-run.

πŸ“‹ Summary: Invariant Testing with Foundry

βœ“ Covered:

  • Why invariant testing beats unit/fuzz testing for DeFi protocols
  • Foundry invariant testing setup β€” StdInvariant, targetContract
  • Handler contracts β€” bounded inputs, actor management, useActor modifier
  • Ghost variables β€” tracking cumulative state (ghost_totalDeposited, etc.)
  • Configuration β€” runs, depth, fail_on_revert
  • Invariant catalog for vaults, lending, AMMs, and CDPs

Key insight: The handler contract is the heart of invariant testing. It doesn’t just wrap functions β€” it defines the realistic action space of your protocol. A well-designed handler with ghost variables and multiple actors will find bugs that thousands of unit tests miss, because it explores sequences of actions that no human would think to test.

Next: Reading audit reports β€” extracting maximum learning from expert security reviews.


πŸ’‘ Reading Audit Reports

πŸ’‘ Concept: Why This Skill Matters

Audit reports are the densest source of real-world vulnerability knowledge. A single report can contain 10-20 findings, each one a potential exploit pattern you might encounter in your own code. Learning to read them efficiently β€” understanding severity classifications, root cause analysis, and recommended fixes β€” is one of the highest-ROI activities for a protocol builder.

πŸ“– How to Read an Audit Report

Structure of a typical report:

  • Executive summary β€” Protocol description, scope, methodology
  • Findings β€” Sorted by severity: Critical, High, Medium, Low, Informational
  • Each finding includes: Description, impact, root cause, proof of concept, recommendation, protocol team response

What to focus on:

  • Critical and High findings β€” these are the exploitable bugs
  • The root cause analysis β€” not just β€œwhat” but β€œwhy” it happened
  • The fix recommendation β€” how would you have solved it?
  • Informational findings β€” these reveal common anti-patterns and code smell

πŸ“– How to Study Audit Reports Effectively

  1. Read the executive summary and scope first β€” Understand what the protocol does and which contracts were audited. If the audit covers only core contracts but not periphery/integrations, that’s a significant limitation. Note the Solidity version, framework, and any unusual architecture choices the auditors call out.

  2. Read Critical and High findings deeply β€” For each one: read the description, then STOP. Before reading the impact/PoC, ask yourself: β€œHow would I exploit this?” Try to construct the attack mentally. Then read the auditor’s impact assessment and PoC. Compare your thinking to theirs β€” this builds attacker intuition.

  3. Classify each finding into your mental taxonomy β€” Is it reentrancy? Oracle manipulation? Access control? Logic error? Rounding? Map each finding to the attack patterns from the DeFi-Specific Attack Patterns section. Over time, you’ll see the same categories appear across every audit. This is the pattern recognition that makes you faster at finding bugs.

  4. Read the fix, then evaluate it β€” Does the fix address the root cause or just the symptom? Would you have fixed it differently? Sometimes the auditor’s recommendation is a patch, but a better fix involves rearchitecting. Forming your own opinion on fixes is where you develop design judgment.

  5. Track informational findings β€” These aren’t exploitable, but they reveal what auditors consider code smell: missing events, inconsistent naming, unused variables, gas inefficiencies. If you see the same informational finding across multiple audits (you will), it’s a pattern to avoid in your own code.

Don’t get stuck on: Reading every finding in a 50+ finding report. Focus on Critical/High first, skim Medium, read Informational titles only. A single Critical finding teaches more than ten Informational ones.

πŸ“– Report 1: Aave V3 Core (OpenZeppelin, SigmaPrime)

Source code: aave-v3-core Audits: OpenZeppelin | SigmaPrime β€” both publicly available.

What to look for:

  • How auditors analyze the interest rate model for edge cases
  • Findings related to oracle integration and staleness
  • Access control findings on protocol governance
  • Any findings related to the aToken/debtToken accounting system

Exercise: Read the findings list. For each High/Medium finding, determine:

  1. Which vulnerability class does it belong to? (from the DeFi-Specific Attack Patterns taxonomy)
  2. Would your SimpleLendingPool from Module 4 be vulnerable to the same issue?
  3. If yes, how would you fix it?

πŸ“– Report 2: A Smaller Protocol With Critical Findings

Recommended options (publicly available):

Pick one report for a protocol similar to what you’ve built (lending, AMM, or vault). Read the critical findings.

Exercise: For the most critical finding:

  1. Reproduce the proof of concept in Foundry (even if simplified)
  2. Implement the fix
  3. Write a test that passes before the fix and fails after (regression test)

πŸ“– Report 3: Immunefi Bug Bounty Writeup

Source: Immunefi Medium (search for β€œbug bounty writeup”) or Immunefi Explore

Bug bounty writeups show attacker thinking β€” the process of discovering a vulnerability, not just the final finding. This is the perspective you need to develop.

Exercise: Read 2-3 writeups. For each:

  1. What was the initial observation that led to the discovery?
  2. How did the researcher escalate from β€œsuspicious” to β€œexploitable”?
  3. What defense would have prevented it?

🎯 Build Exercise: Self-Audit

Take your SimpleLendingPool from Module 4 and apply a structured review:

  1. Threat model: List all actors (supplier, borrower, liquidator, oracle, admin). For each, list what they should and shouldn’t be able to do.

  2. Trust assumptions: List every external dependency (oracle, token contracts, flash loan providers). For each, describe the failure scenario.

  3. Code review checklist:

    • All external/public functions have appropriate access control
    • CEI pattern followed everywhere (or nonReentrant applied)
    • All oracle integrations include staleness checks, zero-price checks
    • No reliance on balanceOf for critical accounting
    • Slippage protection on all swaps
    • Return values of external calls are checked

πŸ“‹ Summary: Reading Audit Reports

βœ“ Covered:

  • Why audit reports are the densest source of vulnerability knowledge
  • How to read an audit report β€” structure, severity levels, what to focus on
  • Building attacker intuition β€” try to construct the exploit before reading the PoC
  • Classifying findings into your mental taxonomy
  • Studying 3 report types: major protocol audit, smaller critical-findings audit, and bug bounty writeup
  • Self-audit methodology β€” threat model, trust assumptions, structured checklist

Key insight: The highest-ROI way to read an audit report is to pause after the vulnerability description and ask β€œHow would I exploit this?” before reading the PoC. This builds the attacker intuition that turns a good developer into a strong security reviewer. Make reading 1-2 reports per month a permanent habit.

Next: Security tooling and audit preparation β€” static analysis, formal verification, and the operational checklist for deployment.


πŸ’‘ Security Tooling & Audit Preparation

Static Analysis Tools

Slither β€” Trail of Bits’ static analyzer. Detects reentrancy, uninitialized variables, incorrect visibility, unchecked return values, and many more patterns. Run in CI/CD on every commit.

pip install slither-analyzer
slither . --json slither-report.json

Aderyn β€” Cyfrin’s Rust-based analyzer. Faster than Slither for large codebases, catches Solidity-specific patterns. Good complement to Slither (different detectors).

cargo install aderyn
aderyn .

Both tools produce false positives. The skill is triaging results: understanding which findings are real vulnerabilities vs. informational or stylistic.

πŸ’» Quick Try:

Save this vulnerable contract as Vulnerable.sol and run Slither on it:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient");
        // Bug: external call before state update (CEI violation)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
        balances[msg.sender] -= amount;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
pip install slither-analyzer   # if not installed
slither Vulnerable.sol

Slither should flag the reentrancy in withdraw() β€” the external call before the state update. See how it identifies the exact vulnerability pattern? Now fix the contract (move balances[msg.sender] -= amount before the call) and re-run Slither to confirm the finding disappears. That’s the feedback loop: write code β†’ analyze β†’ fix β†’ verify.

πŸ” Formal Verification (Awareness)

Certora Prover β€” Used by Aave, Compound, and other major protocols. You write properties in CVL (Certora Verification Language), and the prover mathematically verifies they hold for all possible inputs and states β€” not just random samples like fuzzing, but all of them.

// Certora rule example
rule depositIncreasesBalance {
    env e;
    uint256 amount;
    uint256 balanceBefore = balanceOf(e.msg.sender);
    deposit(e, amount);
    uint256 balanceAfter = balanceOf(e.msg.sender);
    assert balanceAfter >= balanceBefore;
}

Formal verification is expensive ($200,000+ for complex protocols) but provides the highest confidence level. For production DeFi protocols managing significant TVL, it’s increasingly expected. You don’t need to master CVL now, but understand that it exists and what it provides.

βœ… The Security Checklist

Before any deployment:

Code-level:

  • All external/public functions have appropriate access control
  • CEI pattern followed everywhere (or nonReentrant applied)
  • No external calls to user-supplied addresses without validation
  • All arithmetic uses checked math (Solidity β‰₯0.8.0) or explicit SafeMath
  • Return values of external calls are checked
  • No reliance on balanceOf for critical accounting (use internal tracking)
  • All oracle integrations include staleness checks, zero-price checks, and L2 sequencer checks
  • No spot price usage for valuations
  • Slippage protection on all swaps
  • ERC-4626 vaults: virtual shares or dead shares for inflation attack prevention
  • Upgradeable contracts: initializer modifier, storage gap, correct proxy pattern

Testing:

  • Unit tests covering all functions and edge cases
  • Fuzz tests for all functions with numeric inputs
  • Invariant tests encoding protocol-wide properties
  • Fork tests against mainnet state
  • Negative tests (things that should fail DO fail)

Operational:

  • Timelock on all admin functions
  • Emergency pause function
  • Circuit breakers for anomalous conditions (large withdrawals, price deviations)
  • Monitoring/alerting for key state changes
  • Incident response plan documented
  • Bug bounty program (Immunefi or similar)

πŸ“‹ Audit Preparation

Auditors are a final validation, not a substitute for your own security work. Protocols that arrive at audit with comprehensive tests and clear documentation get significantly more value from the audit.

What to prepare:

  • Complete documentation of protocol design and intended behavior
  • Threat model: who are the actors? What can each actor do? What should each actor NOT be able to do?
  • Test suite with coverage report
  • Known issues list (things you’ve identified but haven’t fixed, or accepted risks)
  • Deployment plan (chain, proxy pattern, initialization sequence)

After audit:

  • Fix all critical and high findings before deployment
  • Re-audit significant code changes (even β€œminor” fixes can introduce new vulnerabilities)
  • Don’t deploy code that differs from what was audited

πŸ’‘ Concept: Building Security-First

The security mindset isn’t a checklist β€” it’s a way of thinking about code:

Assume hostile inputs. Every parameter is crafted to exploit your contract. Every external call returns something unexpected. Every caller has unlimited capital via flash loans.

Design for failure. What happens when the oracle goes stale? When a strategy loses money? When gas prices spike 100x? When a collateral token is blacklisted? Your protocol should degrade gracefully, not catastrophically.

Minimize trust. Every trust assumption is an attack surface. Trust in oracles β†’ oracle manipulation. Trust in admin keys β†’ compromised keys. Trust in external contracts β†’ composability attacks. Document every trust assumption and ask: what happens if this assumption fails?

Simplify. The most secure protocol is the simplest one that achieves the goal. Every line of code is a potential vulnerability. MakerDAO’s Vat is ~300 lines. Uniswap V2 core is ~400 lines. Compound V3’s Comet is ~4,300 lines. Complexity is the enemy of security.

🎯 Build Exercise: Security Review

Exercise 1: Full security review. Run Slither and Aderyn on your SimpleLendingPool from Module 4 and your SimpleCDP from Module 6. Triage every finding: real vulnerability, informational, or false positive. Fix any real vulnerabilities found.

Exercise 2: Threat model. Write a threat model for your SimpleCDP from Module 6:

  • Identify all actors (vault owner, liquidator, PSM arbitrageur, governance)
  • For each actor, list what they should be able to do
  • For each actor, list what they should NOT be able to do
  • Identify the trust assumptions (oracle, governance, collateral token behavior)
  • For each trust assumption, describe the failure scenario

Exercise 3: Invariant test your CDP. Apply the Invariant Testing methodology to your SimpleCDP:

  • Handler with: openVault, addCollateral, generateStablecoin, repay, withdrawCollateral, liquidate, updateOraclePrice
  • Invariants: every vault safe or liquidatable, total stablecoin ≀ total vault debt Γ— rate, debt ceiling not exceeded
  • Run with high depth and runs

πŸ’Ό Job Market Context

What DeFi teams expect you to know about security tooling and process:

  1. β€œWhat does your security workflow look like before deployment?”

    • Good answer: Unit tests, fuzz tests, Slither, get an audit
    • Great answer: Describes a layered approach β€” unit tests β†’ fuzz tests β†’ invariant tests (with handlers and ghost variables) β†’ static analysis (Slither + Aderyn, triage false positives) β†’ self-audit with threat model β†’ comprehensive documentation β†’ external audit β†’ fix cycle β†’ re-audit changes β†’ bug bounty program. Mentions that the test suite and documentation quality directly affect audit ROI
  2. β€œInvariant testing vs fuzz testing β€” what’s the difference and when do you use each?”

    • Good answer: Fuzz tests random inputs to one function; invariant tests random sequences of calls and check properties hold
    • Great answer: Fuzz tests verify per-function behavior (testFuzz_depositReturnsCorrectShares). Invariant tests verify protocol-wide properties across arbitrary call sequences β€” they find multi-step bugs like β€œdeposit β†’ accrue β†’ withdraw β†’ accrue β†’ deposit creates phantom assets.” The handler contract is key: it bounds inputs, manages actors, and tracks ghost state. For DeFi, invariant tests are essential because most real exploits involve multi-step interactions, not single-function edge cases
  3. β€œHave you ever found a real bug with invariant testing?”

    • This is a strong signal question. Having a concrete story (even from practice protocols) demonstrates real experience. If you haven’t yet: run invariant tests on your Module 4 and Module 6 exercises with high depth β€” you’ll likely find rounding edge cases or state inconsistencies worth discussing

Hot topics (2025-26):

  • AI-assisted auditing (LLM-powered code review as a complement to manual audit)
  • Formal verification becoming more accessible (Certora, Halmos)
  • Security-as-a-service platforms (continuous monitoring, not just one-time audits)
  • MEV-aware protocol design as a first-class security concern
  • Cross-chain bridge security (still the largest single-exploit category by dollar value)

πŸ“‹ Summary: Security Tooling & Audit Preparation

βœ“ Covered:

  • Static analysis tooling β€” Slither (Python-based, broad detectors) and Aderyn (Rust-based, fast, complementary)
  • Formal verification awareness β€” Certora Prover, CVL rules, when it’s worth the cost
  • The deployment security checklist β€” code-level, testing, and operational requirements
  • Audit preparation β€” what to provide auditors and what to do after
  • Security-first design philosophy β€” assume hostile inputs, design for failure, minimize trust, simplify

Internalized patterns: DeFi-specific attacks go beyond basic Solidity security (read-only reentrancy, flash-loan-amplified manipulation, ERC-4626 exchange rate attacks). Invariant testing is the most powerful DeFi testing methodology (handlers, ghost variables, realistic actor management). Reading audit reports is high-ROI learning (1-2 per month). Security is a spectrum, not a binary (CEI + access control + oracle safety + tests + static analysis + audit + formal verification + bug bounty). Simplify (every abstraction, external call, and storage variable is a potential vulnerability). Read-only reentrancy is the most common β€œnew” DeFi exploit pattern (verify external protocols aren’t mid-execution before trusting their view functions).

Key insight: Security isn’t a phase β€” it’s a design philosophy. The security checklist at the end of this day should be internalized as second nature, not treated as a pre-launch checkbox. The protocols that get exploited aren’t the ones that skip audits β€” they’re the ones that treat security as someone else’s job.


⚠️ Common Mistakes

Mistake 1: Trusting external view functions during state transitions

// WRONG: reading price during a callback
function _callback() internal {
    uint256 price = externalPool.getRate(); // ← stale/manipulated during reentrancy
    _updateCollateralValue(price);
}
// CORRECT: verify the external protocol isn't mid-transaction
function _callback() internal {
    // Check Balancer's reentrancy lock before reading
    IVault(balancerVault).manageUserBalance(new IVault.UserBalanceOp[](0));
    // If we get here, Balancer isn't in a reentrant state
    uint256 price = externalPool.getRate();
    _updateCollateralValue(price);
}

Mistake 2: Checking oracle staleness with too-generous thresholds

// WRONG: 24-hour staleness window is way too long for DeFi
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 24 hours, "Stale price");
// CORRECT: match staleness to the feed's heartbeat (varies per asset)
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < HEARTBEAT + GRACE_PERIOD, "Stale price");
require(price > 0, "Invalid price");
// On L2: also check sequencer uptime feed

Mistake 3: Writing invariant tests without a handler contract

// WRONG: letting Foundry call the vault directly with random calldata
function setUp() public {
    targetContract(address(vault)); // ← Foundry sends garbage inputs, everything reverts
}
// CORRECT: handler bounds inputs and manages actors
function setUp() public {
    handler = new VaultHandler(vault, token);
    targetContract(address(handler)); // ← realistic, bounded interactions
}

Mistake 4: Using balanceOf for critical accounting

// WRONG: total assets = token balance (manipulable via direct transfer)
function totalAssets() public view returns (uint256) {
    return token.balanceOf(address(this));
}
// CORRECT: track deposits/withdrawals internally
function totalAssets() public view returns (uint256) {
    return _totalManagedAssets; // updated only by deposit/withdraw/harvest
}

Mistake 5: Assuming audited means secure

An audit is a snapshot β€” it covers specific code at a specific time. Common traps:

  • Deploying code that differs from what was audited (even β€œminor” changes)
  • Adding new integrations post-audit (new external dependencies = new attack surface)
  • Not re-auditing after fixing audit findings (fixes can introduce new bugs)
  • Treating a clean audit as permanent (the protocol evolves, dependencies change, new attack patterns emerge)

πŸ’Ό Job Market Context

Security knowledge opens multiple career paths beyond β€œprotocol developer.” Understanding where the demand is helps you position yourself.

Path 1: Protocol Security Engineer

  • Role: Build protocols with security as a core responsibility
  • Day-to-day: Threat modeling, invariant test suites, security-aware architecture
  • Compensation: Premium over general Solidity devs (~$180-300k+ for senior roles)
  • Signal: Invariant tests in your portfolio, security-first design decisions, audit participation

Path 2: Smart Contract Auditor

  • Role: Review other teams’ code for vulnerabilities
  • Day-to-day: Code review, writing findings, PoC construction, client communication
  • Entry: Audit competitions (Code4rena, Sherlock, CodeHawks) β†’ audit firm β†’ independent
  • Compensation: Highly variable β€” competitive auditors earn $200-500k+ annually
  • Signal: Audit competition track record, published findings, Immunefi bug bounties

Path 3: Security Researcher / Bug Hunter

  • Role: Find vulnerabilities in deployed protocols for bounties
  • Day-to-day: Reading code, building attack PoCs, submitting to Immunefi/bug bounty programs
  • Compensation: Per-bounty ($10k-$10M+ for critical findings)
  • Signal: Immunefi profile, published writeups, responsible disclosure track record

Path 4: Security Tooling Developer

  • Role: Build the static analyzers, formal verification tools, and monitoring systems
  • Day-to-day: Compiler theory, abstract interpretation, SMT solvers, protocol monitoring
  • Companies: Trail of Bits (Slither), Certora (Prover), Cyfrin (Aderyn), Forta, OpenZeppelin
  • Signal: Contributions to open-source security tools, research publications

How this module prepares you: Every path above requires the fundamentals covered here β€” attack pattern taxonomy, invariant testing, audit report reading, and tooling familiarity. The exercises in this module build the portfolio evidence that differentiates you in any of these directions.


Backward References (concepts from earlier modules used here)

SourceConceptHow It Connects
Part 1 Module 1Custom errorsSecurity checklist requires typed errors for all revert paths β€” error taxonomy from Module 1
Part 1 Module 2Transient storage reentrancy guardGlobal nonReentrant via transient storage is the recommended cross-contract reentrancy defense
Part 1 Module 5Fork testingFlash loan attack exercises require mainnet fork setup from Module 5
Part 1 Module 5Invariant / fuzz testingThe Invariant Testing section builds directly on foundry fuzz patterns from Module 5
Part 1 Module 6Proxy patternsSecurity checklist covers upgradeable contract risks β€” initializer, storage gap from Module 6
M1SafeERC20 / balanceOf pitfallsDonation attack (Category 3) exploits balanceOf-based accounting β€” internal tracking from M1 is the defense
M1Fee-on-transfer / rebasing tokensSecurity Tooling section checklist: these break naive vault and lending accounting
M2AMM spot price / MEV / sandwichPrice manipulation Category 1 uses DEX swaps; sandwich attacks from M2’s MEV section
M2Read-only reentrancy (Balancer)The Attack Patterns section’s read-only reentrancy uses Balancer pool getRate() manipulation during join/exit
M3Oracle manipulation taxonomyThe Attack Patterns section’s 5-category taxonomy extends M3’s Chainlink/TWAP/dual-oracle patterns
M3Staleness checks / L2 sequencerSecurity checklist oracle safety requirements come directly from M3
M4Lending / liquidation mechanicsInvariant catalog for lending protocols; SimpleLendingPool as invariant test target
M5Flash loans as capital amplifierFlash loans make price manipulation free β€” the core enabler for Categories 1, 4, 5
M6CDP mechanics / governance paramsInvariant catalog for CDPs; governance manipulation (Category 5) targets stability fees and debt ceilings
M7ERC-4626 inflation attackPrice manipulation Category 4 β€” exchange rate manipulation, virtual shares defense
M7Profit unlocking (anti-sandwich)The Attack Patterns sandwich defense references M7’s profitMaxUnlockTime pattern

Forward References (where these concepts lead)

TargetConceptHow It Connects
M9Self-audit methodologyApply the Reading Audit Reports threat model + security checklist to the integration capstone
M9Invariant test suiteCapstone requires comprehensive invariant tests using the Invariant Testing handler/ghost pattern
M9Stress testingCapstone stress tests combine flash loan attacks + oracle manipulation from the Attack Patterns section
Part 3 M1Liquid staking securityLST/LRT composability risks β€” read-only reentrancy on staking derivatives, oracle manipulation on rebasing tokens
Part 3 M2Perpetuals securityFunding rate manipulation, oracle frontrunning in perp exchanges β€” extends the price manipulation taxonomy
Part 3 M4Cross-chain securityBridge exploit patterns, message verification bypasses β€” cross-chain composability risk extends the composability section
Part 3 M5MEV and securitySandwich attacks, JIT liquidity, and MEV extraction defenses build directly on the frontrunning/MEV section
Part 3 M7L2 DeFi securityL2 sequencer risks, forced inclusion, and cross-L2 bridge security expand on the oracle L2 sequencer checks
Part 3 M8Governance securityTimelock, multisig, and governance attack defenses build on Category 5 (governance manipulation)
Part 3 M9Capstone stress testingThe Perpetual Exchange capstone requires comprehensive invariant tests and adversarial stress testing from this module

πŸ“– Production Study Order

Study these security resources in order β€” each builds on the previous:

#Repository / ResourceWhy Study ThisKey Files / Sections
1SlitherThe most widely-used static analyzer β€” learn its detector categories and how to triage false positivesslither/detectors/ (detector implementations), README (usage), detector docs
2AderynRust-based complement to Slither β€” faster, catches different patterns, understand the overlapsrc/ (detector implementations), compare output against Slither on the same codebase
3a16z ERC-4626 Property TestsThe gold standard for vault invariant testing β€” study how they encode properties as handler-based testsERC4626.prop.sol (all properties), README (integration guide)
4Aave V3 Audit β€” OpenZeppelinMajor protocol audit from a top firm β€” study finding structure, severity classification, root cause analysisFocus on Critical/High findings, trace each to the attack taxonomy
5Trail of Bits Public AuditsDozens of real audit reports β€” build your finding taxonomy across protocolsPick 3 DeFi audits, classify every High finding into the Attack Patterns categories
6Certora TutorialsIntroduction to formal verification β€” write CVL specs for simple protocols01.Lesson_GettingStarted/, 02.Lesson_InvariantChecking/, example specs

Reading strategy: Start with Slither (1) and Aderyn (2) by running both on your own exercise code β€” compare findings and learn to triage. Study the a16z property tests (3) to understand professional invariant test design. Then read the Aave audit (4) deeply using the Reading Audit Reports methodology. Browse Trail of Bits reports (5) to build breadth across protocol types. Finally, explore Certora tutorials (6) if you want to pursue formal verification.


πŸ“š Resources

Vulnerability references:

Audit reports:

Testing:

Static analysis:

Formal verification:

Practice:


Navigation: ← Module 7: Vaults & Yield | Module 9: Integration Capstone β†’