Section B · Technical Core

Error Handling & Failure Modes

The bug catalogue every senior security engineer carries in their head. For each: the shapes it takes, how to find it, how to formally rule it out, how to design it out.

The bug taxonomy

You should be able to recite the high-impact bug classes cold. The eight categories below cover roughly 90% of DeFi incidents over the past decade:

  1. Reentrancy (classic + variants)
  2. Oracle manipulation
  3. Math / rounding / precision
  4. Access control failures
  5. Donation / share-dilution / first-depositor
  6. MEV-induced griefing
  7. Cross-chain message ordering
  8. Upgradeability / storage layout / initialization

Reentrancy — all the flavors

Classic reentrancy

External call before state update; callee re-enters and exploits stale state. The DAO hack. Solved by CEI or nonReentrant.

// VULNERABLE
function withdraw() external {
    uint256 bal = balances[msg.sender];
    (bool ok,) = msg.sender.call{value: bal}(""); // external call
    require(ok);
    balances[msg.sender] = 0;                      // state update after
}

Cross-function reentrancy

Function A makes external call. Callee re-enters via function B (not A). A's nonReentrant doesn't help if B is on a different lock. Fix: shared lock across all state-touching functions.

Cross-contract reentrancy

Contract A calls Contract B. B re-enters A via a third contract that calls A. Cascading. Requires reasoning across multiple contracts.

Read-only reentrancy

The callee re-enters a view function on the original contract that other protocols depend on. The view returns stale state mid-call. Curve's 2023 incident hit several protocols this way. Mitigation: the view function should also revert if a reentrancy is in progress, or consumers should not call views from within state-modifying paths.

ERC-777 callback reentrancy

ERC-777 tokens trigger tokensReceived hooks on the recipient. If your contract receives such a token, the sender's send() call hands execution to the recipient's hook before your contract's internal state has been updated. Mitigation: avoid ERC-777 support without explicit guards, or use OpenZeppelin's pull-pattern.

ERC-721 onERC721Received

Safe transfers call onERC721Received on the recipient. Same pattern; same hazard. Marketplaces and lending protocols have shipped reentrancy bugs through this hook.

How to prove it out (CVL)

// rule: state updates happen before external calls (CEI)
rule effectsBeforeInteractions(method f) filtered { f -> !f.isView } {
    env e;
    calldataarg args;
    // Track that no external call has been observed before any state modification
    // (in practice: use hooks on Sstore / Call opcodes and ghost state)
    // Sketch only — full proof requires hook scaffolding
    f(e, args);
    assert true; // placeholder
}

Oracle manipulation

Single-block manipulation

Attacker takes a flash loan, swaps in a target AMM pool, reads the manipulated mid-price, exploits a protocol that uses that price as oracle, swaps back. All in one block. Cream Finance, bZx variants, many others.

Mitigation: don't use spot AMM price as oracle. Use Chainlink, or use a TWAP (time-weighted average) which is much harder to manipulate over a window.

Multi-block manipulation

Attacker controls block production for N consecutive blocks (rare on Ethereum, more plausible on validator-light L2s) and pushes a TWAP. Mitigation: longer TWAP windows; cross-source validation.

Donation manipulation

Attacker donates tokens directly to a pool (transfer, not deposit). Pool's internal accounting and on-chain balance diverge. Code that reads balanceOf(address(this)) as a proxy for "deposits" gets fooled. Mitigation: track deposits in storage explicitly; don't use raw balanceOf as accounting.

Flash-loan amplified manipulation

Flash loans give an attacker effectively-unlimited working capital within one block. Any oracle that's manipulable with capital becomes manipulable for free. Mitigation: oracle sources should be either off-chain (Chainlink) or TWAP-based with windows > 1 block.

How to design it out

Use Chainlink (with hardened wrapper from 06) for asset prices. Use protocol-internal exchange rates (vault.totalAssets() / vault.totalSupply()) only when these can't be manipulated within the call. Pin oracle reads to the start of the call; don't re-read after state changes.

Math bugs

Rounding direction

Convert assets-to-shares: round down. Convert shares-to-assets for withdrawal: round down (user receives less, protocol keeps the dust). For repayment / borrow tracking: round up against the user. The wrong direction lets users extract value via repeated tiny operations.

// OpenZeppelin ERC4626 standard pattern
function convertToShares(uint256 assets) public view returns (uint256) {
    return assets.mulDiv(totalSupply() + 10**decimalsOffset, totalAssets() + 1, Math.Rounding.Floor);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
    return shares.mulDiv(totalAssets() + 1, totalSupply() + 10**decimalsOffset, Math.Rounding.Floor);
}

Share inflation / first depositor

Empty vault, attacker deposits 1 wei → gets 1 share. Donates 10000 underlying → vault has 10001 assets, 1 share. Next depositor's 9999 wei rounds to 0 shares → attacker captures the deposit. Mitigation: virtual shares / dead shares / decimal offset.

Overflow (pre-0.8)

Pre-Solidity-0.8, uint256 wraps around on overflow. SafeMath was required. Modern code uses 0.8+, which reverts. But unchecked {} blocks disable checks; review them carefully.

Precision loss in fixed-point

DeFi typically uses fixed-point with 18 decimals (WAD) or 27 (RAY). The classic mistake: (a * b) / c if a*b overflows; or a / c * b which truncates before multiplying. Use mulDiv from OpenZeppelin or Solady.

How to prove it (CVL)

rule conversionRoundsAgainstUser() {
    uint256 assets;
    uint256 shares = convertToShares(assets);
    uint256 backToAssets = convertToAssets(shares);
    assert backToAssets <= assets; // user never extracts more than they deposited
}

Access control failures

Missing modifier

A privileged function ships without onlyOwner. Anyone calls it; protocol drained. This still happens. Defense: a CVL parametric rule that every state-changing function either reverts for non-admin or doesn't touch admin storage.

Wrong msg.sender in delegatecall

Implementation behind a proxy: msg.sender is the proxy caller, not the proxy. A check like require(msg.sender == owner) in a library called via delegatecall works because the storage and context are the proxy's. But a library called via call gets a different sender. Confusion here ships bugs.

Frontrunnable initialize

Proxy deployed; implementation has an initialize() not yet called; an attacker calls it first, becomes owner. Mitigation: deploy + initialize in the same tx (factory pattern), or use OpenZeppelin's _disableInitializers() in the implementation constructor.

Privileged-role grant

Role-based access control (OZ AccessControl). Granting a powerful role through governance is normal; granting it through a less-protected function is a bug. Audit every grantRole path.

Donation / first-depositor share dilution

Already covered under math, but worth its own callout: this single bug class has cost DeFi nine figures cumulatively. Cream, Hundred Finance, several smaller vault protocols. Mitigation patterns:

  • Virtual shares / decimal offset (OpenZeppelin ERC4626 default).
  • Dead shares minted to address(0) on first deposit.
  • Minimum first deposit enforced in the contract.
  • Track deposits internally instead of reading balanceOf.

For new vault designs, default to the OpenZeppelin pattern. Audit how it composes with your fee logic — fee accounting can re-introduce the bug.

MEV-induced griefing

Not always loss-of-funds, but griefing through ordering. Examples:

  • Sandwich attacks on AMM swaps. Attacker frontruns your swap, backruns it; you pay worse price.
  • Liquidation MEV. Multiple liquidators race for the same position; protocol gets liquidated, healthy users see gas spikes.
  • Just-in-time liquidity. Concentrated-liquidity AMMs see attackers add LP just before a known swap, capture fees, remove.
  • Initialize / first-action front-running. Already covered.

Mitigations: slippage tolerance on user-facing calls; private mempool / order flow; commit-reveal for sensitive operations; auction mechanisms (intents, MEV-share).

Cross-chain message ordering

When messages cross between chains (LayerZero, Wormhole, native bridges, Hyperlane, Axelar), ordering and replay become first-class concerns:

  • Replay. Same message delivered twice; same effect applied twice. Mitigation: per-message nonces stored on receiving chain.
  • Out-of-order delivery. Message B (decrement) arrives before message A (deposit). Mitigation: causal ordering or commutative-only operations.
  • Message forging. Validators / oracles compromised; arbitrary message forged. Wormhole 2022 was here. Mitigation: validator-set audit, threshold signatures, on-chain proof verification.
  • Source-chain rollback. Optimistic rollups have a 7-day window; a finalized message on L2 might not be finalized on L1.

Worked example: for each bug class, the three motions

The triad every senior security engineer should be able to apply:

  1. How to find it — what does it look like in code? What's the signature pattern?
  2. How to formally rule it out — what CVL invariant / Foundry invariant test proves you don't have it?
  3. How to design it out — what architectural pattern makes the bug class unrepresentable?

Worked: donation attack

  • Find: Vault contract; first-deposit handler; ratio of shares to assets calculated from balanceOf; no virtual shares.
  • Prove: CVL invariant: forall users: convertToAssets(convertToShares(x)) <= x; AND totalSupply > 0 => totalAssets > 0.
  • Design out: Apply OpenZeppelin ERC4626 with decimal offset; track deposits internally; mint 1000 wei of dead shares on first deposit.

Worked: read-only reentrancy

  • Find: A view function whose return value is consumed by an external protocol. State updated after the call. The view returns a value derived from in-flight state.
  • Prove: Hard to prove without modeling external callers. CVL hook on Sstore + Call: assert no external call within a function body modifies the view's storage between callers' reads.
  • Design out: View function reverts under lock; or expose a "checkpoint" view that's only updated at end-of-tx; or use transient storage to expose only post-update values.