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.

src/HarborVault.sol1–48
// 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

  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.

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
draft-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
report.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.

Challenge this draft

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.

  1. Actor trace

    Who performs every step, and who supplies every decisive value?

    Leads to: Drop

  2. 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

  3. Design intent and counterfactual

    Did the project mean this behaviour, and does the bug step add anything over the intended path?

    Leads to: Drop

  4. 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

  5. Asset and version binding

    Does the cited code exist in the scoped asset, at the revision that is deployed?

    Leads to: DropProve first

  6. 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

  7. 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

  8. 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

  9. Production reachability

    Does every state the proof sets up have a route from live state by public calls?

    Leads to: Prove first

  10. 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

  11. Submission integrity

    Does what the platform stored match the draft: the proof field, the severity, the impact row?

    Leads to: Rewrite, then submit

  12. 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.

  1. 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: Scope
  2. 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: Provenance
  3. 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: Prior art
  4. 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: Proof
  5. Severity

    What is the highest row of the programme’s own scale that the proof fully asserts, after every downgrade clause?

    Run this stage: Severity
  6. Triager

    What is the one sentence that closes this report in ten minutes, and is it answered in the first paragraph?

    Run this stage: Triager
  7. Report

    Do the claims follow from the code, is the proof inline, do form and body agree, are the limits stated?

    Run this stage: Report
  8. 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