Week 16 — Report Writing & Capstone
“The finding you cannot communicate is worth zero. Sixteen weeks of EVM traces, fork tests, invariants and PoCs collapse into one artifact: the report. It is what the client signs off on, what devs grep for the fix, what investors price risk against, what future auditors lean on during the re-audit. If your report is bad, the bug doesn’t exist in any organizational sense — and the protocol ships.”
Tags: web3-security report-writing capstone professional-practice audit-deliverable Learner: Past Tuan-15-Audit-Methodology-Tooling → completing the course as a hireable auditor Time: 7 days lesson + 40h capstone over 1–2 weeks Related: Tuan-15-Audit-Methodology-Tooling · Tuan-Bonus-Audit-Competition-Playbook · Tuan-Bonus-Bug-Bounty-Immunefi · Roadmap
1. Context & Why
1.1 The deliverable is the work product
Up to this week the course has trained the input side of auditing: reading code, modelling protocols, reproducing exploits in Foundry, computing storage slots, fuzzing invariants. That is half the job. The other half — the half that gets you paid, hired, and re-engaged — is converting that work into a written artifact a non-auditor can act on.
Every senior auditor will tell you the same thing in slightly different words: the report is the product. Trail of Bits, Spearbit, OpenZeppelin, ChainSecurity — none of them sell you “code-reading time”. They sell you a document. The reading is the cost of producing the document; the document is the value.
This week is therefore the most disproportionately high-leverage of the entire course. A mediocre auditor with an excellent report style is hired ahead of a brilliant auditor whose findings read like Twitter threads. The market signal lives in the PDF.
1.2 Who reads the report (and what they need from it)
A single report has at least five readers, each with different needs:
| Reader | What they want | What they skim, what they read |
|---|---|---|
| Lead dev / engineer fixing the bug | Exact location, reproducible PoC, recommended fix | Skim exec summary; read the finding, PoC line-by-line; read recommendation; verify in code |
| Protocol founder / project lead | Severity counts, headline risks, “are we shipping or not?” | Read exec summary thoroughly; skim findings; read every Critical and High |
| Investor / DD analyst | Independent third-party signal of code quality | Read exec summary; read methodology; read severity matrix; sample 1–2 High findings |
| Future auditor (re-audit, year 2) | What was already covered, what wasn’t, what the model of the system is | Read system overview; read trust assumptions; read appendix (invariants, threat model); use findings list as priors |
| Insurance underwriter / DAO risk team | Trust-assumption catalog, admin powers, dependency tree | Read trust-assumption section; read governance/upgrade discussion; read Critical/High findings only |
Two practical consequences:
- The executive summary does most of the work. Half the readers never read past it. Get this right and the rest is downstream.
- The body must be navigable. Severity-ordered findings, clear IDs, predictable structure. The dev who needs to fix M-04 should not have to read M-01 through M-03 first.
1.3 What “professional” actually means here
Throughout this lesson, “professional” is not a tone or a vocabulary — it is a contract with the reader. The professional report:
- Distinguishes severity from likelihood, and both from impact — never collapses them.
- Shows, doesn’t tell — every Medium+ has either a PoC or a precise reasoning chain that a reviewer can independently verify.
- Recommends, doesn’t dictate — leaves architecture choices to the team, but is specific enough that the team can implement without a follow-up call.
- Is auditable — a third party could re-grade your severity rationale and either agree or disagree on the same evidence, not on your tone.
- Has no decoration — no “we found that potentially…”, no “obviously this is bad”, no hedging that doesn’t add information.
This is the same standard Trail of Bits, Spearbit, ChainSecurity, OpenZeppelin apply to their public reports. Read three of them this week to internalize the cadence.
1.4 Learning goals for the week
- Write a full audit finding (Description → Impact → PoC → Recommendation → References) at industry quality.
- Distinguish severity, likelihood, and impact and defend a severity classification against client push-back.
- Produce all three report formats: private long-form, Code4rena/Sherlock competitive, Immunefi bounty disclosure.
- Run a remediation review pass, classify each finding’s status, and produce a remediation appendix.
- Begin the 40-hour capstone audit project on the synthetic multi-module protocol.
1.5 Primary references
2. The Anatomy of a Private Audit Report
The long-form private audit report (Trail of Bits, OpenZeppelin, Spearbit, ChainSecurity shape) is the gold standard. Competitive and bounty formats are condensations of this — the long form contains every section. Master this, the others fall out.
2.1 The canonical section order
1. Cover page
2. Disclaimer / legal
3. Table of contents
4. Executive summary
5. Scope & engagement details
6. Methodology
7. System overview
8. Trust assumptions
9. Findings (severity-ordered)
10. Severity matrix / findings list
11. Remediation review (after fix pass)
12. Appendix A — invariants checked
13. Appendix B — fuzz / formal verification campaigns
14. Appendix C — test coverage report
15. Appendix D — threat model
16. Appendix E — tooling output (Slither, etc.)
17. About the firm / author bios
Not every report uses every section, but the missing sections should be conspicuous. A report with no trust-assumption section in 2026 is a red flag about the auditor, not just the protocol.
2.2 Cover page
Looks ornamental, is load-bearing. Must contain:
- Protocol name (exact spelling, matching the marketing material).
- Client / engaging entity (legal entity, not the marketing name — important for contracts).
- Audit firm / author(s) with credentials.
- Engagement dates (start → end of code review; not the report-publication date).
- Code revision — commit hash (full 40-char SHA-1) of the audited code. Branch name alone is unacceptable; branches move.
- Report version (
v1.0for initial,v1.1for remediation-review revision, etc.) and date. - Confidentiality marker if applicable.
The commit hash is the single most important field. Without it, every finding is ambiguous — the dev “fixes” the bug but on a different revision; the auditor reviews main; nobody knows what was actually shipped.
Auditor’s reflex: paste the full commit hash into the report from git rev-parse HEAD, never type it. One-character mistakes are surprisingly common.
2.3 Disclaimer
A short paragraph. The standard content:
- The audit covers the specified commit hash only.
- Audits are not security guarantees; they are best-effort time-boxed reviews.
- The audit does not cover off-chain components unless explicitly scoped.
- The audit does not cover economic/game-theoretic risk beyond what is implementable in code, unless explicitly scoped.
- The audit is point-in-time; subsequent changes invalidate findings as written.
The disclaimer protects you legally. It does not let you skip work. Clients who push back on disclaimers are usually negotiating reputation risk; the answer is to standardize and not flex it per engagement.
2.4 Executive summary
The hardest section to write well. One to two pages. Structure:
- One-sentence statement of the engagement: “OpenZeppelin reviewed the smart contracts of FooProtocol’s v2 release between 2026-03-04 and 2026-04-12 at commit
8a1f....” - Scope in one paragraph: which contracts, total LoC, what was excluded.
- Methodology in one paragraph: how many person-days, which tools, what was the approach.
- Findings counts table:
Severity Count Critical 0 High 2 Medium 4 Low 7 Informational 11 - Top three concerns prose (1 paragraph each): the issues a founder must know about. Reference the finding IDs.
- Overall conclusion — one paragraph. State the security posture without using “highly secure” or “low risk” as throwaway phrases. Examples:
- “The protocol’s core invariants are sound but the upgrade path concentrates significant authority in a 2-of-3 multisig; we recommend the team strengthen this before mainnet.”
- “We identified two High-severity issues in the liquidation flow that, if exploited, could result in protocol insolvency. Both are addressed in
commit b3....”
Hard rule: never write “the protocol is safe to deploy.” That is a business decision the client makes; you state the technical facts and the residual risk.
2.5 Scope & engagement details
A table or list:
| File | LoC (sloc) | Description |
|---|---|---|
src/Vault.sol | 412 | ERC-4626-style vault |
src/LendingPool.sol | 689 | Lending market with single collateral |
src/oracles/ChainlinkAdapter.sol | 87 | Price feed adapter |
src/governance/Governor.sol | 230 | OZ Governor-derived |
src/governance/Timelock.sol | 152 | OZ TimelockController-derived |
| Total | 1,570 |
Also document:
- Out-of-scope: contracts in the repo not reviewed (e.g., test mocks, deployment scripts, frontend, third-party deps). Be explicit.
- Assumed-trusted dependencies: e.g., “we assume OpenZeppelin v5.0.2 is correctly implemented as published; bugs in OZ itself are not in scope.”
- Engagement model: fixed-price, hourly, retainer; number of auditors; total person-days.
Auditor pitfall: under-scoping. If you didn’t read it, mark it out of scope explicitly. Findings inside out-of-scope code are still discoveries worth disclosing — flag them as informational / “drive-by” with a note that they were not the focus of review.
2.6 Methodology
A short section describing how the review was conducted. This is not boilerplate — it tells future readers what was actually done and what gaps may exist.
Typical contents:
- Manual review: how much time, by how many auditors, what depth.
- Threat modeling: was a STRIDE / attack-tree exercise done? Reference Appendix D.
- Tools used: Slither (which detectors enabled), Echidna or Medusa (which properties), Halmos / Certora (which specs), forked-mainnet integration tests, etc.
- Invariant testing: how many invariants, how many runs, depth.
- External review: peer review by which auditor on the team.
Be honest. If you ran Slither once and skimmed output, write “automated static analysis with Slither, default detectors”. Do not write “comprehensive multi-tool dynamic verification”.
2.7 System overview
The often-skipped, often-most-valuable section. One to two pages of narrative prose describing what the protocol does. It serves three functions:
- Proves you understand the system. A reader who reads this and thinks “no, that’s not quite right” loses trust in every finding that follows.
- Documents the protocol for the next auditor. Without this, the year-2 audit team has to rebuild the model from scratch.
- Establishes shared vocabulary. Terms like “share”, “asset”, “position”, “collateral factor” should be pinned to specific code constructs.
Include a Mermaid diagram of contract topology if it helps. Annotate trust boundaries.
flowchart LR User --> Vault[Vault<br/>ERC-4626] User --> Pool[LendingPool] Vault --> Strategy[YieldStrategy] Pool --> Oracle[ChainlinkAdapter] Oracle --> Chainlink[(Chainlink Aggregator)] Pool --> Vault Gov[Governor + Timelock] -.admin.-> Vault Gov -.admin.-> Pool Gov -.admin.-> Oracle style Chainlink fill:#fff2cc style Gov fill:#cce5ff
2.8 Trust assumptions (separate section!)
Promote this out of the executive summary into its own first-class section. Lists, explicitly, what the protocol asks the user to trust:
| Trust | What can go wrong if violated |
|---|---|
| Admin (Timelock): can pause, set parameters, upgrade contracts | Malicious or compromised admin can drain via upgrade or push-bad-parameter |
| Oracle (Chainlink ETH/USD): assumed accurate within ±1% | Stale or manipulated price ⇒ wrong liquidations / wrong vault NAV |
Strategy contract: assumed to return at least principal - fees on withdraw | Insolvent strategy ⇒ vault accounts wrong, last-out users absorb the loss |
| Solidity compiler 0.8.24: assumed correct | Compiler bug (rare; cf. Curve/Vyper 2023) |
| OpenZeppelin Contracts v5.0.2: assumed correct | Library bug |
| Centralized sequencer (if on L2): assumed honest | Censorship; ordered MEV |
A discrepancy between marketing (“fully decentralized”) and trust assumptions (“upgradeable by 2-of-3 EOA multisig”) is itself a finding, typically Medium or High.
2.9 Findings — order, IDs, and the structure of an individual finding
Findings ordered by severity descending: Critical → High → Medium → Low → Informational. Within a severity, order by impact (most severe loss first).
Finding ID convention (industry standard varies, pick one and be consistent):
C-01,C-02— CriticalH-01,H-02— HighM-01, …,M-09— MediumL-01, … — LowI-01, … — InformationalG-01, … — Gas (some firms separate)
ID stability matters: once issued, never renumber. If you remove a finding after a fix, the ID is retired, not reassigned. The dev will reference these IDs in commit messages and PRs; renumbering breaks the audit trail.
Each finding follows the template in §3.
2.10 Severity matrix
A tabular index of all findings. Useful for skimming, also reused by investors and auditors.
| ID | Title | Severity | Status |
|---|---|---|---|
| H-01 | LendingPool ignores stale-price flag | High | Resolved |
| H-02 | Vault inflation attack via first-depositor donation | High | Resolved |
| M-01 | Liquidation rounding favors borrower | Medium | Acknowledged |
| M-02 | Oracle adapter accepts answer of zero | Medium | Resolved |
| … |
2.11 Remediation review
After the client patches, you do a second pass. Each finding gets a status update:
| Status | Meaning |
|---|---|
| Resolved | Fix verified, no regression observed |
| Mitigated | Risk reduced but not eliminated (e.g., admin function added that bypasses but is timelocked) |
| Acknowledged | Client agrees the issue exists, declines to fix (document the reason) |
| Disputed | Client disagrees the issue exists; document both positions |
| Open | No fix; no decision |
| Reopened | A previously-resolved fix introduced a regression |
The remediation review is its own ~1-2 page narrative + a per-finding table. Critically: verify each fix independently. Re-run the original PoC against the patched version. Don’t rely on the dev’s word.
Also explicitly check for regression: did the fix break a previously-correct property? And fix introduces new issue: a hasty patch that silently creates M-05. This is the part the client doesn’t pay extra for but you do anyway.
2.12 Appendices
| Appendix | Contains |
|---|---|
| Invariants checked | The full list of invariants modeled, with brief description, the test/property that verifies each, and outcome (held / violated / not tested) |
| Fuzz / FV campaigns | Tools used (Echidna, Medusa, Halmos, Certora), runtime, coverage, what was tested |
| Test coverage | forge coverage output for the audited code; gaps annotated |
| Threat model | STRIDE table or attack tree; who could attack what under what assumption |
| Tooling output | Slither output, with each finding triaged (true / false positive / N/A) |
Appendices give the reader confidence that the absence of high-severity findings reflects actual coverage, not absent review. A 30-page report with no appendices reads as marketing.
3. The Finding Template
This is the structural unit on which everything rests. Memorize it.
### [SEVERITY-ID] Title
| Field | Value |
|------------|---------------------------------------------|
| Severity | High (Impact: High, Likelihood: Medium) |
| Status | Resolved in commit b3f1... |
| Files | `src/LendingPool.sol:L142-L168` |
| Function | `LendingPool.liquidate` |
| Discovered | 2026-04-09 by <author> |
**Description**
(1–2 paragraphs of what is wrong, with code reference.)
**Impact**
(What is the concrete loss scenario? Who loses what, under what conditions?)
**Proof of Concept**
(Foundry test or shell session demonstrating the bug. Required for Med+.)
**Recommendation**
(Specific fix. Code patch if appropriate. Optional alternatives.)
**References**
(Related EIPs, prior audits, related findings in this report.)
3.1 ID and Title
Title is function-level specific when possible:
| Bad title | Good title |
|---|---|
| ”Reentrancy in vault" | "Vault.withdraw allows cross-function reentrancy via ERC-777 callback" |
| "Bad signature check" | "Permit.execute does not enforce low-s, enabling signature malleability replay" |
| "Math error" | "LendingPool._calculateDebt rounds in favor of the borrower, draining protocol fee balance over time” |
A title that does not name the function or the precise mechanism is a smell. The dev should be able to read the title alone and know roughly where to look.
3.2 Severity
State it as one of Critical / High / Medium / Low / Informational and attach the rationale (Impact + Likelihood). Use the matrix from §4.
3.3 Status
For initial reports: Reported. For remediation: one of Resolved / Mitigated / Acknowledged / Disputed / Open / Reopened with the commit hash if relevant.
3.4 Affected code
Be precise:
- File path relative to the repo root (not absolute).
- Line numbers as they exist at the audited commit.
- Function name.
If the bug spans multiple files, list all. If it’s a design-level issue, point to the central file and note “and related callers in Caller.sol, OtherCaller.sol”.
3.5 Description
Two paragraphs maximum. Structure:
- Paragraph 1: what the code is supposed to do (the intended behavior), with the code excerpt or quote.
- Paragraph 2: what it actually does, where the divergence occurs.
Do not write the impact here. Do not write the fix here. This section is purely the bug.
3.6 Impact
One paragraph. Answer: who loses what, under what conditions?
| Bad impact | Good impact |
|---|---|
| ”Funds could be lost." | "An attacker who controls 1 ETH and one block can drain the vault of any unallocated balance, currently ~50 ETH on the deployed test instance. The attack costs ~150k gas in addition to the flash-loan fee." |
| "This is a security risk." | "If exploited, all depositors lose their entire principal. The attack is one-transaction, no prerequisites beyond a flash loan." |
| "Could be DoS." | "An attacker can grief any liquidation by front-running with a 1-wei deposit, costing the liquidator the entire liquidation bonus. Per-attack cost: <$1. Per-block damage: any liquidation in the block.” |
The Impact section is where severity is justified. A High-severity finding with weak impact prose is the easiest thing to lose to client push-back.
3.7 Proof of Concept
For Medium and above: required. A test that compiles and passes (or fails, in the case of an invariant test). For Code4rena/Sherlock submissions: explicitly required for H/M.
The PoC must be:
- Self-contained — include the necessary setup; not “assume the state of mainnet at block N” unless you provide the fork URL and block.
- Minimal — show only the bug; not a 200-line vault deployment.
- Asserting — use
assertEq/assertTrueso the test passes when the bug is present, orassertLt/assertGtto show numerical drift. The reviewer should be able to run it and see green. - Annotated — comments at the key steps, explaining the attacker’s reasoning.
Example PoC scaffold (Foundry):
// test/PoC_M01.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract PoC_M01 is Test {
Vault vault;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
vault = new Vault(/* args */);
deal(address(asset), attacker, 1 ether);
deal(address(asset), victim, 100 ether);
}
function test_inflation_attack_drains_victim() public {
// 1. Attacker is the first depositor; deposits 1 wei, gets 1 share.
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vault.deposit(1, attacker);
// 2. Attacker donates 1 ether directly to the vault contract,
// inflating share price to 1 share == 1 ether + 1 wei.
asset.transfer(address(vault), 1 ether);
vm.stopPrank();
// 3. Victim deposits 0.5 ether. Due to rounding, victim mints 0 shares.
vm.startPrank(victim);
asset.approve(address(vault), type(uint256).max);
vault.deposit(0.5 ether, victim);
assertEq(vault.balanceOf(victim), 0, "victim has zero shares");
vm.stopPrank();
// 4. Attacker redeems the single share, capturing victim's deposit.
vm.prank(attacker);
vault.redeem(1, attacker, attacker);
assertGt(asset.balanceOf(attacker), 1.5 ether, "attacker took the deposit");
}
}Run command + expected output should also be in the report:
forge test --match-test test_inflation_attack_drains_victim -vvv3.8 Recommendation
This is where you earn the second engagement.
Style:
- Recommend, don’t dictate. Avoid “fix this by doing X”. Use “Consider X” or “We recommend X, because Y.”
- Be specific enough to implement. If you say “use CEI”, say where, and what the rewrite looks like.
- Offer alternatives where they exist. “Option 1: … Option 2: … We prefer Option 1 because …”
- Show code when the patch is short. Three to ten lines of patch is often clearer than three paragraphs of prose.
What to avoid:
- Recommending a fix that introduces a new bug. (Auditor lore: at least 10% of incident-report fixes contain a new vulnerability.)
- Recommending architectural changes that may break other guarantees. Flag the trade-off explicitly.
- Recommending a library upgrade without verifying the library’s current behavior.
Example recommendation (for an ERC-4626 inflation attack):
Recommendation: To mitigate the inflation attack on first-deposit, we recommend one of:
- Mint dead shares on construction. Mint a small but non-trivial number of shares (e.g.,
10**3or10**6) toaddress(0)or to the protocol treasury in the constructor. This sets a floor ontotalSupplyand prevents the share-price-inflation primitive. This is the OpenZeppelin v5 default (_decimalsOffset).- Refuse deposits below a minimum. Revert if
totalSupply == 0andassets < MIN_FIRST_DEPOSIT, whereMIN_FIRST_DEPOSITis large enough that share-price inflation is uneconomic.We recommend Option 1; it is the standard OpenZeppelin pattern and does not impose UX friction on first depositors.
3.9 References
The under-used section. Include:
- EIPs: relevant standards (EIP-4626 for vault, EIP-712 for signatures, etc.).
- Prior audits: link to other public audits where this bug class was discovered.
- Case studies: e.g., link to the 2022 Cream/Iron Bank post-mortem for read-only reentrancy.
- OpenZeppelin / Solady reference implementations that handle the case correctly.
- Related findings in this report: e.g., “see also M-04 which is an instance of the same root cause in a different module.”
References lend credibility (this is a known bug class) and help the dev (here’s the canonical fix). Skipping them is amateur.
4. Severity — the auditor’s most-political section
Severity ratings are where client push-back lives. Get the framework right and you can defend the rating with a one-paragraph response, not a thirty-minute call.
4.1 Impact × Likelihood matrix
The industry-standard frame (used by Trail of Bits, OpenZeppelin, and most firms):
| Impact: Low | Impact: Medium | Impact: High | |
|---|---|---|---|
| Likelihood: High | Medium | High | Critical |
| Likelihood: Medium | Low | Medium | High |
| Likelihood: Low | Informational | Low | Medium |
Definitions:
Impact (what happens if the bug fires):
- High: direct loss of user funds, protocol insolvency, permanent loss of control, governance takeover.
- Medium: partial loss, temporary freeze, value leak, broken core functionality without immediate loss.
- Low: griefing, UX degradation, ill-formed events, minor accounting drift.
Likelihood (probability of exploitation in production):
- High: low pre-conditions (no admin compromise, no specific timing, no rare token), economically rational, can be triggered by any user.
- Medium: requires specific conditions (a particular admin action, a flash-loan-sized amount, a specific token integration), but those conditions are plausible.
- Low: requires admin compromise, specific timing, multi-step social-engineering, or other rarities.
4.2 Sherlock’s tighter rules (competitive)
Sherlock (and similar competitive platforms) impose quantitative thresholds for severity, because subjective judgment doesn’t scale across hundreds of judges. Memorize these:
High: direct loss of funds without (extensive) limitations on external conditions, where:
- Users lose >1% AND >$10 of principal, OR
- Users lose >1% AND >$10 of yield, OR
- Protocol loses >1% AND >$10 of fees.
Medium: loss requires specific external conditions or constrained loss, where:
- Users lose >0.01% AND >$10 of principal, OR
- Users lose >0.01% AND >$10 of yield, OR
- Protocol loses >0.01% AND >$10 of fees, OR
- Breaks core protocol functionality even without direct loss.
Time-based qualifier: funds locked >1 week AND impacts time-sensitive functions = Medium; both conditions present = High.
For private audits, you do not have to use these exact thresholds, but the principle — that severity ties to magnitude and condition density — is universal.
4.3 Immunefi’s classification (bug bounty)
Immunefi v2.3 (verify current at study time):
Smart contracts — Critical: direct theft of funds at rest or in motion (excluding unclaimed yield), permanent freeze, governance manipulation outcome, insolvency, RNG abuse for theft.
Smart contracts — High: theft/freeze of unclaimed yield/royalties, temporary fund freeze.
Smart contracts — Medium: operational failure due to insufficient tokens, block stuffing, gas theft, unbounded gas.
Smart contracts — Low: contract fails to deliver promised returns but doesn’t lose value.
The Immunefi rubric is impact-first: it doesn’t separately weight likelihood (the bug bounty assumes the vulnerability can be reached; the rubric grades what happens once it is).
4.4 Defending severity under push-back
The client (or their dev) will push back on severity. They have reasons — partly defensive (“we don’t want our public report to say Critical”), partly economic (“a Critical may delay launch”), and partly genuine (“we believe this is mitigated by X”).
The auditor’s posture: the severity reflects the technical facts on the audited commit; if the facts are wrong, change the severity; if the facts are right, the severity stands.
Common push-back patterns and the auditor’s response:
| Client claim | Auditor response |
|---|---|
| ”This requires admin to misbehave; the admin is a multisig, so it’s not realistic." | "Trust assumptions are documented separately; the finding is about what happens if admin misbehaves. If you want, we can downgrade to Low and add a Critical finding for ‘protocol depends on multisig integrity with no slashing/timelock-second-key’." |
| "This requires a flash loan, no attacker has $50M." | "Flash loans are commodities at 1B sizes on Aave and Uniswap V3 in 2026. The capital constraint is gas + flash-loan fee, ~ $5k. The bug is exploitable." |
| "This requires a specific token (USDT-style non-bool return). We don’t list USDT." | "Your integration interface accepts arbitrary ERC-20 tokens (addPool(token) is permissionless). USDT is on your list or will be added by governance — both are realistic. Severity remains as stated; we can add a section in trust assumptions noting that the listing process is the mitigation surface." |
| "This is theoretical; it’s never been exploited." | "Reentrancy was theoretical in 2015. Read-only reentrancy was theoretical in 2020. Both are now nine-figure loss categories. We do not grade based on whether exploitation has occurred; we grade based on whether it is possible." |
| "This is an issue in our dependency, not us." | "If the dependency is in your audited code path, you inherit the bug. If you want to scope it out, we’ll move it to Informational with a ‘consumer responsibility’ note, but it remains a finding." |
| "Can’t you make it Informational?" | "Informational means ‘no security impact’. This has security impact. The smallest move I can make in good faith is Low.” |
The trick: every push-back response refers to a written framework (severity definition + trust assumption + scope). When the conversation becomes about the framework rather than your subjective judgment, you win.
4.5 The “Informational” trap
Junior auditors over-dump into Informational because they think more findings = better report. This is wrong.
Informational is reserved for items that are:
- Style/documentation improvements with no security implication.
- Best-practice deviations that have no exploitable consequence on the audited commit.
- Forward-compatibility flags (e.g., “this Solidity version is supported until X; consider upgrading”).
If a finding has any security implication, however small, it is Low or higher.
Informational findings are skimmed and forgotten. A report with 40 Informational and 1 Medium reads as padded — and the Medium is harder for the founder to find. Discipline: target ≤ 10 Informational findings unless the codebase is genuinely undocumented.
4.6 Gas findings — separate or integrated?
Two schools:
- Trail of Bits / OpenZeppelin: gas findings are not security findings; either they go in a separate Gas section or are entirely out of scope.
- Code4rena: gas findings are graded separately (their own G-prefixed IDs and their own pool).
Auditor’s view: keep gas separate. A 1000-gas optimization in a hot loop is interesting; mixed into the same severity ladder with reentrancy, it dilutes the report.
5. Three Formats — Same Skill, Different Containers
5.1 Private audit report (long-form)
What we’ve described in §2–4. Audience: client team + future readers. Length: 20–80 pages, depending on codebase size.
Workflow:
- Draft each finding as you discover it (don’t batch at the end — you’ll forget).
- Internal peer review by another auditor on the team.
- Initial report → client review → remediation → remediation review → final report.
- Final report is what becomes public (if the client publishes).
5.2 Competitive (Code4rena, Sherlock, Cantina) write-up
Audience: judges who will see 100+ submissions per audit. Length: 1–3 pages per finding. Compressed.
Critical differences from private:
| Aspect | Private | Competitive |
|---|---|---|
| Length | Generous | Tight — judges are tired |
| Tone | Collaborative (“we recommend”) | Submission-style (“the issue is”, “mitigation:“) |
| PoC | Strongly preferred | Mandatory for H/M on most platforms |
| Severity | Defensible by argument | Bound to platform’s quantitative rubric (esp. Sherlock) |
| Duplicate risk | None | High — your unique angle wins |
| Recommendation | Architectural, with alternatives | One concrete fix, short |
Competitive template (Code4rena-style):
## [H-01] LendingPool.liquidate uses spot price, enabling sandwich attack on collateral seizure
### Lines of code
https://github.com/code-423n4/2026-XX-foo/blob/main/src/LendingPool.sol#L142-L168
### Vulnerability details
[1-2 paragraphs of mechanism]
### Impact
A liquidator can sandwich their own liquidation tx: deposit large size, manipulate spot
price down via DEX swap, call liquidate(), then unwind. The collateral seized is undervalued
by the sandwich amount, and the seized assets are sold at the manipulated price. Loss to
the protocol on a $1M position: ~$50k per liquidation. Attackable per-liquidation.
### Proof of Concept
[Foundry test that compiles and demonstrates the loss]
### Tools Used
Foundry, manual review.
### Recommended Mitigation Steps
Use the TWAP oracle (already imported as `oracle.twap()`) instead of spot price in
`_seizeCollateral()`. Patch:
```diff
- uint256 price = oracle.spot();
+ uint256 price = oracle.twap(30 minutes);
Assessed type
Math / oracle
**Competitive tips**:
- *Submit early.* First valid finding gets full reward; duplicates split.
- *Be uniquely specific.* If your title and impact are generic, you risk being marked duplicate of someone's better write-up.
- *Always run the PoC yourself.* A PoC that doesn't compile = invalid submission on Sherlock; downgraded on C4.
- *Don't pad with Informational.* On Code4rena, low-effort QA submissions hurt your signal score.
### 5.3 Bug bounty (Immunefi) disclosure
Audience: triage engineer at the protocol, fast. The triager has to decide in 30 minutes: real bug, severity tier, escalate or not.
Format (Immunefi-standard):
Title
[High-level summary, 1 line]
Asset in scope
[Specific contract address + chain]
Severity (per program rubric)
[Critical / High / Medium / Low, with brief reason]
Vulnerability description
[2-3 paragraphs. Mechanism + why this is the bug.]
Impact
[Concrete loss scenario + magnitude. This determines the bounty.]
Steps to reproduce
- …
- …
- …
Proof of Concept
[Foundry test or transaction hash on testnet]
Recommended fix
[Brief; protocol team will write the actual patch]
References
[CWE, prior incidents, EIP]
**Bug bounty tips**:
- *Lead with impact, not mechanism.* Triage skims; the impact line decides if they keep reading.
- *Include a working PoC.* "Theoretical" bug bounty submissions are routinely rejected on Immunefi.
- *Submit privately first.* Public disclosure before bounty acceptance forfeits the bounty.
- *Be explicit about cost-to-attack.* Immunefi often asks "is this economically rational"; pre-emptively answer.
---
## 6. A Full Sample Finding (drawn from Week 5 material)
Below is a complete sample finding write-up at professional quality. Annotate it as a model.
---
### [H-01] `Vault.deposit` is vulnerable to the ERC-4626 inflation attack via direct asset donation
| Field | Value |
|------------|------------------------------------------------------|
| Severity | High (Impact: High, Likelihood: Medium) |
| Status | Reported |
| Files | `src/Vault.sol:L92-L118` |
| Function | `Vault.deposit(uint256 assets, address receiver)` |
| Discovered | 2026-04-09 by audit team |
**Description**
`Vault.sol` implements an ERC-4626-style tokenized vault. The conversion from `assets` to `shares` on deposit follows the standard formula:
```solidity
// src/Vault.sol:L98
function _convertToShares(uint256 assets) internal view returns (uint256) {
return totalSupply() == 0
? assets
: (assets * totalSupply()) / totalAssets();
}
totalAssets() returns asset.balanceOf(address(this)) — the raw asset balance of the vault. The vault accepts the first depositor at a 1:1 ratio (totalSupply == 0 branch), then prices subsequent depositors via the ratio of assets : totalAssets.
This is the canonical ERC-4626 first-depositor / inflation-attack shape. An attacker who is the first depositor can manipulate the share price by transferring assets directly into the vault contract (bypassing deposit). Because the share price is computed from totalAssets() — which includes this donation — but totalSupply() reflects only the attacker’s single share, the price-per-share becomes arbitrarily large. A subsequent victim depositing a non-trivial amount may receive zero shares due to integer truncation, and the attacker can withdraw their share to capture the victim’s deposit.
The vault does not implement OpenZeppelin’s _decimalsOffset mitigation, nor does it mint dead shares to a sink address on construction. It also accepts deposits of any size from the empty state.
Impact
A first-depositor attacker permanently siphons subsequent deposits made by victims whose assets * totalSupply / totalAssets calculation truncates to zero shares. Concretely:
- Attacker is the first depositor with
1 wei→ mints 1 share,totalSupply = 1. - Attacker transfers
Xassets directly to the vault viaasset.transfer(address(vault), X).totalAssets()is nowX + 1 wei. - Victim deposits
Vassets whereV < X + 1 wei. Shares minted:(V * 1) / (X + 1), which truncates to0whenV < X + 1. - Attacker calls
redeem(1)and receives the full vault balance≈ X + V, nettingV.
On a deployment with no protective parameters and a USDC-denominated vault, an attacker can drain any first-N-deposit-block deposits up to the donation size. The attack is one-block (attacker can use MEV ordering to ensure deposit precedes victim) and one-transaction once a victim deposit appears in the mempool. The capital cost is dust + donation; the gain is the donation-sized capture.
This is not a theoretical class. Direct instances of this bug shape have caused mid-to-high six-figure losses across multiple deployed vaults; ERC-4626 v1 was specifically revised and _decimalsOffset introduced in OZ v5 to address it.
Proof of Concept
// test/PoC_H01_InflationAttack.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "../src/mocks/MockERC20.sol";
contract InflationAttackPoC is Test {
Vault vault;
MockERC20 asset;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
asset = new MockERC20("USDC", "USDC", 6);
vault = new Vault(IERC20(address(asset)), "vUSDC", "vUSDC");
asset.mint(attacker, 100_000e6);
asset.mint(victim, 10_000e6);
}
function test_first_depositor_drains_victim() public {
// Step 1: attacker becomes first depositor with 1 wei.
vm.startPrank(attacker);
asset.approve(address(vault), type(uint256).max);
vault.deposit(1, attacker);
assertEq(vault.totalSupply(), 1, "attacker has 1 share");
// Step 2: attacker donates 10_000 USDC directly to the vault.
asset.transfer(address(vault), 10_000e6);
vm.stopPrank();
// Step 3: victim deposits 9_999 USDC (less than donation).
vm.startPrank(victim);
asset.approve(address(vault), type(uint256).max);
vault.deposit(9_999e6, victim);
assertEq(vault.balanceOf(victim), 0, "victim received zero shares (truncation)");
vm.stopPrank();
// Step 4: attacker redeems single share.
vm.prank(attacker);
vault.redeem(1, attacker, attacker);
// Verify attacker captured victim's deposit.
uint256 attackerBalanceAfter = asset.balanceOf(attacker);
// Attacker started with 100_000e6, spent 1 wei + 10_000e6 donation, then redeemed.
// Expected take: (10_000e6 donation + 9_999e6 victim) - 10_000e6 donation = ~9_999e6 victim deposit.
assertGt(attackerBalanceAfter, 100_000e6 - 1, "attacker profited from victim");
emit log_named_uint("attacker net gain (USDC, 1e6)", attackerBalanceAfter - (100_000e6 - 10_000e6 - 1));
emit log_named_uint("victim shares", vault.balanceOf(victim));
}
}Run command and expected output:
$ forge test --match-test test_first_depositor_drains_victim -vvv
Running 1 test for test/PoC_H01_InflationAttack.t.sol:InflationAttackPoC
[PASS] test_first_depositor_drains_victim() (gas: 312_488)
Logs:
attacker net gain (USDC, 1e6): 9999000000
victim shares: 0
Recommendation
We recommend adopting OpenZeppelin’s standard ERC-4626 inflation-resistant pattern, which uses a virtual share/asset offset to set a non-zero floor on totalSupply. Two minimally-invasive options:
Option 1 (preferred): use OpenZeppelin v5 ERC4626Upgradeable with _decimalsOffset()
OZ v5’s ERC4626 exposes a _decimalsOffset() hook that returns the number of “virtual shares” to maintain. The default is 0; override to return a small but non-trivial value (e.g., 3 or 6):
// In Vault.sol
function _decimalsOffset() internal view virtual override returns (uint8) {
return 6;
}This sets the share-to-asset ratio’s denominator floor at 10**(decimals + 6), raising the cost of inflation attack by 10**6× and making it economically irrational for any reasonable victim deposit size. See: https://docs.openzeppelin.com/contracts/5.x/erc4626 .
Option 2: mint dead shares on construction
If the OZ base is not desirable, mint a fixed quantity of shares to address(1) (or to the protocol treasury) in the constructor:
constructor(IERC20 _asset, string memory _name, string memory _symbol) ERC20(_name, _symbol) {
asset = _asset;
_mint(address(1), 1_000); // dead shares; floor totalSupply at 1_000
}This achieves the same effect; the trade-off is that the dead shares’ value (proportional to vault deposits) is “burned” — a small permanent dilution.
Option 1 is preferred because it integrates with the standard ERC-4626 interface, is well-audited, and does not require custom constructor logic that may interact with proxy patterns in unexpected ways.
References
- EIP-4626: https://eips.ethereum.org/EIPS/eip-4626
- OpenZeppelin Contracts v5 ERC-4626 documentation: https://docs.openzeppelin.com/contracts/5.x/erc4626
- ERC-4626 inflation attack write-up (OpenZeppelin Blog): https://blog.openzeppelin.com/a-novel-defense-against-erc4626-inflation-attacks
- See also M-04 (this report): related rounding-direction issue in
Vault.previewWithdraw.
The above sample is roughly one page printed. Note the structure: the description does not editorialize; the impact gives concrete numbers; the PoC compiles and the output line proves the bug; the recommendation offers options with reasoning. The tone is neutral throughout.
7. Lab — Three Exercises
7.1 Lab 1: Write a full audit finding for a Week 5/7 bug you’ve already reproduced
Pick one of:
- The classic single-function reentrancy from Week 5 §2.4.
- The read-only reentrancy from the Week 5 stretch lab / Case-Cream-Iron-Bank-2021.
- The signature replay from Week 5 §7.5.
- The ERC-4626 inflation attack from Week 7 (or the sample finding above as a starting template).
Requirements:
- Use the full finding template from §3.
- Severity must be assigned with explicit Impact + Likelihood justification in the table.
- PoC must be a Foundry test that compiles and runs (paste both the test and the
forge test -vvvoutput into the report). - Recommendation must offer at least two options if there are real alternatives.
- References must include at least one EIP, one prior public audit / case study, and a related finding ID (you can fabricate the related ID, just demonstrate cross-linking style).
Deliverable: a markdown file ~/web3-sec-lab/wk16/finding-01.md of ~1–2 pages.
Self-grading checklist before considering it done:
- Title names the function and the mechanism.
- Description is < 2 paragraphs, no editorializing.
- Impact has a concrete number (amount lost, attack cost, attacker preconditions).
- PoC includes both the code and the
forge test -vvvoutput. - Recommendation is implementable without follow-up.
- References include at least three items.
- No use of “obviously”, “clearly”, “simply”, “potentially” (without explanation).
- No first-person (“I think”).
7.2 Lab 2: Critique a published audit report
Pick one public audit report from:
- Trail of Bits: https://github.com/trailofbits/publications (filter for
blockchain, recent). - Spearbit Portfolio: https://spearbit.com/portfolio
- OpenZeppelin: https://blog.openzeppelin.com/security-audits
- ChainSecurity: https://www.chainsecurity.com/security-audits
- Cantina: https://cantina.xyz/portfolio
Read it cover-to-cover. Then write a ~1-page critique answering:
- What’s done well? Specific examples: a finding whose impact section is excellent, a methodology section that’s transparent, a remediation review that’s honest.
- What could be clearer? Specific examples: a finding whose severity is hard to defend, a missing trust-assumption section, an executive summary that buries the lead.
- What would you have written differently? Pick one finding and rewrite its impact paragraph or its recommendation in your own words.
- What’s the firm’s “house style”? Tone, structure, formatting choices — would you recognize one of their reports without the cover page?
This is a critical-reading exercise. Most auditors never do this and their reports remain in the style they first imitated. Doing it once explicitly accelerates your own style by months.
Deliverable: ~/web3-sec-lab/wk16/critique.md.
7.3 Lab 3: Begin the capstone (specified in §10)
The capstone is the deliverable on which the course’s terminal assessment hinges. Start setup this week; complete it across weeks 17–18 if you treat this as a 16-week program with a 2-week capstone tail.
This week’s Lab 3 task is only:
- Clone or scaffold the capstone protocol (per §10 spec).
- Read every external function once, with the only goal being to write the system overview narrative (§2.7).
- Draft the trust assumptions table (§2.8).
- Identify and list at least 10 invariants you will test (§10.3).
You will not yet write findings. You will not yet PoC anything. The first 8 hours of the capstone are pure modeling.
8. Anti-patterns Checklist for Report Writing
When reviewing your own draft (or another auditor’s), screen for:
- Vague title (“Reentrancy issue”) — should name function + mechanism.
- Severity without rationale — every Med+ has Impact + Likelihood justification.
- Impact paragraph without a number — “funds could be lost” without specifying how much and under what conditions.
- No PoC for Med+ — for private reports, this is allowed only for design-level findings; for competitive, never allowed.
- PoC that doesn’t compile / doesn’t assert — wastes the reviewer’s time, undermines credibility.
- Recommendation that dictates architecture — “rewrite the contract using …” instead of “consider option A or option B”.
- Recommendation that introduces a new bug — re-review your own fix idea before submitting.
- Editorializing — “obviously bad”, “this is a textbook bug”, “surprised this passed review” — neutral language only.
- First-person voice — “I think”, “we feel” — use “this finding identifies” or “the engagement found”.
- Hedging without reason — “could potentially possibly” — say what you know, then state uncertainty separately if it exists.
- Missing chain-id / commit-hash binding — every finding implicitly references the audited commit; every cross-chain bug names the chains involved.
- Severity creep into Informational — 30 Informational findings drown the Medium signal.
- Severity deflation under push-back — “we agreed to call it Low because the client doesn’t like High” — the rationale should be technical, not social.
- Missing references — every finding should cite at least an EIP or a prior comparable bug.
- Findings unordered or weirdly ordered — severity descending, impact-within-severity descending.
- Severity matrix omitted — single-table index lets the reader skim.
- Remediation review absent or boilerplate — re-verify each fix; don’t trust the dev’s claim.
- No “regression check” in remediation — fixes can introduce new bugs; spot-check related code.
- Missing scope / out-of-scope split — readers need to know what wasn’t reviewed.
- Missing trust-assumptions section — every report after 2024 should have this; its absence is a tell.
- No system overview — readers (and future auditors) need the model.
- PDF without selectable text — a small thing, but unsearchable PDFs make findings annoying to reference.
9. Trade-offs (format & style choices)
| Decision | Option A | Option B | Auditor’s view |
|---|---|---|---|
| PoC inline vs appendix | Inline in the finding | All PoCs in an appendix | Inline. Forces the reader to read the proof while reading the finding; appendix-only invites skipping. |
| Severity model: Impact×Likelihood vs single-axis | I×L matrix (TOB / OZ style) | Single severity axis (Sherlock / Immunefi style) | I×L for private; single-axis (with quantitative thresholds) for competitive. Defending severity is much easier with I×L. |
| Gas as findings vs separate section | Findings list | Separate “Gas” section | Separate. Mixing dilutes severity perception. |
| Markdown source vs LaTeX vs Notion | Markdown + pandoc | LaTeX (Spearbit’s tool) | Markdown for solo / small firms; LaTeX or Spearbit’s tool for high-volume or PDF-formal output. Notion is fine for collaborative drafts but exports awkwardly. |
| Remediation as separate report vs revised version of original | Revised v1.1 of original | Separate “remediation review” document | Revised v1.1. Single document with status column. Otherwise readers chase two PDFs. |
| Public release: full report or summary | Full report (TOB / Spearbit) | Marketing-summary only (some firms) | Full report. The marketing summary is read once; the full report is referenced for years. |
| Naming findings: numbered or descriptive | H-01, M-04 | vault-inflation-attack, lend-rounding | Numbered. Descriptive names break when findings are renumbered, merged, or split during review. |
| Severity discussions: in finding or in exec summary | Per-finding rationale only | Exec summary discusses severity choices | Per-finding rationale + exec summary highlights only the most-contested ones. |
| Tone: collaborative vs detached | ”We recommend…" | "The auditor recommends…" | "We recommend” is industry-standard and reads naturally; “the auditor” is overly formal. Reserve detached voice for legal sections. |
| Cross-linking: heavy or sparse | Every related finding cross-linked | Self-contained findings | Heavy. The reader who reads M-07 cold benefits from knowing M-03 is the same root cause in a different module. |
10. Capstone Audit Project — Full Specification
This is the terminal deliverable of the course. Allocate 40 hours across 1–2 weeks. Treat it as a real engagement: scope, plan, audit, write, remediation-review.
10.1 The synthetic protocol (FortyTwo Finance)
You will audit a fictional protocol “FortyTwo Finance”, a multi-module DeFi system. The scope:
Module 1 — Vault (src/Vault.sol)
- ERC-4626-compliant tokenized vault.
- Wraps an underlying ERC-20 (e.g., USDC).
- Routes deposits into a yield strategy.
- ~400 LoC.
Module 2 — Yield Strategy (src/Strategy.sol)
- Simple strategy: holds the underlying, accrues fixed interest per block (mock yield).
- Has a
harvest()function to realize gains, amigrate()function to swap implementation. - ~250 LoC.
Module 3 — Lending Market (src/LendingPool.sol)
- Single-collateral lending: users deposit Module 1’s vault tokens as collateral, borrow a debt token.
- Liquidation flow when health factor < 1.
- ~700 LoC.
Module 4 — Oracle Adapter (src/oracles/ChainlinkAdapter.sol)
- Wraps a Chainlink aggregator.
- Provides
getPrice()with staleness check. - Optionally provides
getTwap()(mock TWAP for the lab). - ~100 LoC.
Module 5 — Governor + Timelock (src/governance/Governor.sol, src/governance/Timelock.sol)
- OpenZeppelin Governor with a 2-day timelock.
- Manages oracle swaps, collateral parameter, strategy migration.
- ~300 LoC.
Module 6 — Minimal frontend (/frontend/src/api.ts)
- TypeScript file that reads contract state via ethers.js or viem.
- Out-of-scope for code-level review but in-scope for trust-seam analysis.
Total: ~1,750 LoC + 100 frontend LoC. Realistic for a 40-hour engagement.
The protocol is intentionally seeded with bugs spanning at least:
- One reentrancy variant (from Week 5).
- One oracle-staleness or precision issue (from Week 6).
- One ERC-4626 / token-quirk issue (from Week 7).
- One governance / timelock weakness (from Week 14).
- Several Low-severity issues (event emission, missing zero-checks, etc.).
You are not told which bugs. Discovery is the assignment.
Setting up: a starter repository is provided by the course instructor with the source code, Foundry config, and a partial test suite. Clone it, run forge test, ensure the baseline test suite is green before you begin reviewing.
10.2 Required deliverables
Deliverable A — Threat model document (~3-5 pages)
Use STRIDE or attack-tree methodology (see Tuan-15-Audit-Methodology-Tooling). For each module, list:
- Assets (what is valuable).
- Actors (user, attacker, admin).
- Trust boundaries.
- Attack scenarios.
Deliverable B — Invariant list with Foundry tests (at least 10 specific invariants)
Examples of invariants you should include:
Vault.totalAssets() >= Vault.totalSupply()after construction (or with a stated decimals offset).Σ user_balances == Vault.totalSupply()at all times.LendingPool.totalBorrowed <= LendingPool.totalDepositedalways.- Health factor formula:
collateralValue * CF > debtfor all non-liquidatable positions. - Governor: a proposal cannot execute before
proposalETA. - Oracle:
getPrice()reverts ifroundData.updatedAt + maxStaleness < block.timestamp.
Implement each as either a property in a Foundry invariant test (forge-std/InvariantTest) or as an assertion in a fuzz test. Run at least 50,000 calls per invariant.
Deliverable C — Audit report (per the template in §2)
Must include at least 3 valid Medium-or-above findings. (“Valid” = the bug is real, reproducible against the audited commit, severity is defensible.) Realistic count for this scope: 8–15 findings total across all severities.
Report must include:
- Cover with full commit hash.
- Executive summary with severity counts.
- Scope, methodology, system overview, trust assumptions.
- Findings (in §3 template).
- Severity matrix.
- Appendix: invariants checked (Deliverable B), threat model (Deliverable A), test coverage report.
Target length: 25–50 pages. Markdown source converted to PDF via pandoc or similar.
Deliverable D — Remediation review
The instructor (or a partner) will provide a “patched” version of the protocol at a new commit. Conduct a remediation review:
- For each finding, re-verify the fix (run your original PoC; should now fail in the bug’s favor).
- Check for regression in related code.
- Update each finding’s Status field with the new commit hash if Resolved.
- Write a 1-page remediation review narrative.
10.3 Suggested 40-hour time budget
| Hours | Phase |
|---|---|
| 0–4 | Scope reading; baseline test run; system overview draft; trust-assumptions draft. |
| 4–8 | Threat model (Deliverable A). |
| 8–14 | Invariant identification + Foundry invariant tests (Deliverable B). |
| 14–28 | Manual review: line-by-line through each module. Findings drafted as discovered. |
| 28–32 | Tooling pass: Slither, fuzz campaigns, fork-mainnet integration. |
| 32–36 | Report compilation: exec summary, methodology, formatting. |
| 36–40 | Peer review (self or external) + remediation review when patched commit arrives. |
Don’t skip the early modeling phase. Auditors who jump straight to “read the code” miss design-level issues that only show up against a built mental model.
10.4 Grading rubric
| Criterion | Weight | What’s assessed |
|---|---|---|
| Coverage | 30% | Every external function reviewed; every storage variable mapped; out-of-scope items explicitly listed. Graded against a checklist of all external entry points. |
| Findings | 30% | True positives vs false positives. Severity justifications. At least 3 valid Med+ findings to pass. Score reduced for missed Highs (against the seed list). |
| Report quality | 25% | Writing clarity, structure, PoC quality, recommendation specificity. Graded by another senior auditor against the §3 finding template. |
| Threat model + invariants | 15% | Quality of Deliverable A & B. Coverage of attacker scenarios; depth of invariants; runs without flake. |
Passing threshold: 70%. To pass, you must:
- Hit at least 3 valid Med+ findings.
- Score ≥ 60% in each category (no category can be skipped or sandbagged).
- Deliver remediation review with at least one fix re-verified.
Distinction (≥ 85%): indicative of junior-auditor readiness for a mid-tier firm. You can apply with this report as a portfolio piece.
10.5 What the capstone is not
It is not:
- A bug-bounty competition. You are graded on the report, not the speed.
- A puzzle box. Findings are real, but they are not exotic — the difficulty is in coverage and write-up, not in the bugs themselves.
- An exercise in finding all bugs. A great auditor catches 70-80% of the seeded findings in 40h; the rest belong to a second pass or another auditor’s pair-review.
It is:
- A simulation of a real engagement, with realistic time pressure.
- The most concrete output of the course — something to attach to applications.
- An opportunity to demonstrate the style of work, not just the content.
11. Quiz (10 Q+A on report writing)
-
Q: What is the single most important field on the cover page of an audit report? A: The full commit hash (40-character SHA). Branch names move; everything in the report is bound to this hash. Without it, every finding becomes ambiguous.
-
Q: A founder asks you to remove a Critical finding from the report because they’ve already shipped a patch. How do you respond? A: Decline. The finding stands; you update its Status to “Resolved in commit X” in the remediation review, but the finding remains in the report. The audit is a record of what existed at the audited commit. Removing it falsifies the record and undermines the report’s credibility for future readers.
-
Q: Why is “Informational” a trap? A: Junior auditors over-dump style/documentation items as Informational to inflate finding counts. This dilutes Medium+ signal for the founder, makes the report look padded to investors, and trains the auditor’s instincts to value count over impact. Informational should be reserved for genuinely no-security-implication items (≤ ~10 per report).
-
Q: A finding requires a flash-loan-sized attacker. The client says “no realistic attacker has 50M-$1B is available from Aave, Balancer, or Uniswap V3 in a single block at fees in the basis points. Capital is not a meaningful constraint for flash-loanable exploits. The severity stands; you may add a note in the finding’s Impact section that the attack requires a flash loan to set expectations.
-
Q: What’s the difference between “severity” and “likelihood” in an Impact×Likelihood matrix? A: Severity is the final grade (Critical / High / Medium / Low / Informational), which is derived from Impact × Likelihood. Impact is what happens if the bug fires (how much is lost, what’s broken). Likelihood is the probability of exploitation in production (low pre-conditions = high likelihood). Collapsing the three causes most severity disputes.
-
Q: Your PoC for a Medium finding compiles but reverts due to an unrelated setup mistake. The judge marks it invalid on Code4rena. What’s the lesson? A: PoCs must be clean, minimal, and asserting. Run your own PoC end-to-end before submission. A PoC that reverts must demonstrate the precise revert error to be valid; a PoC that reverts for unrelated reasons is invalid. Treat PoC as deliverable code, not scratch work.
-
Q: A finding’s recommendation suggests an architectural change (e.g., “switch from spot to TWAP oracle”). Is this appropriate? A: Only if alternatives are offered or the trade-off is explicit. Auditors should recommend, not dictate. “We recommend switching to TWAP because X; the trade-off is Y. An alternative is to add a circuit-breaker around the spot reading; this preserves UX but does not eliminate the manipulation.” This way the team owns the architectural decision; you’ve articulated the constraint.
-
Q: A protocol team claims “we don’t list non-standard ERC-20s, so the bug isn’t reachable”. The integration interface, however, accepts arbitrary tokens. Severity? A: The severity is judged against the code, not the listing policy. If the code accepts arbitrary tokens, the bug is reachable through normal integration. You can note the listing policy as a mitigation surface in the Impact section, but it does not lower severity. If the policy is enforced at the contract level (e.g., a whitelist), severity may legitimately be downgraded — but then the whitelist becomes an attack surface itself.
-
Q: What is a “regression” in a remediation review? A: A regression is when a patch for one finding silently breaks a previously-working property. Auditors must spot-check related code paths during remediation review, not just verify the patched line itself. A fix that resolves M-04 but introduces M-07 is a net-zero (or worse) outcome.
-
Q: A junior auditor on your team writes a finding titled “Reentrancy vulnerability”. You review the draft. What do you push back on? A: The title is generic. Push back: name the function (
Vault.withdraw), the variant (cross-function via ERC-777 callback), and the mechanism (balances[user]not zeroed before external call). A specific title is the single largest readability improvement available, costs 30 seconds to write, and signals professional discipline.
12. Where this leads
You’ve reached the end of the structured curriculum. The 16 weeks were:
- Phase 1 (Weeks 1-3): the substrate — blockchain, EVM, Solidity, Foundry.
- Phase 2 (Weeks 4-7): the vulnerability vocabulary — CEI/AC, reentrancy variants, oracle/MEV, token quirks.
- Phase 3 (Weeks 8-11): the protocol layer — DeFi math, oracles, bridges, L2s.
- Phase 4 (Weeks 12-14): the adjacent surfaces — wallets, frontend, governance.
- Phase 5 (Weeks 15-16): the practice — methodology, tooling, and now report writing.
The next horizon is Bonus Chapters and Case Studies (see Roadmap):
- Tuan-Bonus-Non-EVM-Solana and Tuan-Bonus-Non-EVM-CosmWasm-Move — the non-EVM frontier. EVM auditors who can read Anchor or Move are differentiated.
- Tuan-Bonus-Formal-Verification-Deep and Tuan-Bonus-Fuzzing-Invariant-Advanced — moving up the tool stack. Certora and Halmos work is increasingly billable.
- Tuan-Bonus-ZK-Circuit-Security — under-constrained circuit auditing. The bar is high but the market is starved.
- Tuan-Bonus-Stablecoin-Economic-Modeling and Tuan-Bonus-Liquid-Staking-Restaking — economic-attack auditing. Where the highest-stakes engagements live.
- Tuan-Bonus-Audit-Competition-Playbook and Tuan-Bonus-Bug-Bounty-Immunefi — applying skill in public markets, building a portfolio under your own name.
Case studies (18 listed in the Roadmap) are where the curriculum’s lessons become empirical: reproducing every major exploit from 2016 through 2024 in your own Foundry repos. Pick three to start; over the course of the next 6-12 months, work through the rest.
Entering competitive audits
The single highest-leverage move post-curriculum is competing on Code4rena, Sherlock, or Cantina. Why:
- Public track record. Every finding you submit is judged and ranked. Your handle becomes a CV.
- Volume of practice. A single competitive audit gives you 7–14 days of focused exposure to a real codebase with real reward at stake.
- Calibration of severity judgment. Judges’ calls — especially the contested ones — train your severity intuition faster than any textbook.
- Income before reputation. You can earn (sometimes substantially) before any firm has heard of you.
The progression that works:
- Month 1: read 30+ recent Code4rena and Sherlock reports. Pick handles you respect (e.g.,
0x52,hansfriese,obront, etc.); read everything they’ve submitted in the last year. - Month 2: enter one short (200k pool) Code4rena contest. Submit at least 5 findings, even if some are Lows. Read the judging once results land.
- Month 3: enter a Sherlock contest. The quantitative severity rubric will sharpen your write-ups.
- Month 4: keep competing. By contest #5–7 most auditors land their first Medium-or-above with rewards.
- Month 6+: a Top-10 finish on any platform is a calling card. Apply to private firms with this in your portfolio.
Also consider Cantina for larger and more recent competitions, Hats Finance for ongoing bounties, and Immunefi for picking up live bug bounties on protocols you’ve studied.
The 16-week curriculum was scaffolding. The next 12 months are where you become an auditor. Audit one protocol at a time. Read judging decisions. Write reports. Re-read your own reports six months later — the cringe is the calibration.
13. Week 16 Deliverables
- One full audit finding written from a Week 5/7 bug, using the §3 template.
- One ~1-page critique of a published audit report (TOB / Spearbit / OZ / ChainSecurity / Cantina).
- Capstone setup: system overview draft, trust-assumptions table, list of 10+ invariants.
- Notes file:
~/web3-sec-lab/wk16/notes.mdwith your written answers to the quiz. - Begin capstone proper; complete within 1-2 weeks.
14. A closing note from the desk
Sixteen weeks ago you read about ECDSA on secp256k1 and asked yourself whether you were going to remember any of this. Today you can sketch the attack surface of a DeFi vault, write the PoC for an ERC-4626 inflation attack, defend a severity rating under push-back, and deliver a report a senior auditor would sign their name to.
The skill is real. It is not, however, the same as having shipped 50 audits. The next thousand hours — applied in competitive audits, bounty submissions, private engagements, and case-study reproductions — convert this skill into a profession.
Read every audit report you can find. Reproduce every exploit you can. Submit findings publicly under your own handle. Defend your severity ratings. Re-read your own writing in six months and rewrite the worst paragraph. Then again in twelve.
Auditing is a portfolio profession. The portfolio compounds. Start.
Last updated: 2026-05-16 See also: Roadmap · References · Tuan-15-Audit-Methodology-Tooling · Tuan-Bonus-Audit-Competition-Playbook · Tuan-Bonus-Bug-Bounty-Immunefi · MOC-Web3-Security-Mastery