How to write a bug bounty report that survives triage
A triager needs one sentence to close a weak report, and about ten minutes to find it. This guide covers what a report needs to get past that, the twelve checks to run before you file, and one report rewritten fragment by fragment.
Twelve checks, distilled from 105 real case files across five platforms. The wins and the closures. Built by Tradi3: 2nd of 133 in Immunefi’s Firelight competition and 8th of 65 in Quantus.
What a report that lands contains
A report that lands has these seven things. A triager can verify each of them without taking your word for it.
An executed proof, with its command and output, and a control case. The control is the same test without the bug step. It shows the loss comes from the bug and not from the setup.
A final assertion on the object the impact row names. A balance, an owner, a stored record. An event or a reverted call is not the object.
The exact code location on a pinned revision, and a concrete fix. File, lines and commit. The fix names the lines it changes.
A title that states mechanism and consequence in one sentence. Numbered attack steps follow it, kept separate from the test code.
Limits and non-claims, stated by you. Name the nearest known issue and say how this one differs.
The impact row, quoted verbatim. Copy it from the programme page. Do not paraphrase it.
Filed within hours of reproduction. Fold the variants into one report.
A worked example, before and after
HarborVault is a contract written for this guide. It is not a real protocol and no report on it was ever filed. The bug is real in the code as written, and the test output below is from an actual Foundry run.
The vault burns shares when a withdrawal is requested and pays two days later. Line 34 prices the shares against the vault's whole token balance, which still holds assets queued for earlier requests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
/// @notice Written for the Bounty Operator report guide. Not a real protocol.
contract HarborVault {
IERC20 public immutable asset;
uint256 public constant DELAY = 2 days;
uint256 public totalShares;
mapping(address => uint256) public shares;
mapping(address => uint256) public queued;
mapping(address => uint256) public unlockAt;
constructor(IERC20 asset_) {
asset = asset_;
}
function deposit(uint256 assets) external returns (uint256 minted) {
uint256 pool = asset.balanceOf(address(this));
minted = totalShares == 0 ? assets : assets * totalShares / pool;
shares[msg.sender] += minted;
totalShares += minted;
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
}
/// @dev Burns shares now and pays after DELAY, so an exit cannot be front-run.
function requestWithdraw(uint256 shareAmount) external {
uint256 pool = asset.balanceOf(address(this));
uint256 owed = shareAmount * pool / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
queued[msg.sender] += owed;
unlockAt[msg.sender] = block.timestamp + DELAY;
}
function claim() external {
require(block.timestamp >= unlockAt[msg.sender], "locked");
uint256 owed = queued[msg.sender];
queued[msg.sender] = 0;
require(asset.transfer(msg.sender, owed), "transfer failed");
}
}
Title
Before
Critical: accounting bug in HarborVault lets an attacker steal funds
After
requestWithdraw prices shares against assets already queued for withdrawal, so a split exit is overpaid out of the remaining holders’ assets
Mechanism and consequence in one sentence. The severity moves to the severity field, where it has to match the programme’s scale.
Impact
Before
Critical. Loss of all user funds.
After
Impact row: “Direct theft of user funds held by the vault.”
A holder who splits a 50.000000-share exit into two requests is paid 58.333333 USDC instead of 50.000000. The other holder’s 50.000000 shares then redeem for 41.666667 USDC.
The impact row is quoted word for word, and each clause has a number behind it: who is paid, how much, and who is short.
Attack steps
Before
An attacker can withdraw more than they put in and drain the vault.
After
- Alice and Bob each hold 50.000000 shares. The vault holds 100.000000 USDC.
- Alice calls
requestWithdraw(25.000000). Queued for Alice: 25.000000. Shares outstanding: 75.000000. Vault balance: still 100.000000. - Alice calls
requestWithdraw(25.000000)again. Line 35 computes 25 × 100 / 75 = 33.333333. Queued for Alice: 58.333333. - After the two-day delay Alice calls
claim()and receives 58.333333 USDC. - Bob holds the only 50.000000 shares left. The vault holds 41.666667 USDC. Bob exits and receives 41.666667 USDC.
Every step is a public call by an ordinary holder. No privileged role acts.
Numbered steps with concrete values, kept apart from the test code. The last line is the actor trace: nobody trusted has to do anything.
Proof
Before
PoC attached. Run the test to see the exploit.
After
$ forge test --match-contract HarborVaultSplitTest -vv
[PASS] test_control_oneRequest() (gas: 155201)
Logs:
alice paid 50000000
bob paid 50000000
[PASS] test_split_twoRequests() (gas: 158023)
Logs:
alice paid 58333333
bob paid 41666667
The control test is the same exit made with one request. The final assertion in each test reads Bob’s token balance. The test deploys HarborVault unmodified and a plain six-decimal token. Nothing is mocked.
The command and its output sit in the report body. The control case shows the loss comes from the second request, and the last assertion reads the object the impact row names: a holder’s balance.
Severity
Before
Critical.
After
High. The proof shows theft of part of one holder’s funds: 8.333333 of Bob’s 50.000000 USDC. The programme’s Critical row requires loss of all funds in the vault, which this proof does not assert.
The claim is the highest row the proof fully asserts. The report also names the one fact that would move it up.
Limits and prior art
Before
Nothing. The draft has no section for either.
After
Limits: measured for two requests only. More requests take more; that follows from line 35 and is not measured here. deposit() reads the same balance at line 25 and is not part of this claim.
Nearest known issue: audit note L-03, “rounding in requestWithdraw favours the user”. L-03 concerns one unit of rounding and its fix changes the rounding direction. This report concerns the unsubtracted queue and its fix is different.
The limit and the nearest known issue are stated by the author, before a triager raises either. The known issue is distinguished by its fix, not by its wording.
The first draft in fulldraft-report.md
# Critical: accounting bug in HarborVault lets an attacker steal funds
## Summary
The withdrawal accounting in HarborVault is wrong. An attacker can withdraw more than they put in and drain the vault. All user funds are at risk.
## Impact
Critical. Loss of all user funds.
## Proof of concept
PoC attached. Run the test to see the exploit.
## Recommendation
Fix the accounting in requestWithdraw.
The rewritten report in fullreport.md
# requestWithdraw prices shares against assets already queued for withdrawal, so a split exit is overpaid out of the remaining holders' assets
## Impact row
"Direct theft of user funds held by the vault."
## Location
src/HarborVault.sol:34-35 at commit 4f1c9e2, the revision the scope page lists.
## Root cause
Line 34 reads the vault's whole token balance as the pool. Assets queued at line 38 stay in that balance until claim() pays them, while the shares they belonged to were burned at line 37. Every request made while a queue exists is priced against assets that are already owed to someone.
## Attack steps
1. Alice and Bob each hold 50.000000 shares. The vault holds 100.000000 USDC.
2. Alice calls requestWithdraw(25.000000). Queued for Alice: 25.000000. Shares outstanding: 75.000000. Vault balance: still 100.000000.
3. Alice calls requestWithdraw(25.000000) again. Line 35 computes 25 × 100 / 75 = 33.333333. Queued for Alice: 58.333333.
4. After the two-day delay Alice calls claim() and receives 58.333333 USDC.
5. Bob holds the only 50.000000 shares left. The vault holds 41.666667 USDC. Bob exits and receives 41.666667 USDC.
Every step is a public call by an ordinary holder. No privileged role acts.
## Proof
```
$ forge test --match-contract HarborVaultSplitTest -vv
[PASS] test_control_oneRequest() (gas: 155201)
Logs:
alice paid 50000000
bob paid 50000000
[PASS] test_split_twoRequests() (gas: 158023)
Logs:
alice paid 58333333
bob paid 41666667
```
The control test is the same exit made with one request. The final assertion in each test reads Bob's token balance. The test deploys HarborVault unmodified and a plain six-decimal token. Nothing is mocked.
## Severity
High. The proof shows theft of part of one holder's funds: 8.333333 of Bob's 50.000000 USDC. The programme's Critical row requires loss of all funds in the vault, which this proof does not assert.
## Limits
Measured for two requests only. More requests take more; that follows from line 35 and is not measured here. deposit() reads the same balance at line 25 and is not part of this claim.
## Nearest known issue
Audit note L-03, "rounding in requestWithdraw favours the user". L-03 concerns one unit of rounding and its fix changes the rounding direction. This report concerns the unsubtracted queue and its fix is different.
## Fix
Track a totalQueued counter. Add to it at line 38, subtract it in claim(), and price shares against balanceOf(vault) - totalQueued at lines 25 and 34.
Run the weak draft through the workbench
Loads HarborVault.sol and the first draft into the Challenge a draft report profile. You choose the model and supply the key, or export the prompt to your chat app.
The twelve checks
Every check exists because real reports were closed for that reason. Each one asks one question of your draft and leads to a verdict. The method page says why reports die on each one.
-
Actor trace
Who performs every step, and who supplies every decisive value?
Leads to: Drop
-
Own-verdict reversal
Which new evidence defeats each reason you recorded when you held this finding or lowered its severity?
Leads to: Earlier verdict stands
-
Design intent and counterfactual
Did the project mean this behaviour, and does the bug step add anything over the intended path?
Leads to: Drop
-
Literal impact and exclusion fit
Does an exclusion name this class, and does every clause of the impact row have an artefact behind it?
Leads to: DropRewrite, then submit
-
Asset and version binding
Does the cited code exist in the scoped asset, at the revision that is deployed?
Leads to: DropProve first
-
Prior-art sweep
Does a known issue, an audit note, a branch or a pull request cover the same function and consequence, or carry the same fix?
Leads to: Hold: duplicate
-
Own-report family
Stated with no file and no entry point, is the broken invariant one you have already reported on this programme?
Leads to: Hold: duplicate
-
Executed end-state proof
Does the final assertion read the object the impact row names, on production code, with the command and its output in the report?
Leads to: Prove first
-
Production reachability
Does every state the proof sets up have a route from live state by public calls?
Leads to: Prove first
-
Severity against the written scale
Which row of the programme’s own scale does the proof assert, after every recovery action is applied?
Leads to: Rewrite, then submit
-
Submission integrity
Does what the platform stored match the draft: the proof field, the severity, the impact row?
Leads to: Rewrite, then submit
-
Private-duplicate clock
Small fix, old code, central path, checklist class: how long before someone else files it?
Leads to: Sets a filing deadline
The executable version of each check runs inside the gauntlet.
The order to work in
Run the gates that end a report before the stages that cost work. Scope, provenance and prior art are reading. A proof is building, so it comes fourth.
-
Run this stage: Scope
Scope
Is the code in the scoped asset at the deployed revision, is the class excluded, and does every clause of the impact row have an artefact behind it?
-
Run this stage: Provenance
Provenance
Who performs each step, is the behaviour documented design or an audit fix, and is every precondition reachable from live state?
-
Run this stage: Prior art
Prior art
Same root cause or same one-line fix as a known issue, a prior audit note, a team branch, or your own earlier report?
-
Run this stage: Proof
Proof
Does the proof run production code on every step and end by reading the object the impact row names, with measured numbers?
-
Run this stage: Severity
Severity
What is the highest row of the programme’s own scale that the proof fully asserts, after every downgrade clause?
-
Run this stage: Triager
Triager
What is the one sentence that closes this report in ten minutes, and is it answered in the first paragraph?
-
Run this stage: Report
Report
Do the claims follow from the code, is the proof inline, do form and body agree, are the limits stated?
-
Verdict
One decision, one blocker, the cheapest action that removes it, and a filing deadline.
Every stage but the last is a review profile in the workbench, run as a hosted review with your own model key. The Free plan runs one hosted review per UTC day, any profile. Operator runs all eight in order as one Gauntlet and ends with a single verdict:
SubmitRewrite, then submitProve firstHold: duplicateDrop
Evidence to collect before you write
These decide outcomes, so gather them before you write a sentence. A missing one is a gap a triager finds for you.
The programme’s impact list, and the row you selected
Exclusions and trusted roles
The severity scale, with thresholds and downgrade clauses
The asset, the revision your proof ran on, and the deployed revision
Known issues, audits and fix-review notes, team branches, and how deep your clone goes
Your own earlier reports on the programme
The actor behind each step
The measured loss
The proof run: command, commit, output, and anything mocked
Fee and duplicate rules, and the date you first reproduced it
The platform’s read-back of the stored submission