- name
- web3-triage-report
- description
- Bug triage validation system, Immunefi report format, and 20 real paid bounty examples dissected. Use this when validating a finding before submitting, writing an Immunefi report, checking if a bug is actually valid, or studying real examples of paid vulnerabilities.
# TRIAGE, REPORT WRITING & REAL EXAMPLES
---
## PART 1: TRIAGE
### THE 7-QUESTION GATE
Ask these IN ORDER before writing a single word of your report.
ONE wrong answer = STOP and move on.
---
#### Q1: Can an attacker use this RIGHT NOW, step by step?
Complete this template:
```
1. Setup: [what I need]
2. Call: [exact function, exact params]
3. Result: [what I have that I didn't have before]
4. Cost: [gas + capital]
5. ROI: [profit / cost ratio]
```
If you cannot complete steps 2 and 3 with specific function calls: **KILL IT.**
---
#### Q2: Is the impact in the program's accepted impact list?
Go to the Immunefi program page. Find "Impacts in Scope."
Match your bug to one of these EXACTLY.
Example impact tiers:
- "Direct theft of any user funds" — Critical
- "Permanent freezing of funds" — Critical
- "Protocol insolvency" — Critical
- "Theft of unclaimed yield" — High
- "Permanent freezing of unclaimed yield" — High
- "Temporary freezing of funds" — High
- "Smart contract unable to operate due to lack of token funds" — Medium
- "Griefing (no profit motive, but damage to users)" — Medium
- "Contract fails to deliver promised returns, but doesn't lose value" — Low
If your bug does not match any impact in scope: **KILL IT.**
---
#### Q3: Is the root cause in an in-scope contract?
Confirm the exact deployed address is in scope on the program page.
If the bug is in Aave, Uniswap, OpenZeppelin, or any external dependency: **KILL IT.**
---
#### Q4: Does it require admin/privileged access?
"Admin can drain funds" = centralization risk = **KILL IT.**
"Admin can set parameter X which under condition Y creates DoS" = borderline.
Salvage path: can the bug trigger WITHOUT the admin doing anything unusual?
- If yes: valid
- If no: likely invalid (requires admin mistake — almost always out of scope)
---
#### Q5: Is this already known/acknowledged in prior audits?
Find the audit reports for the protocol. Search for "Risk Accepted," "Acknowledged," "Won't Fix."
If your bug matches a known finding: **KILL IT.**
Edge case: if acknowledged finding + NEW code around it creates a new attack path → that is a new bug, not the acknowledged one. Must prove the new path.
---
#### Q6: Is the economic attack viable?
```
Attacker spends: gas + capital
Attacker gains: tokens stolen or protocol damaged
If profit < cost: KILL IT.
```
Example:
- DoS via dust harvest: costs 1 wei USDC + gas, disables yield for $81K TVL → VIABLE.
- Withdraw-fee arbitrage: fee (0.1%) > diluted yield from attack → NOT profitable → KILL IT.
---
#### Q7: Is this already public?
- Is it on social media or in a disclosed report?
- Was it previously submitted and disclosed?
- Is the "sensitive" data visible in the UI already?
If yes: **KILL IT.**
---
### THE SEVERITY MATRIX
Score = Impact × Likelihood × Exploitability (each 1–3)
| | Impact=1 (info leak) | Impact=2 (partial) | Impact=3 (theft/freeze) |
|--|--|--|--|
| L=1 E=1 | 1 (Info) | 2 (Low) | 3 (Low) |
| L=2 E=2 | 4 (Medium) | 8 (High) | 12 (High) |
| L=3 E=3 | 9 (High) | 18 (Critical) | 27 (Critical) |
**Rule: When borderline, round DOWN. Over-classification destroys credibility.**
---
### THINK LIKE AN ATTACKER TEMPLATE
Before writing your report, fill in this attack scenario:
```
Protocol: [name]
Target contract: [address + function]
Preconditions: [what state must exist?]
Attack sequence:
1. Attacker calls [exact function] with [exact params]
2. [What happens in the contract]
3. [What state changes]
4. Attacker ends up with: [X more tokens / broken state / DoS]
Total cost: [gas estimate + capital requirement]
Total gain: [$X stolen / $Y TVL frozen]
Viable? [yes/no + reason]
```
If you can't fill in steps 1–4 with specific values, the bug is not ready to submit.
---
### THINK LIKE A TRIAGER CHECKLIST
A triager reviewing your report will immediately check:
- [ ] Does the title match an accepted impact?
- [ ] Is the vulnerable function clearly identified (file + line)?
- [ ] Is the root cause explained (not just "there is a bug")?
- [ ] Is there comparison evidence ("function A has this, function B doesn't")?
- [ ] Does the PoC run without errors?
- [ ] Is the severity appropriate to the actual impact?
- [ ] Is the bug already in the known issues list?
- [ ] Does the fix make sense (proves you understand the root cause)?
If your report can't pass this checklist: revise before submitting.
---
### SEVERITY DOWNGRADE TRIGGERS
| Condition | Severity drops |
|-----------|---------------|
| Requires specific admin configuration | -1 level |
| Impact limited to a small subset of users | -1 level |
| Requires long time window (>24h) to exploit | -1 level |
| Protocol can detect and pause before loss | -1 level |
| Impact is yield loss, not principal loss | -1 level |
| Bug is theoretical with no practical attack | Down to Info |
| Attack costs more than attacker gains | Invalid |
---
### VALID vs INVALID COMPARISON TABLE
| Bug | Valid? | Reason |
|-----|--------|--------|
| DISTRIBUTOR_ROLE never granted → claimFor() permanently uncallable | **Valid (Medium)** | Deployment bug, not admin action, real impact on users |
| `- 1` strands 1 wei per harvest | **Valid (Low/Info)** | Real, quantified, honest about minor impact |
| Front-run harvest (acknowledged in prior audit) | **Invalid** | Known issue = instant rejection |
| Admin can change fee to 100% | **Invalid** | Centralization risk = almost always OOS |
| Harvest DoS via dust (requires admin misconfiguration) | **Borderline** | Must prove it triggers without unusual admin action |
| ecrecover returns address(0) = anyone can pass | **Valid (Critical)** | No preconditions, direct theft |
| Contract uses spot price oracle | **Valid (High/Critical)** | Flash loan manipulation, well-documented impact |
| Missing slippage parameter | **Valid (Medium)** | MEV sandwich possible, quantifiable loss |
| GraphQL introspection enabled | **Invalid** | Info disclosure only, no exploitation path |
| Missing HSTS header | **Invalid** | Always rejected |
---
### THE VALIDITY RATIO
Immunefi tracks your submission:triage ratio.
High invalid submission rate → your future reports get lower priority.
**Target: 70%+ valid submissions.**
Better to submit 3 valid bugs than 10 invalid ones.
A low-severity honest submission is better for your ratio than an overclaimed invalid one.
---
## PART 2: REPORT WRITING
### THE WINNING FORMULA
1. **Title** = [Exact function] + [root cause] + [quantified impact]
2. **Comparison evidence** = "Function A has X, Function B doesn't"
3. **Attack path** = numbered steps, each with exact function call
4. **Quantified impact** = "$X stolen" or "X% yield diluted"
5. **PoC output** = actual console.log numbers, not just "test passes"
6. **1-line fix** = proves you understand the root cause
---
### IMMUNEFI REPORT TEMPLATE (Complete)
```markdown
## [Exact function in ContractName] — [root cause in 10 words] leads to [quantified impact]
### Example title:
"_performHarvest() in Ern.sol subtracts hardcoded 1 wei causing reward token
permanent lockup across all harvests"
---
## Summary
[2-3 sentences maximum. What is the bug, where is it, what does it enable.]
The `_performHarvest()` function in `Ern.sol` subtracts a hardcoded `1` from
`userRewards` without distributing or accounting for the remainder. This causes
1 wei of reward token to be permanently locked in the contract after every
harvest, and — more critically — causes a revert when Uniswap returns 0 output
for dust-amount swaps, permanently freezing the harvest function.
---
## Vulnerability Details
**Contract:** `Ern.sol`
**Function:** `_performHarvest()`, line 187
**Type:** Arithmetic error / Incomplete path
**Vulnerable Code:**
```solidity
uint256 protocolFee = (rewardReceived * harvestFee) / 10000;
uint256 userRewards = rewardReceived - protocolFee - 1; // ← BUG: hardcoded -1
if (protocolFee > 0) REWARD_TOKEN.safeTransfer(owner(), protocolFee);
if (totalSharesSupply > 0) {
cumulativeRewardPerShare += (userRewards * 1e18) / totalSharesSupply;
}
```
**Comparison Evidence:**
Function `claimYield()` at line 120 correctly handles zero-amount cases.
`_performHarvest()` at line 187 does not account for the stranded `1 wei`
remainder, creating a silent fund loss on every harvest.
**Root Cause:**
The `- 1` subtraction creates a permanent accounting gap. The 1 wei:
- Is NOT sent to the protocol fee recipient (owner)
- Is NOT distributed to users via `cumulativeRewardPerShare`
- Remains locked in the contract indefinitely with no recovery mechanism
**Attack Path (numbered, each step is a specific function call):**
1. Owner sets `minYieldAmount` to minimum (1 * 10^6 = 1 USDC)
2. `harvestTimePeriod` passes (24 hours by default)
3. Yield accrued: 1 wei of aUSDC above totalSupply
4. `canHarvest()` returns `true` (time condition satisfied)
5. Harvester calls `harvest(0)` with `minOut = 0`
6. `yieldAmount = 1` → Aave withdraws 1 wei USDC
7. Uniswap `exactInputSingle(1 wei USDC → WBTC)` returns `0` output
8. `rewardReceived = 0`
9. `userRewards = 0 - 0 - 1 = type(uint256).max` ← ARITHMETIC UNDERFLOW
10. Transaction reverts. All future harvests permanently blocked.
---
## Impact
**Severity:** High
**Category:** Temporary freezing of funds
**Quantified Impact:**
- ernUSDC TVL: $69,300
- ernUSDT TVL: $12,000
- All accrued wBTC yield frozen for all depositors
- Recovery requires owner intervention or protocol upgrade
**Preconditions:**
[list any setup conditions required]
---
## Proof of Concept
[Working Foundry test that runs with forge test -vvv]
[Must include console.log output showing actual numbers]
[Must compile and pass cleanly]
**Expected Output:**
```
[PASS] testHarvestDoS()
Logs:
canHarvest: true
yieldAmount: 1
harvest() REVERTS: arithmetic underflow confirmed
All future harvests blocked until owner intervenes
```
---
## Recommended Fix
**Option 1 — Remove the unexplained `-1`:**
```solidity
// Before:
uint256 userRewards = rewardReceived - protocolFee - 1;
// After:
uint256 userRewards = rewardReceived - protocolFee;
```
**Option 2 — Guard against zero rewardReceived:**
```solidity
if (rewardReceived == 0) {
lastHarvest = block.timestamp;
return;
}
```
---
## References
- Vulnerable code: `ContractName.sol` line X (deployed at `0x...`)
- Related prior audit finding (if relevant): [explain why yours is DIFFERENT]
- CWE/weakness class: [e.g., CWE-191: Integer Underflow]
```
---
### TITLE FORMULA
```
[ROOT CAUSE] in [function name] allows [WHO] to [IMPACT]
```
Examples:
- `Missing access control in setPassword() allows anyone to change the stored password`
- `Reentrancy in refund() enables attacker to drain all ETH before state update`
- `Spot price oracle in getPrice() enables flash loan manipulation of exchange rates`
- `_performHarvest() subtracts hardcoded 1 wei causing permanent harvest DoS when Uniswap returns 0`
---
### IMPACT SELECTION GUIDE
Match your finding to one of these Immunefi tiers (program-specific — always verify):
| Impact | Tier | Typical payout range |
|--------|------|---------------------|
| Direct theft of user funds (no limit) | Critical | $50K–$10M |
| Permanent freezing of funds | Critical | $50K–$10M |
| Protocol insolvency | Critical | $50K–$10M |
| Theft of unclaimed yield | High | $10K–$100K |
| Permanent freezing of unclaimed yield | High | $10K–$100K |
| Temporary freezing of funds (>1 hour) | High | $5K–$50K |
| Contract can't operate due to lack of funds | Medium | $2K–$10K |
| Griefing (damage, no profit motive) | Medium | $2K–$10K |
| Contract fails to deliver promised returns | Low | $500–$2K |
---
### PoC REQUIREMENTS
A PoC that wins bounties must:
1. Be a working Foundry test (`forge test -vvv` passes)
2. Fork mainnet at a specific block number
3. Use real deployed contract addresses
4. Show console.log output with actual dollar amounts or token counts
5. Be reproducible by the triager with a single command
A PoC that gets rejected:
- "Test passes" with no meaningful assertion
- Compilation errors
- Wrong mainnet addresses
- No fork — just unit tests with mocks
- No quantification of impact
---
### COMMON REJECTION REASONS
1. **Vague impact:** "Could potentially cause loss of funds" → Always quantify in USD
2. **No comparison evidence:** "This is missing" without showing what sibling function has it
3. **PoC that doesn't run:** Compilation errors, wrong addresses → test before submitting
4. **Wrong severity:** Overclassifying → damages credibility for future reports
5. **Known issue not checked:** Submitting what's already in the audit report → instant reject
6. **No fix provided:** Shows you don't fully understand root cause
7. **Multiple variants of same bug:** Submit ONE report per root cause
8. **Missing preconditions:** Listing an admin action as if it's freely exploitable
---
### WHAT TRIAGERS ACTUALLY WANT TO SEE
From analyzing winning reports across Immunefi competitions:
- **Comparison evidence is the #1 differentiator.** "Function X has guard Y, Function Z doesn't" is more compelling than just saying "Function Z is missing guard Y."
- **Numbers matter more than words.** "$69,300 TVL frozen" > "significant funds at risk."
- **The fix proves understanding.** A 1-line fix that addresses root cause > a 10-line defensive patch.
- **Attack cost matters.** "1 wei USDC + gas" > "substantial capital required."
- **Preconditions must be honest.** Triagers will test the scenario. If you omit that it requires admin action, they will find it and reject.
---
## PART 3: REAL EXAMPLES
20 paid bounty reports dissected — pattern, technique, key insight.
---
### 1. Wormhole — $10M (Uninitialized Proxy)
**Protocol:** Wormhole bridge
**Payout:** $10,000,000
**Bug class:** Upgrade patterns / Access control
**Root cause:** UUPS proxy implementation contract missing `_disableInitializers()` in constructor. Attacker called `initialize()` directly on the implementation, became guardian, upgraded proxy to malicious contract.
**What the hunter did:** Called `implementation()` on the UUPS proxy → got impl address → called `initialize()` directly on impl → became guardian → upgraded proxy.
Auf GitHub ansehen