web3-triage-report

Installation
SKILL.md

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)

## [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:

// Before:
uint256 userRewards = rewardReceived - protocolFee - 1;

// After:
uint256 userRewards = rewardReceived - protocolFee;

Option 2 — Guard against zero rewardReceived:

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.

**Key insight:** `_disableInitializers()` was missing from the impl constructor. Always check the IMPL contract directly, not the proxy.

**Grep to replicate:**
```bash
grep -rn "function initialize\b" contracts/ -A3
grep -rn "_disableInitializers()" contracts/
# If initialize has no protection on implementation → CRITICAL

2. ZeroLend — Existence vs. Ownership Check

Bug class: Access control / Logic error

Root cause: split() called _requireOwned(tokenId) which only checks the token exists — not that msg.sender owns it. Any caller could split any token.

What the hunter did: Read split(). Noticed _requireOwned. Looked up what it does. Found it only checks existence, not ownership. Called split(victimTokenId, 1).

Key insight: _requireOwned_checkAuthorized. Always verify what the access control function ACTUALLY validates, not just what its name implies.

Grep:

grep -rn "_requireOwned\|ownerOf\b" contracts/ -B5 -A5
# Read the implementation: does it check msg.sender == owner? Or just: does owner exist?

3. Alchemix — Missing onlyNewEpoch on poke()

Bug class: Missing guard on sibling function

Root cause: vote() and reset() had onlyNewEpoch(tokenId) modifier. poke() did not. Called poke() unlimited times per epoch to drain FLUX tokens.

What the hunter did: Listed all functions in the vote/veNFT system. Saw vote() and reset() had onlyNewEpoch. Checked poke() — it didn't. Called poke unlimited times per epoch.

Key insight: When you see a modifier on 2 out of 3 sibling functions, the 3rd is the bug. This is the "missing guard on sibling function" pattern — one of the most common Critical findings.

Grep:

grep -rn "function vote\|function poke\|function reset" contracts/ -A2
# Compare: do all have the same set of modifiers?

4. Yeet — startUnstake() Phantom Rewards

Bug class: Accounting desync

Root cause: startUnstake() decremented totalSupply but balanceOf(this) didn't change. harvest() computed yieldAmount = balanceOf(this) - totalSupply — now larger than real. Phantom yield created from the desync.

What the hunter did: Traced the flow. startUnstake() decrements totalSupply. harvest() computes yield from the difference. After startUnstake, the difference is now artificially inflated.

Key insight: Always trace what balanceOf(this) - totalSupply equals in each state transition.

Grep:

grep -rn "balanceOf(address(this)).*-.*total\|total.*-.*balanceOf(address(this))" contracts/
# Then: does totalSupply change without balanceOf changing?

5. Folks Finance — Zero-Amount Array Push

Bug class: Missing validation before array push

Root cause: increaseCollateral() called colPools.push(pool) BEFORE checking amount > 0. Depositing 0 pushed the pool entry. Call 5 times → 5 entries for same pool → getLoanLiquidity counted it 5×, inflating collateral.

What the hunter did: Read increaseCollateral(). Found colPools.push(pool) before the amount check. Deposited 0 five times to inflate collateral count.

Key insight: Check that array push operations have amount validation BEFORE the push — not after.

Grep:

grep -rn "\.push(" contracts/ -B5
# Is there: require(amount > 0) or if (amount == 0) return; BEFORE the push?

6. VeChain Stargate — > vs >= Infinite Drain

Bug class: Off-by-one / boundary condition

Root cause: if (endPeriod > nextClaimablePeriod) — the equal case (==) fell through to the active rewards branch instead of the completed branch. After exiting, attacker could claim infinite rewards.

What the hunter did: Read the delegation period claiming function. Found > comparison. Asked "what when equal?" Traced: equal case triggers infinite reward claim loop.

Key insight: At EVERY > comparison, ask "what when equal?" Boundary conditions are the most common source of off-by-one criticals.

Grep:

grep -rn "endPeriod\|exitPeriod\|lastPeriod" contracts/ | grep "[<>][^=]"
# For every strict comparison on period/epoch boundaries: test the equal case

7. Flare FAssets — Tautology in Proof Verification

Bug class: Logic error / Tautology

Root cause: require(sourceAddressesRoot == sourceAddressesRoot) — comparing a variable to itself. Always true. Non-payment proof verification was bypassed entirely.

What the hunter did: Read the non-payment proof validation. Found the tautological comparison. Recognized it as always-true.

Key insight: Run a tautology check on every codebase. Any variable compared against itself is a critical bypass.

Grep:

grep -rn "require\|assert" contracts/ | python3 -c "
import sys, re
for l in sys.stdin:
    if re.search(r'\b(\w{4,})\b.*==.*\b\1\b', l):
        print(l.strip())
"

8. Aurora — DelegateCall to Precompile ($6M)

Bug class: Non-standard EVM behavior

Root cause: On Aurora (custom EVM chain), delegatecall to precompile addresses caused balance deduction in the precompile's context — not the caller's. Attacker delegatecalled the precompile: ETH sent to NEAR, but caller's balance unchanged.

What the hunter did: On a custom EVM chain, tested delegatecall to precompile addresses. Found that balance accounting diverged from mainnet Ethereum behavior.

Key insight: On L2s and custom EVMs, test every opcode's behavior — it may differ from mainnet Ethereum. This is especially true for precompiles, gas mechanics, and storage.

Replicable on: Any chain with custom precompiles, EVM-Cosmos hybrids, Substrate-based EVMs.


9. Polygon MRC20 — Missing Balance Check on Gasless Transfer ($2.2M)

Bug class: Cryptography / Missing validation

Root cause: transferWithSig() did not check if from had sufficient balance. If ecrecover returned address(0) (invalid signature), from = address(0) and tokens were minted from the void.

What the hunter did: Read transferWithSig(). Checked: is from balance validated? No. Checked: if ecrecover returns address(0), does it revert? No. Called with invalid signature → minted from zero address.

Key insight: Always test ecrecover with an invalid signature. The return value must be compared against address(0) and must revert.

Grep:

grep -rn "ecrecover\|ECDSA\.recover" contracts/ -A5
# Is the return value compared against address(0)?
# Is the from-balance verified before transfer?

10. Evmos — Read the Docs ($150K)

Bug class: Configuration / Logic error

Root cause: Cosmos SDK documentation specifies that BlockedAddrs map must include all module accounts. The module account was not in BlockedAddrs. Sending tokens to it corrupted chain state and caused a halt.

What the hunter did: Read the Cosmos SDK documentation. Found the requirement. Read app.go. Found the module account was missing from BlockedAddrs. Demonstrated the chain halt.

Key insight: READ THE DOCS. The documentation describes the correct behavior. If the code doesn't match, that's the bug. This is especially true for framework-specific requirements (Cosmos SDK, Anchor, Move runtime).


11. Plume — safeApprove Without Cleanup

Bug class: Token standards / Deprecated pattern

Root cause: DEX wrapper used safeApprove(router, amountIn) before a partial swap with no cleanup. Second swap failed with "approve from non-zero to non-zero." First amount locked in wrapper permanently.

What the hunter did: Read the DEX wrapper. Found safeApprove. Asked: "What happens to leftover approval?" No cleanup. Traced: second call reverts, amount locked.

Key insight: safeApprove is deprecated for a reason — it fails if there's a non-zero existing approval. Must call safeApprove(spender, 0) after every swap.

Grep:

grep -rn "safeApprove\b" contracts/ -A8
# Is there: safeApprove(spender, 0) cleanup after the swap?

12. Raydium — remaining_accounts Validation ($505K)

Bug class: Solana-specific / Missing account validation

Root cause: increase_liquidity.rs used remaining_accounts[0] as TickArrayBitmapExtension with no validation that it was actually that account. Passing arbitrary account flipped tick bitmap, draining the pool.

What the hunter did: On Solana, read increase_liquidity.rs. Found remaining_accounts[0] used without validation. Passed arbitrary account to flip tick bitmap.

Key insight: On Solana, every remaining_accounts[n] must be validated with ownership check, discriminator check, or constraint attribute.

Grep (Solana):

grep -rn "remaining_accounts\[" src/ --include="*.rs" -A5
# Is there: AccountInfo constraint, owner check, discriminator check?

13. Shardeum — Duplicate Validator Signatures

Bug class: Cryptography / Missing deduplication

Root cause: validSignatures++ incremented without deduplication. One malicious validator stuffed its own signature 100× → reached 66% threshold alone, bypassing consensus.

What the hunter did: Read the consensus code. Found validSignatures++ with no deduplication check. Demonstrated one validator reaching threshold by repeating its signature.

Key insight: Any counter that increases per-signature must check for duplicate signers. if (seen.has(signer)) continue; seen.add(signer) is the fix pattern.

Grep:

grep -rn "validSignatures++\|signatureCount++" src/ -B5
# Is there: if (seen.has(signer)) continue; seen.add(signer)?

14. DFX Finance — 2-Decimal Token Rounding ($100K)

Bug class: Arithmetic / Precision loss

Root cause: Protocol supported EURS token (2 decimals). Deposit of 1 wei EURS: viewRawAmount(1) → division result rounds to 0 → deposit 0 underlying, receive non-zero LP shares.

What the hunter did: Noted the protocol supported EURS (2 decimals). Ran the calculation manually: 1 wei → 0 underlying after rounding → free LP shares.

Key insight: For any non-standard decimal token (2, 4, 6 decimals), test edge cases near 0. Division truncates toward zero, which creates deposit-with-0-cost attacks.

Grep:

grep -rn "\.decimals()\|EURS\|2.*decimal\|decimals.*2\b" contracts/
# For any non-standard decimal token: test edge cases near 0

15. Alchemix V3 — Fast Path Skips State Update

Bug class: Incomplete path / Fast path skip

Root cause: claimRedemption() had a fast path: if Transmuter already had funds, it transferred and burned NFT, then returned early. The slow path updated cumulativeEarmarked, _redemptionWeight, totalDebt. Fast path skipped all 3. Phantom debt + stranded collateral.

What the hunter did: Read claimRedemption(). Found if (transmuter has funds) { transfer; burn; return; }. Traced which state updates the slow path does that the fast path skips.

Key insight: For every early return or fast path: which state updates happen in normal flow but NOT here? The delta is the bug. This is the most common Critical pattern across Immunefi competition reports.

Grep:

grep -rn "if.*sufficient\|fast.*path\|return\b" contracts/ -B3 -A10
# For each early return: which state updates happen in normal flow but NOT here?

16. Belt Finance — Logic Error in Yield Aggregator ($1.05M)

Bug class: Oracle manipulation / Flash loan

Root cause: Vault's price per full share calculation included a temporary value before it was settled. Flash loan inflated the transient value → manipulated ppfs → borrow against inflated collateral.

What the hunter did: Read the vault's pricePerFullShare calculation. Found it included a value that could be temporarily inflated. Flash loaned to inflate it, then borrowed against the inflated collateral.

Key insight: Any vault with pricePerFullShare or exchangeRate that is readable AND writable in the same transaction is a flash loan oracle target. Check: can a single transaction inflate then read the rate?


17. Belong — ERC4626 First Depositor

Bug class: Arithmetic / First depositor inflation

Root cause: convertToShares had no virtual offset. First depositor could inflate exchange rate: deposit 1 wei → donate 999,999 → next depositor gets 0 shares for 999,999 deposit.

What the hunter did: Read convertToShares. No totalSupply() + 10**decimalsOffset() virtual offset. Executed the classic first depositor inflation: deposit 1 → donate → victim gets 0.

Key insight: No virtual offset in ERC4626 = first depositor inflation possible. Fix: OpenZeppelin's implementation adds totalSupply() + 10**_decimalsOffset() to numerator.

Grep:

grep -rn "convertToShares\|_convertToShares" contracts/ -A5
# Is there: totalSupply() + 10**decimalsOffset() or totalAssets() + 1?

18. Paradex — Negative Value in Account Transfer

Bug class: Type system / Non-EVM chain specifics

Root cause: On Starknet (Cairo), account_transfer_partial() took felt252 input (which can be negative). Validation amount >= min_amount passed even for negative values. balance - (-X) = balance + X → infinite balance.

What the hunter did: On Starknet, read account_transfer_partial(). Input type is felt252. Tested: does validation pass for negative felt252? Yes. balance - (-X) = balance + X.

Key insight: On Cairo/Starknet, felt252 can represent negative values. Any amount validation must check amount > 0 explicitly, not just amount >= minimum.


19. Anvil Protocol — uint256 → uint16 Truncation

Bug class: Arithmetic / Type truncation

Root cause: uint16((bigValue * 10_000) / collateral) — result cast to uint16 without range check. Value 65,537 truncates to 1. Collateral factor validation passed. Deposited $0.003, issued $10,000 LOC.

What the hunter did: Found uint16(...) cast in collateral factor calculation. Calculated: what input produces a value just above 65,535? That value truncates to 1. Passed that input.

Key insight: Any downcast (uint256 → uint16, uint128 → uint64) without a range check is a critical candidate if the result is used for security validation.

Grep:

grep -rn "uint16(\|uint8(\|as u16\|as u8" contracts/ src/
# Is there a range check before the downcast?

20. ResupplyFi — Near-Empty Vault Manipulation ($1.8M)

Bug class: Arithmetic / Exchange rate manipulation

Root cause: Protocol used virtual shares but with too-small virtual offset. Near-empty vault (1 existing share) still allowed exchange rate manipulation when total assets were very low.

What the hunter did: Found that virtual shares were present but the offset was small enough that with 1 existing share and very low TVL, the same inflation math still applied.

Key insight: Even WITH virtual shares, very low TVL vaults can still be vulnerable if the virtual offset is too small relative to the token's price. Test at TVL = 1 share.


THE 3 UNIVERSAL PATTERNS (From All 20 Examples)

Almost every bug comes from one of three root causes:

Pattern A: "I assumed function B was called, but it wasn't"

  • Fast path skip, early return, conditional execution
  • The fix: ensure ALL paths update ALL state variables
  • Examples: #15 (Alchemix V3 fast path), #4 (Yeet accounting desync)

Pattern B: "I assumed the check meant X, but it actually means Y"

  • _requireOwned = existence not ownership
  • > = doesn't include boundary
  • Modifier = silent bypass when missing
  • Examples: #2 (ZeroLend), #6 (VeChain), #3 (Alchemix poke)

Pattern C: "I assumed this can't happen, but it can"

  • "ecrecover can't return address(0)" → it can
  • "negative amounts can't be passed" → they can (felt252)
  • "this function won't be called twice" → it will (no onlyNewEpoch)
  • Examples: #9 (Polygon MRC20), #18 (Paradex), #3 (Alchemix)

→ NEXT: 06-methodology-research.md

Related skills

More from shuvonsec/web3-bug-bounty-hunting-ai-skills

Installs
3
GitHub Stars
57
First Seen
Mar 17, 2026