anchor-audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +28 -0
  2. package/dist/auditor.d.ts +16 -0
  3. package/dist/auditor.js +235 -0
  4. package/dist/auditor.js.map +1 -0
  5. package/dist/index.d.ts +19 -0
  6. package/dist/index.js +96 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/metadata.d.ts +25 -0
  9. package/dist/metadata.js +48 -0
  10. package/dist/metadata.js.map +1 -0
  11. package/dist/reporter.d.ts +18 -0
  12. package/dist/reporter.js +177 -0
  13. package/dist/reporter.js.map +1 -0
  14. package/dist/rules-loader.d.ts +12 -0
  15. package/dist/rules-loader.js +65 -0
  16. package/dist/rules-loader.js.map +1 -0
  17. package/dist/scanner.d.ts +6 -0
  18. package/dist/scanner.js +42 -0
  19. package/dist/scanner.js.map +1 -0
  20. package/package.json +41 -0
  21. package/rules/001-missing-signer-check.md +57 -0
  22. package/rules/002-missing-owner-check.md +53 -0
  23. package/rules/003-missing-discriminator-check.md +53 -0
  24. package/rules/004-account-substitution.md +54 -0
  25. package/rules/005-sysvar-spoofing.md +57 -0
  26. package/rules/006-missing-rent-exemption-check.md +47 -0
  27. package/rules/007-account-aliasing.md +53 -0
  28. package/rules/008-uninitialized-account-use.md +53 -0
  29. package/rules/009-missing-mut-constraint.md +52 -0
  30. package/rules/010-missing-close-constraint.md +48 -0
  31. package/rules/011-pda-seed-collision.md +49 -0
  32. package/rules/012-missing-bump-validation.md +55 -0
  33. package/rules/013-non-canonical-bump-accepted.md +52 -0
  34. package/rules/014-predictable-pda.md +57 -0
  35. package/rules/015-insecure-pda-across-upgrades.md +54 -0
  36. package/rules/016-bump-mismatch.md +49 -0
  37. package/rules/017-arbitrary-cpi.md +56 -0
  38. package/rules/018-cpi-confused-deputy.md +50 -0
  39. package/rules/019-missing-program-id-check-spl.md +51 -0
  40. package/rules/020-reentrancy-via-cpi.md +50 -0
  41. package/rules/021-untrusted-callback.md +49 -0
  42. package/rules/022-cpi-with-attacker-accounts.md +58 -0
  43. package/rules/023-lamport-overflow.md +50 -0
  44. package/rules/024-token-amount-overflow.md +52 -0
  45. package/rules/025-precision-loss.md +42 -0
  46. package/rules/026-rounding-direction.md +43 -0
  47. package/rules/027-token-decimal-mismatch.md +50 -0
  48. package/rules/028-integer-cast-truncation.md +42 -0
  49. package/rules/029-off-by-one.md +45 -0
  50. package/rules/030-missing-authorization.md +51 -0
  51. package/rules/031-reinitialization-attack.md +50 -0
  52. package/rules/032-closed-account-revival.md +49 -0
  53. package/rules/033-init-if-needed-misuse.md +66 -0
  54. package/rules/034-missing-has-one.md +55 -0
  55. package/rules/035-insecure-admin-transfer.md +49 -0
  56. package/rules/036-missing-pause-guards.md +50 -0
  57. package/rules/037-clock-manipulation.md +53 -0
  58. package/rules/038-missing-address-validation.md +53 -0
  59. package/rules/039-constraint-evaluation-stage.md +54 -0
  60. package/rules/040-realloc-zero-init.md +53 -0
  61. package/rules/041-missing-payer-on-init.md +59 -0
  62. package/rules/042-incorrect-space-allocation.md +53 -0
  63. package/rules/043-account-vs-account-info.md +53 -0
  64. package/rules/044-token-account-owner-unverified.md +56 -0
  65. package/rules/045-token-mint-unverified.md +58 -0
  66. package/rules/046-ata-assumption-errors.md +53 -0
  67. package/rules/047-token-program-id-hardcoded.md +55 -0
  68. package/rules/048-compute-budget-abuse.md +51 -0
  69. package/rules/049-log-spam-dos.md +49 -0
  70. package/rules/050-stack-overflow-deep-cpi.md +48 -0
  71. package/rules/INDEX.md +65 -0
  72. package/rules/README.md +40 -0
@@ -0,0 +1,50 @@
1
+ # Rule 018: CPI Confused Deputy
2
+
3
+ **Severity:** High
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ A program holds privileged authority (a PDA that owns a vault, mints a token, or controls an admin function) and exposes an instruction that performs a privileged CPI on a caller's behalf without checking that the caller is authorized for *that specific* resource. The program is the "deputy": it has authority, and the attacker confuses it into using that authority for the attacker's benefit.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Program PDA is the mint authority. This instruction mints to any
12
+ // destination the caller names, with no check on who may mint or how much.
13
+ pub fn mint_reward(ctx: Context<MintReward>, amount: u64) -> Result<()> {
14
+ let seeds = &[b"mint_auth", &[ctx.accounts.config.bump]];
15
+ token::mint_to(
16
+ CpiContext::new_with_signer(/* ... */, &[&seeds[..]]),
17
+ amount, // attacker-chosen, to an attacker-owned token account
18
+ )?;
19
+ Ok(())
20
+ }
21
+ ```
22
+
23
+ ## Why this is dangerous
24
+ The PDA signs the CPI, so from the token program's perspective the mint is fully authorized. The attacker calls `mint_reward` directly, names their own token account as the destination and an arbitrary amount, and the deputy mints unlimited tokens. The missing piece is authorization on the *caller* and bounds on the action.
25
+
26
+ ## Fix pattern
27
+ ```rust
28
+ pub fn mint_reward(ctx: Context<MintReward>, amount: u64) -> Result<()> {
29
+ // Verify the caller is entitled to this reward and the amount is bounded
30
+ require_keys_eq!(ctx.accounts.config.distributor, ctx.accounts.distributor.key());
31
+ require!(amount <= ctx.accounts.reward.claimable, ErrorCode::ExceedsClaimable);
32
+ ctx.accounts.reward.claimable -= amount;
33
+ // ...then perform the signed mint CPI
34
+ Ok(())
35
+ }
36
+ ```
37
+ Require a `Signer` for the privileged role and bind the destination to validated state.
38
+
39
+ ## Detection heuristic
40
+ - `invoke_signed` with a program-held PDA where the instruction lacks an authorization check on the caller
41
+ - Privileged CPIs (mint, transfer-from-vault, set-authority) whose amount/destination come straight from instruction args
42
+ - Instructions that expose the program's signing authority without `has_one`/`address`/signer gating tied to the affected resource
43
+
44
+ ## References
45
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
46
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
47
+ - Sec3 — Solana security best practices (https://www.sec3.dev/blog)
48
+
49
+ ## Real-world exploits (if any)
50
+ The confused-deputy pattern underlies several DeFi drains where a vault/mint authority PDA was invocable without adequate caller authorization; it recurs as a critical finding in public audits.
@@ -0,0 +1,51 @@
1
+ # Rule 019: Missing Program ID Check on SPL CPIs
2
+
3
+ **Severity:** High
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ A specialization of arbitrary CPI (Rule 017) for SPL Token / Token-2022 / Associated Token Program calls. Code constructs a token CPI but passes the token program as an unvalidated `AccountInfo`, or fails to distinguish SPL Token from Token-2022. The attacker substitutes a counterfeit token program that satisfies the call signature without moving real tokens.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
12
+ let cpi_accounts = Transfer {
13
+ from: ctx.accounts.user_token.to_account_info(),
14
+ to: ctx.accounts.vault_token.to_account_info(),
15
+ authority: ctx.accounts.user.to_account_info(),
16
+ };
17
+ // token_program is AccountInfo, never checked against spl_token::ID
18
+ let cpi = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
19
+ token::transfer(cpi, amount)?;
20
+ // program credits the user as if `amount` arrived
21
+ Ok(())
22
+ }
23
+ ```
24
+
25
+ ## Why this is dangerous
26
+ With a fake token program, the "transfer" succeeds (the fake program returns Ok and does nothing), but the program's own bookkeeping credits the user a deposit. The attacker mints protocol credit for free, then withdraws real tokens through a legitimate path. Token-2022 confusion is a related risk: assuming SPL Token semantics for a Token-2022 mint with transfer hooks/fees.
27
+
28
+ ## Fix pattern
29
+ ```rust
30
+ #[derive(Accounts)]
31
+ pub struct Deposit<'info> {
32
+ pub token_program: Program<'info, Token>, // pins spl_token::ID
33
+ // ...
34
+ }
35
+ // For Token-2022 support, use anchor_spl::token_interface and the
36
+ // TokenInterface program type, and validate the mint's program owner.
37
+ ```
38
+
39
+ ## Detection heuristic
40
+ - Token CPIs (`token::transfer`, `mint_to`, `burn`, `set_authority`) built from a `token_program: AccountInfo` instead of `Program<'info, Token>` / `Interface`
41
+ - Token-account types as `AccountInfo` rather than `Account<'info, TokenAccount>` / `InterfaceAccount`
42
+ - No `require_keys_eq!(token_program.key(), spl_token::ID)` when raw `AccountInfo` is used
43
+ - Code that assumes received amount equals requested amount (ignores Token-2022 transfer fees)
44
+
45
+ ## References
46
+ - Coral sealevel-attacks — 5-arbitrary-cpi (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/5-arbitrary-cpi)
47
+ - anchor_spl docs — Token / TokenInterface (https://docs.rs/anchor-spl/latest/anchor_spl/)
48
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
49
+
50
+ ## Real-world exploits (if any)
51
+ No single attributed public exploit; spoofed-token-program findings are common in public audits of staking and vault programs.
@@ -0,0 +1,50 @@
1
+ # Rule 020: Reentrancy via CPI
2
+
3
+ **Severity:** High
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ Solana's runtime forbids a program from being re-entered while already on the call stack *except* through self-recursion, which mitigates classic EVM-style reentrancy. But a check-then-CPI-then-effect ordering is still dangerous: if a program reads state, performs a CPI into another program, and that program (or a later instruction in the same transaction) can alter the first program's accounts before the effect is written, invariants break. The safe discipline is checks-effects-interactions: update your own state *before* the external call.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
12
+ require!(ctx.accounts.position.balance >= amount, ErrorCode::Insufficient);
13
+ // Interaction BEFORE effect: transfer out first...
14
+ token::transfer(/* vault -> user */, amount)?;
15
+ // ...then decrement. If the CPI path can re-enter a sibling instruction
16
+ // that also reads `balance`, the stale balance is double-spent.
17
+ ctx.accounts.position.balance -= amount;
18
+ Ok(())
19
+ }
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ A callback program, a Token-2022 transfer hook, or a crafted multi-instruction transaction can observe the un-decremented balance and act on it (withdraw again, use it as collateral) before the effect lands. Even without true reentrancy, doing effects after interactions widens the window for cross-instruction inconsistencies and partial-failure states.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
28
+ require!(ctx.accounts.position.balance >= amount, ErrorCode::Insufficient);
29
+ // Effect first
30
+ ctx.accounts.position.balance = ctx.accounts.position.balance
31
+ .checked_sub(amount).ok_or(ErrorCode::Underflow)?;
32
+ // Interaction last
33
+ token::transfer(/* vault -> user */, amount)?;
34
+ Ok(())
35
+ }
36
+ ```
37
+ Be especially careful with Token-2022 transfer hooks, which run untrusted code during the transfer.
38
+
39
+ ## Detection heuristic
40
+ - State mutations placed *after* CPIs that depend on the pre-CPI state (withdraw/transfer before decrement)
41
+ - CPIs into programs that can call back (transfer hooks, callback registries) mid-update
42
+ - Missing per-account "in progress"/lock flags around multi-step flows that span CPIs
43
+
44
+ ## References
45
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
46
+ - Sec3 — checks-effects-interactions on Solana (https://www.sec3.dev/blog)
47
+ - Solana docs — CPI and call depth (https://solana.com/docs/core/cpi)
48
+
49
+ ## Real-world exploits (if any)
50
+ No single attributed public Solana exploit (the runtime blocks the classic form); included because Token-2022 transfer hooks reintroduce callback-driven reentrancy surface.
@@ -0,0 +1,49 @@
1
+ # Rule 021: Untrusted Callback Execution
2
+
3
+ **Severity:** High
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ A program invokes a caller-supplied program as a "callback" or "hook" — flash-loan receivers, transfer hooks, router callbacks — without constraining which program may be called or validating the state it leaves behind. The callback runs arbitrary attacker code, often while the calling program is mid-operation and holding elevated authority or unsettled balances.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn flash_loan(ctx: Context<FlashLoan>, amount: u64) -> Result<()> {
12
+ let pre = ctx.accounts.vault.amount;
13
+ token::transfer(/* vault -> borrower */, amount)?;
14
+ // Calls an arbitrary borrower-supplied program with no allowlist...
15
+ invoke(&ctx.accounts.callback_ix, ctx.remaining_accounts)?;
16
+ // ...and never re-checks that the loan was repaid.
17
+ Ok(())
18
+ }
19
+ ```
20
+
21
+ ## Why this is dangerous
22
+ The borrower's callback program does whatever it wants with the borrowed funds and the accounts handed to it, then returns. Without a post-callback invariant check (balance restored + fee), the attacker keeps the loan. More broadly, any unvalidated callback can re-enter sibling instructions, manipulate oracle/price accounts, or abuse the caller's signer authority.
23
+
24
+ ## Fix pattern
25
+ ```rust
26
+ pub fn flash_loan(ctx: Context<FlashLoan>, amount: u64) -> Result<()> {
27
+ let pre = ctx.accounts.vault.amount;
28
+ let fee = amount / 1000;
29
+ token::transfer(/* vault -> borrower */, amount)?;
30
+ invoke(&ctx.accounts.callback_ix, ctx.remaining_accounts)?;
31
+ ctx.accounts.vault.reload()?;
32
+ require!(ctx.accounts.vault.amount >= pre + fee, ErrorCode::LoanNotRepaid);
33
+ Ok(())
34
+ }
35
+ ```
36
+ Where possible, allowlist callback program IDs and minimize the accounts/authority exposed to them.
37
+
38
+ ## Detection heuristic
39
+ - `invoke`/`invoke_signed` of a program ID taken from instruction data or `remaining_accounts` with no allowlist
40
+ - Flash-loan / hook patterns lacking a post-callback `reload()` + invariant assertion
41
+ - Callbacks passed the calling program's PDA signer or mutable core accounts
42
+
43
+ ## References
44
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
45
+ - Sec3 — flash loan and callback safety (https://www.sec3.dev/blog)
46
+ - SPL Token-2022 — transfer hook interface (https://spl.solana.com/token-2022/extensions#transfer-hook)
47
+
48
+ ## Real-world exploits (if any)
49
+ Flash-loan callbacks that fail to enforce repayment invariants have driven multiple DeFi drains across chains; on Solana this is a standard high-severity audit finding for lending/flash-loan programs.
@@ -0,0 +1,58 @@
1
+ # Rule 022: CPI Invoked with Attacker-Controlled Accounts
2
+
3
+ **Severity:** High
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ A program performs a CPI to a trusted program but forwards accounts that the caller chose, without validating that those accounts are the correct ones for the operation. The target program ID is right, yet the *accounts* passed into it (source token account, destination, mint, authority) are attacker-substituted, so the trusted CPI executes against the wrong assets.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn payout(ctx: Context<Payout>, amount: u64) -> Result<()> {
12
+ // token_program is correctly the SPL Token program, but `from` is
13
+ // whatever token account the caller supplied — including the protocol
14
+ // treasury — and the PDA authority signs for it.
15
+ let seeds = &[b"vault_auth", &[ctx.accounts.config.bump]];
16
+ token::transfer(
17
+ CpiContext::new_with_signer(
18
+ ctx.accounts.token_program.to_account_info(),
19
+ Transfer {
20
+ from: ctx.accounts.from.to_account_info(), // unvalidated
21
+ to: ctx.accounts.to.to_account_info(), // unvalidated
22
+ authority: ctx.accounts.vault_auth.to_account_info(),
23
+ },
24
+ &[&seeds[..]],
25
+ ),
26
+ amount,
27
+ )?;
28
+ Ok(())
29
+ }
30
+ ```
31
+
32
+ ## Why this is dangerous
33
+ Because the program's PDA signs the transfer, it authorizes movement from whatever `from` account is supplied. The attacker points `from` at a token account the PDA controls (or `to` at their own wallet) and drains it through an otherwise-legitimate, correctly-targeted token CPI. The program ID check alone is insufficient — the accounts must be pinned too.
34
+
35
+ ## Fix pattern
36
+ ```rust
37
+ #[derive(Accounts)]
38
+ pub struct Payout<'info> {
39
+ #[account(mut, address = config.vault_token)] // pin the source
40
+ pub from: Account<'info, TokenAccount>,
41
+ #[account(mut, token::mint = config.mint, token::authority = recipient)]
42
+ pub to: Account<'info, TokenAccount>,
43
+ // ...
44
+ }
45
+ ```
46
+
47
+ ## Detection heuristic
48
+ - CPIs whose account fields (`from`, `to`, `mint`, `authority`) are `AccountInfo`/`Account` with no `address =`, `token::*`, `has_one`, or seed binding
49
+ - A PDA signer used in a CPI where the source account is not constrained to the PDA-owned account
50
+ - `remaining_accounts` forwarded into CPIs without validation
51
+
52
+ ## References
53
+ - Coral sealevel-attacks — 5-arbitrary-cpi (account-level variant) (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/5-arbitrary-cpi)
54
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
55
+ - anchor_spl docs — token constraints (https://docs.rs/anchor-spl/latest/anchor_spl/)
56
+
57
+ ## Real-world exploits (if any)
58
+ No single attributed public exploit; unconstrained CPI accounts are a frequent critical/high finding in public audits of vault and payout programs.
@@ -0,0 +1,50 @@
1
+ # Rule 023: Lamport Arithmetic Overflow / Underflow
2
+
3
+ **Severity:** High
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ Lamport balances are `u64`. Adding to or subtracting from them with plain `+`/`-` (or `+=`/`-=`) risks wrapping: in release builds Rust arithmetic does not panic on overflow unless `overflow-checks` is enabled, so an underflow silently wraps to a huge number. Direct lamport manipulation via `try_borrow_mut_lamports` is especially exposed because it bypasses any token-program accounting.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn settle(ctx: Context<Settle>, fee: u64) -> Result<()> {
12
+ let vault = ctx.accounts.vault.to_account_info();
13
+ let user = ctx.accounts.user.to_account_info();
14
+ // If fee > vault balance, this underflows and wraps to ~u64::MAX
15
+ **vault.try_borrow_mut_lamports()? -= fee;
16
+ **user.try_borrow_mut_lamports()? += fee;
17
+ Ok(())
18
+ }
19
+ ```
20
+
21
+ ## Why this is dangerous
22
+ An underflow on the debit side wraps the vault's lamport field to an enormous value, and a paired credit can mint lamports out of nothing, breaking the transaction's lamport-conservation invariant (the runtime rejects unbalanced lamports, but intra-program accounting fields tracking "balance" can still be corrupted). Overflow on a credit can zero out a balance. Either way, accounting is falsified.
23
+
24
+ ## Fix pattern
25
+ ```rust
26
+ pub fn settle(ctx: Context<Settle>, fee: u64) -> Result<()> {
27
+ let vault = ctx.accounts.vault.to_account_info();
28
+ let user = ctx.accounts.user.to_account_info();
29
+ let v = vault.lamports();
30
+ **vault.try_borrow_mut_lamports()? = v.checked_sub(fee).ok_or(ErrorCode::Underflow)?;
31
+ let u = user.lamports();
32
+ **user.try_borrow_mut_lamports()? = u.checked_add(fee).ok_or(ErrorCode::Overflow)?;
33
+ Ok(())
34
+ }
35
+ ```
36
+ Also set `overflow-checks = true` in the program's release profile in `Cargo.toml`.
37
+
38
+ ## Detection heuristic
39
+ - `try_borrow_mut_lamports` with `+=`/`-=`/`+`/`-` instead of `checked_add`/`checked_sub`
40
+ - Lamport math on `account.lamports()` without checked operations
41
+ - `Cargo.toml` profile missing `overflow-checks = true`
42
+ - Any `u64` balance field updated with unchecked arithmetic
43
+
44
+ ## References
45
+ - Neodyme — Solana common pitfalls: integer overflow (https://neodyme.io/en/blog/solana_common_pitfalls/)
46
+ - Solana program security course — overflow and underflow (https://solana.com/developers/courses/program-security)
47
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
48
+
49
+ ## Real-world exploits (if any)
50
+ Unchecked arithmetic is a contributing factor in numerous DeFi accounting exploits; it is one of the most frequently flagged issues in public Solana audit reports.
@@ -0,0 +1,52 @@
1
+ # Rule 024: Token Amount Arithmetic Overflow
2
+
3
+ **Severity:** High
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ The same overflow/underflow risk as lamport math (Rule 023) applied to SPL token amounts, share calculations, reward accumulators, and any `u64`/`u128` balance the program tracks itself. Vaults that compute shares from deposits, AMMs that compute output amounts, and staking programs accumulating rewards all multiply and add large numbers; without checked arithmetic these wrap silently in release builds.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
12
+ let vault = &mut ctx.accounts.vault;
13
+ // shares = amount * total_shares / total_assets
14
+ // amount * total_shares overflows u64 for large inputs
15
+ let shares = amount * vault.total_shares / vault.total_assets;
16
+ vault.total_shares += shares; // unchecked
17
+ vault.total_assets += amount; // unchecked
18
+ ctx.accounts.user_position.shares += shares;
19
+ Ok(())
20
+ }
21
+ ```
22
+
23
+ ## Why this is dangerous
24
+ An overflow in `amount * total_shares` wraps to a small number, minting too few or — combined with a wrapped denominator — too many shares, letting an attacker mint shares disproportionate to their deposit and then redeem more than they put in. Accumulator overflows can reset reward debt. The conservation invariant (shares ↔ assets) is broken.
25
+
26
+ ## Fix pattern
27
+ ```rust
28
+ pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
29
+ let vault = &mut ctx.accounts.vault;
30
+ let shares = (amount as u128)
31
+ .checked_mul(vault.total_shares as u128).ok_or(ErrorCode::Overflow)?
32
+ .checked_div(vault.total_assets as u128).ok_or(ErrorCode::DivByZero)?;
33
+ let shares = u64::try_from(shares).map_err(|_| ErrorCode::Overflow)?;
34
+ vault.total_shares = vault.total_shares.checked_add(shares).ok_or(ErrorCode::Overflow)?;
35
+ vault.total_assets = vault.total_assets.checked_add(amount).ok_or(ErrorCode::Overflow)?;
36
+ Ok(())
37
+ }
38
+ ```
39
+
40
+ ## Detection heuristic
41
+ - `*`, `+`, `-` on token-amount/share/reward fields instead of `checked_*` (or widening to `u128` for intermediates)
42
+ - Multiplications of two `u64` token amounts without widening
43
+ - Reward/index accumulators using unchecked `+=`
44
+ - Missing `overflow-checks = true` in release profile
45
+
46
+ ## References
47
+ - Neodyme — Solana common pitfalls: integer overflow (https://neodyme.io/en/blog/solana_common_pitfalls/)
48
+ - Sec3 — arithmetic safety in Solana programs (https://www.sec3.dev/blog)
49
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
50
+
51
+ ## Real-world exploits (if any)
52
+ Share/asset arithmetic errors are a recurring root cause of vault and AMM exploits across DeFi; standard high-severity audit finding.
@@ -0,0 +1,42 @@
1
+ # Rule 025: Precision Loss (Division Before Multiplication)
2
+
3
+ **Severity:** Medium
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ Integer division truncates. Performing division before multiplication discards the remainder early, magnifying rounding error in the final result. The canonical bug is `(a / b) * c` where `a / b` rounds to zero or loses significant digits before being scaled up, instead of `(a * c) / b`, which preserves precision by dividing last.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // reward = stake / total_stake * reward_pool
12
+ // stake / total_stake is integer division: for any stake < total_stake
13
+ // it evaluates to 0, so reward is always 0.
14
+ let reward = (stake / total_stake) * reward_pool;
15
+ ```
16
+
17
+ ## Why this is dangerous
18
+ A user with 1 of 1000 total stake computes `1 / 1000 = 0`, then `0 * reward_pool = 0` — they earn nothing, while the rounding "dust" silently accrues somewhere or is lost. In fee or exchange-rate math, the same error lets an attacker structure amounts so the protocol rounds in their favor repeatedly, extracting value over many small transactions.
19
+
20
+ ## Fix pattern
21
+ ```rust
22
+ // Multiply first, divide last, and widen to u128 to avoid overflow:
23
+ let reward = (stake as u128)
24
+ .checked_mul(reward_pool as u128).ok_or(ErrorCode::Overflow)?
25
+ .checked_div(total_stake as u128).ok_or(ErrorCode::DivByZero)?;
26
+ let reward = u64::try_from(reward).map_err(|_| ErrorCode::Overflow)?;
27
+ ```
28
+ Where exactness matters, track and distribute remainders explicitly.
29
+
30
+ ## Detection heuristic
31
+ - Expressions of the form `(a / b) * c` or `a / b * c` on token/share/fee amounts
32
+ - Division applied to operands that are then scaled up
33
+ - Ratio/rate math done entirely in `u64` without widening to `u128`
34
+ - Reward-per-share or price calculations dividing before applying a multiplier
35
+
36
+ ## References
37
+ - Sec3 — arithmetic precision in Solana programs (https://www.sec3.dev/blog)
38
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
39
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
40
+
41
+ ## Real-world exploits (if any)
42
+ No single attributed public exploit; precision-loss findings are common in audits of staking and AMM math and can leak value continuously.
@@ -0,0 +1,43 @@
1
+ # Rule 026: Incorrect Rounding Direction
2
+
3
+ **Severity:** Medium
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ When integer math must round, the direction must always favor the protocol, not the user. Minting shares should round *down*; charging fees or computing how much a user must repay should round *up*. A consistent "round in the user's favor" or naive truncation lets an attacker repeatedly extract the rounding difference, and in share-based vaults enables inflation/donation attacks.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Withdrawal: assets owed for `shares`. Truncating division rounds DOWN
12
+ // the amount burned-for but the protocol pays out the rounded-up assets
13
+ // elsewhere — or, mint rounds UP giving free shares:
14
+ let shares_to_mint = (deposit * total_shares + total_assets) / total_assets; // rounds up on mint
15
+ ```
16
+
17
+ ## Why this is dangerous
18
+ If minting rounds up, an attacker deposits tiny amounts many times, each rounding up to extra shares, and redeems for more than deposited. The first-depositor / share-inflation attack combines zero-supply edge cases with favorable rounding to steal later depositors' funds. Rounding that favors the user is a slow, repeatable drain.
19
+
20
+ ## Fix pattern
21
+ ```rust
22
+ // Mint shares: round DOWN (truncating division is correct here)
23
+ let shares = deposit.checked_mul(total_shares)?.checked_div(total_assets)?;
24
+ // Repay / fee owed by user: round UP
25
+ let owed = amount.checked_mul(rate)?
26
+ .checked_add(scale - 1)? // ceil division
27
+ .checked_div(scale)?;
28
+ ```
29
+ Add a minimum-liquidity / dead-shares mechanism to neutralize first-depositor inflation.
30
+
31
+ ## Detection heuristic
32
+ - Share-minting math that rounds up, or redemption math that rounds up in the user's favor
33
+ - Fee/interest/repayment calculations that truncate (round down) what the user owes
34
+ - Vaults with no first-depositor protection (dead shares, minimum liquidity)
35
+ - Mixed rounding directions used inconsistently between deposit and withdraw paths
36
+
37
+ ## References
38
+ - Sec3 — rounding and share inflation (https://www.sec3.dev/blog)
39
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
40
+ - OpenZeppelin — ERC4626 inflation attack writeups (concept reference) (https://docs.openzeppelin.com/contracts/4.x/erc4626)
41
+
42
+ ## Real-world exploits (if any)
43
+ Share-inflation/first-depositor attacks have caused losses in vault protocols across ecosystems; rounding-direction errors are a standard medium/high audit finding.
@@ -0,0 +1,50 @@
1
+ # Rule 027: Token Decimal Mismatch
2
+
3
+ **Severity:** Medium
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ Different SPL mints have different `decimals`. Code that compares, adds, or exchanges raw token amounts across mints without normalizing for decimals treats `1 USDC` (6 decimals → 1_000_000) and `1 of an 9-decimal token` (1_000_000_000) as if they were the same magnitude. Any cross-mint price, swap, or collateral computation that ignores decimals is wrong by orders of magnitude.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Collateral value compared directly to debt, different mints/decimals:
12
+ let collateral_amount = ctx.accounts.collateral_token.amount; // 9 decimals
13
+ let debt_amount = ctx.accounts.debt_token.amount; // 6 decimals
14
+ require!(collateral_amount >= debt_amount, ErrorCode::Undercollateralized);
15
+ // 1.0 collateral (1e9) looks like 1000x the debt of 1.0 (1e6)
16
+ ```
17
+
18
+ ## Why this is dangerous
19
+ The attacker exploits the scale error in whichever direction helps them: borrowing against collateral that appears 1000x more valuable than it is, or swapping at a wildly mispriced rate. Hardcoding a decimal assumption (e.g. always 6) breaks the moment a mint with different decimals is used, which an attacker can arrange when mints are user-supplied.
20
+
21
+ ## Fix pattern
22
+ ```rust
23
+ // Normalize both sides to a common scale using each mint's decimals:
24
+ fn to_scaled(amount: u64, decimals: u8, target: u8) -> Option<u128> {
25
+ let a = amount as u128;
26
+ if decimals <= target {
27
+ a.checked_mul(10u128.pow((target - decimals) as u32))
28
+ } else {
29
+ Some(a / 10u128.pow((decimals - target) as u32))
30
+ }
31
+ }
32
+ let coll = to_scaled(collateral_amount, collateral_mint.decimals, 18)?;
33
+ let debt = to_scaled(debt_amount, debt_mint.decimals, 18)?;
34
+ require!(coll >= debt, ErrorCode::Undercollateralized);
35
+ ```
36
+ Read `decimals` from the actual `Mint` account, never hardcode.
37
+
38
+ ## Detection heuristic
39
+ - Cross-mint comparisons/arithmetic on raw `.amount` without reading each `Mint::decimals`
40
+ - Hardcoded decimal constants (e.g. `1_000_000`) instead of `mint.decimals`
41
+ - Price/collateral/swap math mixing amounts from different mints
42
+ - Mint accounts passed but `decimals` never read
43
+
44
+ ## References
45
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
46
+ - SPL Token docs — mint decimals (https://spl.solana.com/token)
47
+ - Sec3 — token handling pitfalls (https://www.sec3.dev/blog)
48
+
49
+ ## Real-world exploits (if any)
50
+ No single attributed public exploit; decimal-handling errors are a frequent audit finding in lending and DEX programs that support arbitrary mints.
@@ -0,0 +1,42 @@
1
+ # Rule 028: Integer Cast Truncation
2
+
3
+ **Severity:** Medium
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ The Rust `as` operator performs a silent, lossy cast: `large_u128 as u64`, `u64 as u32`, or `i64 as u8` discard high bits without any error. Casting a computed value down to a narrower type — common when interfacing with `u64` token amounts after `u128` intermediate math — can wrap a large number to a small one, falsifying amounts and bypassing bound checks.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ let scaled: u128 = (amount as u128) * (price as u128) / DENOM;
12
+ // scaled may exceed u64::MAX; `as u64` silently keeps only the low 64 bits
13
+ let payout: u64 = scaled as u64;
14
+ token::transfer(/* ... */, payout)?;
15
+ ```
16
+
17
+ ## Why this is dangerous
18
+ A `u128` result that legitimately exceeds `u64::MAX` wraps to a small `payout`, or a near-boundary value wraps in a way the attacker can engineer to mint/withdraw an amount that passes earlier `<=` checks (performed in the wide type) but executes with a different narrow value. Downcasting indices or counts (`as u32`, `as u8`) can also wrap loop bounds.
19
+
20
+ ## Fix pattern
21
+ ```rust
22
+ let scaled: u128 = (amount as u128)
23
+ .checked_mul(price as u128).ok_or(ErrorCode::Overflow)?
24
+ .checked_div(DENOM).ok_or(ErrorCode::DivByZero)?;
25
+ // Fallible conversion: errors instead of truncating
26
+ let payout: u64 = u64::try_from(scaled).map_err(|_| ErrorCode::Overflow)?;
27
+ token::transfer(/* ... */, payout)?;
28
+ ```
29
+
30
+ ## Detection heuristic
31
+ - `as u64` / `as u32` / `as u16` / `as u8` applied to wider computed values (especially after `u128` math)
32
+ - Narrowing casts on token amounts, lamports, prices, or indices
33
+ - Casts used in place of `try_from` / `try_into` where the source range exceeds the target
34
+ - Signed/unsigned casts (`as i64` ↔ `as u64`) on values that could be negative or large
35
+
36
+ ## References
37
+ - Neodyme — Solana common pitfalls: casting (https://neodyme.io/en/blog/solana_common_pitfalls/)
38
+ - Rust reference — numeric cast semantics (https://doc.rust-lang.org/reference/expressions/operator-expr.html#numeric-cast)
39
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
40
+
41
+ ## Real-world exploits (if any)
42
+ No single attributed public exploit; truncating casts are a recurring audit finding wherever `u128` intermediate math is narrowed back to `u64`.
@@ -0,0 +1,45 @@
1
+ # Rule 029: Off-by-One Errors
2
+
3
+ **Severity:** Low
4
+ **Category:** Math
5
+
6
+ ## Description
7
+ Boundary mistakes in iteration, indexing, slicing, and threshold comparisons: `<` where `<=` was meant, loops that skip the last element or read one past the end, and slice ranges that drop or duplicate a byte. On Solana these surface in manual account-data parsing, `remaining_accounts` iteration, and threshold checks (quorums, caps, expiry).
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Quorum check off by one: requires strictly more than half, but the
12
+ // intent was "at least half" — or vice-versa. Either way the boundary
13
+ // case is wrong.
14
+ require!(votes > total / 2, ErrorCode::QuorumNotMet);
15
+
16
+ // Slice that drops the last byte of the discriminator/region:
17
+ let body = &data[8..data.len() - 1];
18
+ ```
19
+
20
+ ## Why this is dangerous
21
+ A threshold off by one lets a proposal pass with one vote too few, or blocks a legitimate one. Slice/index off-by-ones either panic (DoS for that instruction) or, worse, read adjacent bytes, mis-parsing a field that feeds into authorization or amount logic. In `remaining_accounts` loops, an off-by-one can skip a required account check.
22
+
23
+ ## Fix pattern
24
+ ```rust
25
+ // State the boundary explicitly and test it:
26
+ require!(votes.checked_mul(2).ok_or(ErrorCode::Overflow)? >= total,
27
+ ErrorCode::QuorumNotMet); // >= half
28
+
29
+ let body = data.get(8..).ok_or(ErrorCode::Malformed)?; // no manual len math
30
+ ```
31
+ Prefer `.get(range)` (returns `Option`) over direct indexing, and add explicit boundary tests.
32
+
33
+ ## Detection heuristic
34
+ - Threshold comparisons (`>`, `>=`, `<`, `<=`) on quorums, caps, expiries, min/max where the boundary intent is ambiguous
35
+ - Manual slice ranges with `+ 1` / `- 1` / `len() - 1` arithmetic
36
+ - `for i in 0..n` loops indexing `arr[i + 1]` or `arr[i - 1]`
37
+ - Direct slice indexing (`data[a..b]`) on attacker-sized data instead of `.get`
38
+
39
+ ## References
40
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
41
+ - Sec3 — common Solana logic bugs (https://www.sec3.dev/blog)
42
+ - Rust docs — slice::get (https://doc.rust-lang.org/std/primitive.slice.html#method.get)
43
+
44
+ ## Real-world exploits (if any)
45
+ No single attributed public exploit; off-by-one bugs are common low/medium audit findings, occasionally escalating when they govern authorization thresholds.
@@ -0,0 +1,51 @@
1
+ # Rule 030: Missing Authorization on Privileged Instruction
2
+
3
+ **Severity:** Critical
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ A privileged instruction — withdraw, close, set-config, pause, mint, upgrade-authority change — performs its action without verifying that the caller is allowed to. This is broader than a missing signer (Rule 001): even with a signer present, the program may fail to check that the signer is *the right* principal for this resource (the vault's owner, the protocol admin, the position holder).
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct SetFee<'info> {
13
+ #[account(mut)]
14
+ pub config: Account<'info, Config>,
15
+ pub caller: Signer<'info>, // any signer at all
16
+ }
17
+
18
+ pub fn set_fee(ctx: Context<SetFee>, new_fee: u16) -> Result<()> {
19
+ // No check that caller == config.admin
20
+ ctx.accounts.config.fee_bps = new_fee;
21
+ Ok(())
22
+ }
23
+ ```
24
+
25
+ ## Why this is dangerous
26
+ Anyone can sign a transaction, so requiring "a signer" without binding it to the privileged role lets any user set the protocol fee, withdraw from any vault, or change critical config. The attacker simply calls the instruction with their own signature. This is the most direct privilege-escalation class.
27
+
28
+ ## Fix pattern
29
+ ```rust
30
+ #[derive(Accounts)]
31
+ pub struct SetFee<'info> {
32
+ #[account(mut, has_one = admin)] // config.admin must equal admin.key()
33
+ pub config: Account<'info, Config>,
34
+ pub admin: Signer<'info>,
35
+ }
36
+ ```
37
+ Use `has_one`, `address = config.admin`, or an explicit `require_keys_eq!(caller.key(), config.admin)` plus a signer requirement.
38
+
39
+ ## Detection heuristic
40
+ - Privileged handlers (set_*, withdraw, close, mint, pause, transfer_authority) with a `Signer` that is never compared to a stored authority
41
+ - Missing `has_one` / `address =` / `require_keys_eq!` linking the signer to the resource's owner/admin
42
+ - Config or vault mutations where the only gate is "is a signer"
43
+ - Admin functions reachable by accounts with no role binding
44
+
45
+ ## References
46
+ - Solana program security course — signer & owner authorization (https://solana.com/developers/courses/program-security/signer-auth)
47
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
48
+ - Anchor docs — has_one and address constraints (https://www.anchor-lang.com/docs/account-constraints)
49
+
50
+ ## Real-world exploits (if any)
51
+ Missing/weak authorization is among the most common root causes in public Solana exploit post-mortems and audit critical findings.