Part 2 β Module 8: DeFi Security
Difficulty: Advanced
Estimated reading time: ~45 minutes | Exercises: ~4-5 hours
π Table of Contents
DeFi-Specific Attack Patterns
- Read-Only Reentrancy
- Read-Only Reentrancy β Numeric Walkthrough
- Cross-Contract Reentrancy
- Price Manipulation Taxonomy
- Flash Loan Attack P&L Walkthrough
- Frontrunning and MEV
- Precision Loss and Rounding Exploits
- Access Control Vulnerabilities
- Composability Risk
Invariant Testing with Foundry
- Why Invariant Testing Is the Most Powerful DeFi Testing Tool
- Foundry Invariant Testing Setup
- Handler Contracts
- Quick Try: Invariant Testing Catches a Bug
- What Invariants to Test for Each DeFi Primitive
Reading Audit Reports
- How to Read an Audit Report
- Report 1: Aave V3 Core
- Report 2: A Smaller Protocol
- Report 3: Immunefi Bug Bounty Writeup
- Exercise: Self-Audit
Security Tooling & Audit Preparation
- Static Analysis Tools
- Formal Verification (Awareness)
- The Security Checklist
- Audit Preparation
- Building Security-First
π 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
viewfunctions during your own state transitions - Check reentrancy locks on external protocols before reading their rates (Balancer V2 Vault pools have a
getPoolTokensthat 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 Type | Where Truncation Hits | Impact |
|---|---|---|
| Reward pools | reward / totalStaked accumulator | Rewards silently lost |
| Vaults (ERC-4626) | Share/asset conversions | Value extraction via repeated small ops |
| Lending (Aave, Compound) | Interest index updates | Interest can be rounded away for small positions |
| AMMs | Fee collection and distribution | LP fees lost to rounding |
| CDPs (MakerDAO) | art * rate debt calculation | Dust 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:
- Unprotected
initialize()β re-initialization overwrites owner - Missing
onlyOwner/ role checks on critical functions tx.originused for authentication (phishable via intermediate contract)- Incorrect role assignment in constructor/initializer
- Missing two-step ownership transfer (single-step transfer to wrong address = permanent lockout)
Defense checklist:
- Every
initialize()usesinitializermodifier (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.originfor 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:
-
β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
-
β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 (
balanceOfinflation), 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
-
β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
viewfunctions 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
depthtoo 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:
-
Handler contract with:
supply(),borrow(),repay(),withdraw(),liquidate(),accrueInterest()β all with bounded inputs and actor management -
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)
-
Ghost variables: total deposited, total withdrawn, total borrowed, total repaid, total liquidated
-
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,
useActormodifier - 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
-
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.
-
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.
-
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.
-
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.
-
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:
- Which vulnerability class does it belong to? (from the DeFi-Specific Attack Patterns taxonomy)
- Would your SimpleLendingPool from Module 4 be vulnerable to the same issue?
- If yes, how would you fix it?
π Report 2: A Smaller Protocol With Critical Findings
Recommended options (publicly available):
- Any Cyfrin audit with critical findings (search their blog for audit reports)
- Trail of Bits public audits on GitHub
- Spearbit reports β many DeFi protocol audits 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:
- Reproduce the proof of concept in Foundry (even if simplified)
- Implement the fix
- 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:
- What was the initial observation that led to the discovery?
- How did the researcher escalate from βsuspiciousβ to βexploitableβ?
- What defense would have prevented it?
π― Build Exercise: Self-Audit
Take your SimpleLendingPool from Module 4 and apply a structured review:
-
Threat model: List all actors (supplier, borrower, liquidator, oracle, admin). For each, list what they should and shouldnβt be able to do.
-
Trust assumptions: List every external dependency (oracle, token contracts, flash loan providers). For each, describe the failure scenario.
-
Code review checklist:
- All external/public functions have appropriate access control
- CEI pattern followed everywhere (or
nonReentrantapplied) - All oracle integrations include staleness checks, zero-price checks
- No reliance on
balanceOffor 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
nonReentrantapplied) - 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
balanceOffor 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:
-
β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
-
β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
-
β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.
π Cross-Module Concept Links
Backward References (concepts from earlier modules used here)
| Source | Concept | How It Connects |
|---|---|---|
| Part 1 Module 1 | Custom errors | Security checklist requires typed errors for all revert paths β error taxonomy from Module 1 |
| Part 1 Module 2 | Transient storage reentrancy guard | Global nonReentrant via transient storage is the recommended cross-contract reentrancy defense |
| Part 1 Module 5 | Fork testing | Flash loan attack exercises require mainnet fork setup from Module 5 |
| Part 1 Module 5 | Invariant / fuzz testing | The Invariant Testing section builds directly on foundry fuzz patterns from Module 5 |
| Part 1 Module 6 | Proxy patterns | Security checklist covers upgradeable contract risks β initializer, storage gap from Module 6 |
| M1 | SafeERC20 / balanceOf pitfalls | Donation attack (Category 3) exploits balanceOf-based accounting β internal tracking from M1 is the defense |
| M1 | Fee-on-transfer / rebasing tokens | Security Tooling section checklist: these break naive vault and lending accounting |
| M2 | AMM spot price / MEV / sandwich | Price manipulation Category 1 uses DEX swaps; sandwich attacks from M2βs MEV section |
| M2 | Read-only reentrancy (Balancer) | The Attack Patterns sectionβs read-only reentrancy uses Balancer pool getRate() manipulation during join/exit |
| M3 | Oracle manipulation taxonomy | The Attack Patterns sectionβs 5-category taxonomy extends M3βs Chainlink/TWAP/dual-oracle patterns |
| M3 | Staleness checks / L2 sequencer | Security checklist oracle safety requirements come directly from M3 |
| M4 | Lending / liquidation mechanics | Invariant catalog for lending protocols; SimpleLendingPool as invariant test target |
| M5 | Flash loans as capital amplifier | Flash loans make price manipulation free β the core enabler for Categories 1, 4, 5 |
| M6 | CDP mechanics / governance params | Invariant catalog for CDPs; governance manipulation (Category 5) targets stability fees and debt ceilings |
| M7 | ERC-4626 inflation attack | Price manipulation Category 4 β exchange rate manipulation, virtual shares defense |
| M7 | Profit unlocking (anti-sandwich) | The Attack Patterns sandwich defense references M7βs profitMaxUnlockTime pattern |
Forward References (where these concepts lead)
| Target | Concept | How It Connects |
|---|---|---|
| M9 | Self-audit methodology | Apply the Reading Audit Reports threat model + security checklist to the integration capstone |
| M9 | Invariant test suite | Capstone requires comprehensive invariant tests using the Invariant Testing handler/ghost pattern |
| M9 | Stress testing | Capstone stress tests combine flash loan attacks + oracle manipulation from the Attack Patterns section |
| Part 3 M1 | Liquid staking security | LST/LRT composability risks β read-only reentrancy on staking derivatives, oracle manipulation on rebasing tokens |
| Part 3 M2 | Perpetuals security | Funding rate manipulation, oracle frontrunning in perp exchanges β extends the price manipulation taxonomy |
| Part 3 M4 | Cross-chain security | Bridge exploit patterns, message verification bypasses β cross-chain composability risk extends the composability section |
| Part 3 M5 | MEV and security | Sandwich attacks, JIT liquidity, and MEV extraction defenses build directly on the frontrunning/MEV section |
| Part 3 M7 | L2 DeFi security | L2 sequencer risks, forced inclusion, and cross-L2 bridge security expand on the oracle L2 sequencer checks |
| Part 3 M8 | Governance security | Timelock, multisig, and governance attack defenses build on Category 5 (governance manipulation) |
| Part 3 M9 | Capstone stress testing | The 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 / Resource | Why Study This | Key Files / Sections |
|---|---|---|---|
| 1 | Slither | The most widely-used static analyzer β learn its detector categories and how to triage false positives | slither/detectors/ (detector implementations), README (usage), detector docs |
| 2 | Aderyn | Rust-based complement to Slither β faster, catches different patterns, understand the overlap | src/ (detector implementations), compare output against Slither on the same codebase |
| 3 | a16z ERC-4626 Property Tests | The gold standard for vault invariant testing β study how they encode properties as handler-based tests | ERC4626.prop.sol (all properties), README (integration guide) |
| 4 | Aave V3 Audit β OpenZeppelin | Major protocol audit from a top firm β study finding structure, severity classification, root cause analysis | Focus on Critical/High findings, trace each to the attack taxonomy |
| 5 | Trail of Bits Public Audits | Dozens of real audit reports β build your finding taxonomy across protocols | Pick 3 DeFi audits, classify every High finding into the Attack Patterns categories |
| 6 | Certora Tutorials | Introduction to formal verification β write CVL specs for simple protocols | 01.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:
- OWASP Smart Contract Top 10 (2025)
- Three Sigma β 2024 most exploited DeFi vulnerabilities
- SWC Registry (Smart Contract Weakness Classification)
- Cyfrin β Reentrancy attack guide
Audit reports:
- Trail of Bits public audits
- OpenZeppelin audits
- Cyfrin audit reports
- Spearbit
- Immunefi bug bounty writeups
Testing:
- Foundry invariant testing docs
- RareSkills β Invariant testing tutorial
- Cyfrin β Fuzz testing and invariants guide
Static analysis:
Formal verification:
Practice:
Navigation: β Module 7: Vaults & Yield | Module 9: Integration Capstone β