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,47 @@
1
+ # Rule 006: Missing Rent-Exemption Check
2
+
3
+ **Severity:** Low
4
+ **Category:** Account validation
5
+
6
+ ## Description
7
+ Accounts created or resized through raw system-program calls must be funded to the rent-exempt minimum for their size. Anchor's `init` and `realloc` handle this automatically, but manual `create_account` / `transfer` + `allocate` flows that compute lamports themselves can under-fund the account. The runtime rejects most non-exempt account creation today, but programs that accept *existing* accounts and assume durable state should still verify exemption rather than assume it.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Manual account creation with a hardcoded lamport amount
12
+ let create_ix = system_instruction::create_account(
13
+ payer.key,
14
+ new_account.key,
15
+ 1_000_000, // guessed value — not Rent::get()?.minimum_balance(space)
16
+ space as u64,
17
+ program_id,
18
+ );
19
+ invoke(&create_ix, &[payer.clone(), new_account.clone()])?;
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ Under-funded accounts fail at runtime in ways that surface as hard-to-diagnose errors, and lamport-balance assumptions elsewhere in the program (e.g. treating every account's balance above some floor as withdrawable) can double-count the rent reserve. Draining an account below its rent-exempt minimum while leaving it open also makes subsequent writes fail.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ let rent = Rent::get()?;
28
+ let lamports = rent.minimum_balance(space);
29
+ let create_ix = system_instruction::create_account(
30
+ payer.key, new_account.key, lamports, space as u64, program_id,
31
+ );
32
+ invoke(&create_ix, &[payer.clone(), new_account.clone()])?;
33
+ ```
34
+ In Anchor, prefer `#[account(init, payer = payer, space = 8 + Data::INIT_SPACE)]`, which funds exemption automatically.
35
+
36
+ ## Detection heuristic
37
+ - `system_instruction::create_account` with a literal/derived lamport value not based on `Rent::minimum_balance`
38
+ - Lamport withdrawals that drain an account to zero or below `Rent::minimum_balance(data_len)` while the account stays open
39
+ - Programs reading `account.lamports()` as "user balance" without subtracting the rent reserve
40
+
41
+ ## References
42
+ - Solana docs — rent (https://solana.com/docs/core/fees#rent)
43
+ - Anchor docs — account constraints, init (https://www.anchor-lang.com/docs/account-constraints)
44
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
45
+
46
+ ## Real-world exploits (if any)
47
+ No public exploit attributed to this pattern; it appears in audits as a robustness/informational finding that compounds with lamport-accounting bugs (see Rule 023).
@@ -0,0 +1,53 @@
1
+ # Rule 007: Account Aliasing (Duplicate Mutable Accounts)
2
+
3
+ **Severity:** High
4
+ **Category:** Account validation
5
+
6
+ ## Description
7
+ An instruction takes two mutable accounts of the same type for two distinct logical roles (sender/receiver, position A/position B) but never checks that they are different accounts. Passing the *same* account for both roles makes the handler's reads and writes interleave on one underlying account, corrupting balance math — typically letting value be created or destroyed.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Transfer<'info> {
13
+ #[account(mut)]
14
+ pub from: Account<'info, Balance>,
15
+ #[account(mut)]
16
+ pub to: Account<'info, Balance>, // may be the same account as `from`
17
+ pub authority: Signer<'info>,
18
+ }
19
+
20
+ pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
21
+ ctx.accounts.from.amount -= amount;
22
+ ctx.accounts.to.amount += amount; // overwrites the deduction when aliased
23
+ Ok(())
24
+ }
25
+ ```
26
+
27
+ ## Why this is dangerous
28
+ With `from == to`, Anchor deserializes the account into two independent in-memory copies; the last one serialized on exit wins. In the pattern above the `to` copy never saw the deduction, so the attacker's balance is credited `amount` with no debit — free money on every call.
29
+
30
+ ## Fix pattern
31
+ ```rust
32
+ #[derive(Accounts)]
33
+ pub struct Transfer<'info> {
34
+ #[account(mut)]
35
+ pub from: Account<'info, Balance>,
36
+ #[account(mut, constraint = to.key() != from.key() @ ErrorCode::DuplicateAccount)]
37
+ pub to: Account<'info, Balance>,
38
+ pub authority: Signer<'info>,
39
+ }
40
+ ```
41
+
42
+ ## Detection heuristic
43
+ - Two or more `#[account(mut)]` fields of the same account type in one context
44
+ - No `constraint = a.key() != b.key()` (or distinct PDA seeds) separating same-typed mutable accounts
45
+ - Handlers performing read-modify-write on both accounts (balances, counters, positions)
46
+
47
+ ## References
48
+ - Coral sealevel-attacks — 6-duplicate-mutable-accounts (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/6-duplicate-mutable-accounts)
49
+ - Solana program security course — duplicate mutable accounts (https://solana.com/developers/courses/program-security/duplicate-mutable-accounts)
50
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
51
+
52
+ ## Real-world exploits (if any)
53
+ No public headline exploit; a standard critical finding in public audit reports for AMM/order-matching programs that move value between same-typed accounts.
@@ -0,0 +1,53 @@
1
+ # Rule 008: Uninitialized Account Use
2
+
3
+ **Severity:** High
4
+ **Category:** Account validation
5
+
6
+ ## Description
7
+ An account is read or written before its initialization is complete or verified. This includes deserializing accounts whose data is still all zeroes, trusting fields of an account that a separate "initialize" instruction was supposed to populate, and `zero`-copy patterns that skip an is-initialized flag. Zeroed bytes often deserialize into *valid-looking* defaults (authority = `Pubkey::default()`, amount = 0).
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> {
12
+ let vault = &ctx.accounts.vault;
13
+ // If vault was never initialized, vault.authority == Pubkey::default()
14
+ // and an attacker can satisfy this by passing the default pubkey path
15
+ if vault.authority != ctx.accounts.authority.key() {
16
+ return err!(ErrorCode::Unauthorized);
17
+ }
18
+ // ...
19
+ Ok(())
20
+ }
21
+ ```
22
+
23
+ ## Why this is dangerous
24
+ An attacker sequences instructions so the consuming instruction runs against an account that exists (rent-funded, correct owner) but was never initialized, or races initialization in the same transaction. Default field values then pass or bypass checks — `Pubkey::default()` authorities, zero balances treated as "no debt", or flags that read as false.
25
+
26
+ ## Fix pattern
27
+ ```rust
28
+ #[account]
29
+ pub struct Vault {
30
+ pub is_initialized: bool,
31
+ pub authority: Pubkey,
32
+ }
33
+
34
+ // Anchor's Account<'info, Vault> already rejects accounts whose
35
+ // discriminator is unset (never went through `init`). For raw accounts:
36
+ require!(vault.is_initialized, ErrorCode::Uninitialized);
37
+ require_keys_neq!(vault.authority, Pubkey::default());
38
+ ```
39
+ Use `#[account(zero)]` only for accounts being initialized *in this instruction*, never for accounts being consumed.
40
+
41
+ ## Detection heuristic
42
+ - `AccountInfo`/`UncheckedAccount` data deserialized without a discriminator or `is_initialized` check
43
+ - Authority comparisons that would pass for `Pubkey::default()`
44
+ - `#[account(zero)]` on accounts that are read before being written
45
+ - Multi-step init flows (create, then configure) where step 2+ doesn't verify step 1 ran
46
+
47
+ ## References
48
+ - Coral sealevel-attacks — 4-initialization (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/4-initialization)
49
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
50
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
51
+
52
+ ## Real-world exploits (if any)
53
+ No single attributed public exploit; closely related to reinitialization attacks (Rule 031), which have caused fund loss in unaudited programs.
@@ -0,0 +1,52 @@
1
+ # Rule 009: Missing `mut` Constraint
2
+
3
+ **Severity:** Medium
4
+ **Category:** Account validation
5
+
6
+ ## Description
7
+ A handler modifies an account's data or lamports, but the account is not declared `#[account(mut)]`. Anchor only persists changes for accounts marked writable; without `mut`, the runtime either rejects the transaction (for lamport changes) or silently discards data mutations at serialization time, depending on the access path. Logic that *appears* to update state — debiting a balance, flipping a flag — leaves on-chain state untouched.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct RecordDebt<'info> {
13
+ pub user_state: Account<'info, UserState>, // missing #[account(mut)]
14
+ #[account(mut)]
15
+ pub vault: Account<'info, Vault>,
16
+ pub user: Signer<'info>,
17
+ }
18
+
19
+ pub fn borrow(ctx: Context<RecordDebt>, amount: u64) -> Result<()> {
20
+ ctx.accounts.user_state.debt += amount; // never persisted
21
+ ctx.accounts.vault.balance -= amount; // persisted — funds leave
22
+ Ok(())
23
+ }
24
+ ```
25
+
26
+ ## Why this is dangerous
27
+ The asymmetry is the exploit: the value-moving side persists while the bookkeeping side does not. In the example, a user borrows repeatedly and their recorded debt stays at zero — the protocol pays out with no liability recorded. Even when the failure mode is a transaction error, it can brick critical paths like liquidations.
28
+
29
+ ## Fix pattern
30
+ ```rust
31
+ #[derive(Accounts)]
32
+ pub struct RecordDebt<'info> {
33
+ #[account(mut, has_one = user)]
34
+ pub user_state: Account<'info, UserState>,
35
+ #[account(mut)]
36
+ pub vault: Account<'info, Vault>,
37
+ pub user: Signer<'info>,
38
+ }
39
+ ```
40
+
41
+ ## Detection heuristic
42
+ - Handler assigns to fields of an account (`ctx.accounts.x.field = ...`, `+=`, `-=`) whose struct field lacks `#[account(mut)]`
43
+ - Lamport mutation (`try_borrow_mut_lamports`) on non-`mut` accounts
44
+ - Pairs of accounts where one side of a balanced operation is `mut` and the other is not
45
+
46
+ ## References
47
+ - Anchor docs — account constraints (https://www.anchor-lang.com/docs/account-constraints)
48
+ - The Anchor Book — the Accounts struct (https://book.anchor-lang.com/anchor_in_depth/the_accounts_struct.html)
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
+ No public exploit attributed; typically caught as a correctness bug in audits because it makes state updates silently no-op.
@@ -0,0 +1,48 @@
1
+ # Rule 010: Missing or Improper Close Constraint
2
+
3
+ **Severity:** Medium
4
+ **Category:** Account validation
5
+
6
+ ## Description
7
+ Closing an account on Solana means draining its lamports, but a "manual close" that only transfers lamports leaves the account's data intact for the rest of the transaction — and garbage collection only happens after the transaction ends. A close path that doesn't zero the data and mark the account closed (or that doesn't exist at all, stranding rent) is incorrect. Anchor's `close = receiver` constraint performs all required steps.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
12
+ let position = ctx.accounts.position.to_account_info();
13
+ let dest = ctx.accounts.receiver.to_account_info();
14
+ // Lamports drained — but data and discriminator left intact
15
+ **dest.try_borrow_mut_lamports()? += position.lamports();
16
+ **position.try_borrow_mut_lamports()? = 0;
17
+ Ok(())
18
+ }
19
+ ```
20
+
21
+ ## Why this is dangerous
22
+ Within the same transaction, the attacker sends rent-exempt lamports back to the "closed" account in a later instruction, preventing garbage collection. The account survives with stale data and can be passed into other instructions as if still live — e.g. a closed loan position that still vouches for collateral (see Rule 032 for the revival half of this attack).
23
+
24
+ ## Fix pattern
25
+ ```rust
26
+ #[derive(Accounts)]
27
+ pub struct ClosePosition<'info> {
28
+ #[account(mut, has_one = owner, close = receiver)]
29
+ pub position: Account<'info, Position>,
30
+ #[account(mut)]
31
+ pub receiver: SystemAccount<'info>,
32
+ pub owner: Signer<'info>,
33
+ }
34
+ ```
35
+ Anchor's `close` drains lamports, zeroes data, and writes the `CLOSED_ACCOUNT_DISCRIMINATOR`.
36
+
37
+ ## Detection heuristic
38
+ - Manual lamport-drain "closes" (`try_borrow_mut_lamports` to zero) without zeroing `data` and setting a closed discriminator
39
+ - Account types that are initialized somewhere but have no close path at all (stranded rent, unbounded account growth)
40
+ - Close instructions missing an authority check on who may close
41
+
42
+ ## References
43
+ - Coral sealevel-attacks — 9-closing-accounts (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/9-closing-accounts)
44
+ - Solana program security course — closing accounts (https://solana.com/developers/courses/program-security/closing-accounts)
45
+ - Anchor docs — account constraints, close (https://www.anchor-lang.com/docs/account-constraints)
46
+
47
+ ## Real-world exploits (if any)
48
+ No single attributed public exploit; the revival variant is a standard critical audit finding (see Rule 032).
@@ -0,0 +1,49 @@
1
+ # Rule 011: PDA Seed Collision
2
+
3
+ **Severity:** High
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ Two logically distinct PDAs can derive to the same address when their seed schemes overlap. Seeds are concatenated raw bytes with no delimiters or type tags, so `["vault", user, mint]` and `["vault", mint, user]`-style ambiguities, variable-length seeds (user-supplied strings), or two account kinds sharing a prefix can all collide. A PDA created for one purpose then satisfies derivation checks for another.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // Two account kinds, same seed shape — both derive from ["pool", key]
12
+ #[account(seeds = [b"pool", base_mint.key().as_ref()], bump)]
13
+ pub lending_pool: Account<'info, LendingPool>,
14
+
15
+ // elsewhere in the program:
16
+ #[account(seeds = [b"pool", quote_mint.key().as_ref()], bump)]
17
+ pub staking_pool: Account<'info, StakingPool>,
18
+
19
+ // Or: variable-length user input lets ["ab" + "cd"] == ["a" + "bcd"]
20
+ #[account(seeds = [name.as_bytes(), suffix.as_bytes()], bump)]
21
+ pub named_account: Account<'info, Named>,
22
+ ```
23
+
24
+ ## Why this is dangerous
25
+ An attacker crafts inputs so the address of an account they control (or one with favorable state) derives identically to the account the program expects in a different context. Seed checks pass, and the program operates on the wrong account — mixing pools, reusing one account for two roles, or hijacking a namespace.
26
+
27
+ ## Fix pattern
28
+ ```rust
29
+ // Distinct literal prefixes per account kind, fixed-length seeds,
30
+ // and a length cap on any user-supplied seed component
31
+ #[account(seeds = [b"lending_pool", base_mint.key().as_ref()], bump)]
32
+ pub lending_pool: Account<'info, LendingPool>,
33
+
34
+ #[account(seeds = [b"staking_pool", quote_mint.key().as_ref()], bump)]
35
+ pub staking_pool: Account<'info, StakingPool>,
36
+ ```
37
+
38
+ ## Detection heuristic
39
+ - Multiple account types whose `seeds = [...]` share the same literal prefix and shape
40
+ - Seeds containing user-controlled variable-length data (strings, vecs) — adjacent variable-length seeds are always ambiguous
41
+ - The same seed tuple used by more than one instruction for different account types
42
+
43
+ ## References
44
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
45
+ - Sec3 — How to audit Solana smart contracts (https://www.sec3.dev/blog)
46
+ - Solana docs — program derived addresses (https://solana.com/docs/core/pda)
47
+
48
+ ## Real-world exploits (if any)
49
+ No public headline exploit; seed-design findings appear in public Neodyme and Sec3 audit reports, usually rated high due to namespace hijack potential.
@@ -0,0 +1,55 @@
1
+ # Rule 012: Missing Bump Validation
2
+
3
+ **Severity:** Medium
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ A PDA account is accepted without any verification that its address actually derives from the expected seeds and bump for this program. If the account is typed `AccountInfo`/`UncheckedAccount` (or the handler recomputes nothing), an attacker can pass an arbitrary account where a PDA is expected, or pass a PDA derived from different seeds than intended.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Withdraw<'info> {
13
+ /// CHECK: vault PDA — but nothing verifies the derivation
14
+ #[account(mut)]
15
+ pub vault_authority: AccountInfo<'info>,
16
+ #[account(mut)]
17
+ pub vault_token: Account<'info, TokenAccount>,
18
+ pub user: Signer<'info>,
19
+ }
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ The program assumes `vault_authority` is *its* PDA for *this* vault, but any account passes. Depending on what the handler does, the attacker redirects authority checks, signs CPIs with the wrong derivation, or points shared logic at an account from an unrelated context. PDA identity is only meaningful if it is recomputed and compared.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ #[derive(Accounts)]
28
+ pub struct Withdraw<'info> {
29
+ /// CHECK: derivation enforced by seeds + bump below
30
+ #[account(
31
+ mut,
32
+ seeds = [b"vault_authority", vault_token.key().as_ref()],
33
+ bump = vault.authority_bump, // stored canonical bump
34
+ )]
35
+ pub vault_authority: AccountInfo<'info>,
36
+ #[account(mut)]
37
+ pub vault_token: Account<'info, TokenAccount>,
38
+ #[account(has_one = vault_token)]
39
+ pub vault: Account<'info, Vault>,
40
+ pub user: Signer<'info>,
41
+ }
42
+ ```
43
+
44
+ ## Detection heuristic
45
+ - Accounts whose names imply PDA roles (`*_authority`, `*_pda`, `escrow`, `vault`) with no `seeds`/`bump` constraint and no manual `find_program_address` comparison
46
+ - Handlers that use an account as a CPI signer (`invoke_signed`) whose address was never validated against those signer seeds
47
+ - `bump` arguments accepted from instruction data and used without verification (see Rule 013)
48
+
49
+ ## References
50
+ - Coral sealevel-attacks — 7-bump-seed-canonicalization (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/7-bump-seed-canonicalization)
51
+ - Anchor docs — account constraints, seeds/bump (https://www.anchor-lang.com/docs/account-constraints)
52
+ - Solana program security course — bump seed canonicalization (https://solana.com/developers/courses/program-security/bump-seed-canonicalization)
53
+
54
+ ## Real-world exploits (if any)
55
+ No single attributed public exploit; unvalidated PDA inputs are a recurring high-severity finding in public OtterSec and Sec3 reports.
@@ -0,0 +1,52 @@
1
+ # Rule 013: Non-Canonical Bump Accepted
2
+
3
+ **Severity:** High
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ For any set of seeds there are typically several valid bump values that produce off-curve addresses, but only one *canonical* bump (the highest, found by `find_program_address`). If a program accepts a caller-supplied bump and validates only that the resulting address is a valid PDA — rather than that it used the canonical bump — an attacker can derive multiple distinct, valid PDAs from the same logical seeds.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ #[instruction(bump: u8)]
13
+ pub struct Init<'info> {
14
+ #[account(
15
+ init, payer = user, space = 8 + 32,
16
+ seeds = [b"user", user.key().as_ref()],
17
+ bump, // with the bump taken from instruction args, any valid bump passes
18
+ )]
19
+ pub user_pda: Account<'info, UserData>,
20
+ #[account(mut)]
21
+ pub user: Signer<'info>,
22
+ }
23
+ // handler trusts the `bump` arg and calls create_program_address with it
24
+ ```
25
+
26
+ ## Why this is dangerous
27
+ The attacker initializes several PDAs for the same seeds using different non-canonical bumps, creating duplicate "user" accounts where the program assumed one-per-user. This defeats per-user accounting, one-time-claim guards, and uniqueness invariants — e.g. claiming an airdrop multiple times.
28
+
29
+ ## Fix pattern
30
+ ```rust
31
+ #[account(
32
+ init, payer = user, space = 8 + 32,
33
+ seeds = [b"user", user.key().as_ref()],
34
+ bump, // Anchor uses the canonical bump from find_program_address
35
+ )]
36
+ pub user_pda: Account<'info, UserData>,
37
+ // On later use, re-derive with the stored canonical bump:
38
+ #[account(seeds = [b"user", user.key().as_ref()], bump = user_pda.bump)]
39
+ ```
40
+
41
+ ## Detection heuristic
42
+ - `bump` values read from instruction arguments and passed to `create_program_address` / `Pubkey::create_program_address`
43
+ - `bump = <user_input>` rather than bare `bump` (canonical) or `bump = stored_bump`
44
+ - Any use of `create_program_address` without a preceding `find_program_address` canonical comparison
45
+
46
+ ## References
47
+ - Coral sealevel-attacks — 7-bump-seed-canonicalization (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/7-bump-seed-canonicalization)
48
+ - Solana program security course — bump seed canonicalization (https://solana.com/developers/courses/program-security/bump-seed-canonicalization)
49
+ - Anchor docs — PDA constraints (https://www.anchor-lang.com/docs/account-constraints)
50
+
51
+ ## Real-world exploits (if any)
52
+ No single attributed public exploit; canonical-bump findings are common in public audits of claim/airdrop and per-user-account programs.
@@ -0,0 +1,57 @@
1
+ # Rule 014: Predictable / Attacker-Controlled PDA Seeds
2
+
3
+ **Severity:** High
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ A PDA's authority or identity derives entirely from seeds the attacker can choose, with no signer or ownership tying the PDA to a legitimate principal. Because anyone can compute and request initialization of such a PDA, the attacker front-runs or pre-creates the account, taking control of state the program treats as authoritative.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ #[instruction(market_id: u64)]
13
+ pub struct CreateMarket<'info> {
14
+ #[account(
15
+ init, payer = anyone, space = 8 + Market::INIT_SPACE,
16
+ seeds = [b"market", market_id.to_le_bytes().as_ref()],
17
+ bump,
18
+ )]
19
+ pub market: Account<'info, Market>,
20
+ #[account(mut)]
21
+ pub anyone: Signer<'info>, // no admin/authority gate
22
+ }
23
+ ```
24
+
25
+ ## Why this is dangerous
26
+ Since `market_id` is attacker-chosen and no privileged signer is required, an attacker creates markets at addresses the protocol (or integrators) will later trust, seeding them with malicious parameters (fee recipient = attacker, oracle = attacker). Any later instruction that resolves "the market for id X" lands on the attacker's account.
27
+
28
+ ## Fix pattern
29
+ ```rust
30
+ #[derive(Accounts)]
31
+ #[instruction(market_id: u64)]
32
+ pub struct CreateMarket<'info> {
33
+ #[account(
34
+ init, payer = admin, space = 8 + Market::INIT_SPACE,
35
+ seeds = [b"market", market_id.to_le_bytes().as_ref()],
36
+ bump,
37
+ )]
38
+ pub market: Account<'info, Market>,
39
+ #[account(mut, address = config.admin)] // only the protocol admin may create
40
+ pub admin: Signer<'info>,
41
+ #[account(seeds = [b"config"], bump = config.bump)]
42
+ pub config: Account<'info, Config>,
43
+ }
44
+ ```
45
+
46
+ ## Detection heuristic
47
+ - `init` on a PDA whose seeds are fully attacker-supplied (instruction args, user-chosen ids) with no privileged `Signer` / `address =` gate
48
+ - PDAs whose seeds omit any principal binding (no `user.key()`, no `authority.key()`) for accounts that represent ownership
49
+ - Programs that resolve trusted singletons by attacker-chosen id without verifying who created them
50
+
51
+ ## References
52
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
53
+ - Sec3 — Solana security best practices (https://www.sec3.dev/blog)
54
+ - Solana docs — program derived addresses (https://solana.com/docs/core/pda)
55
+
56
+ ## Real-world exploits (if any)
57
+ No single attributed public exploit; front-running of permissionlessly-creatable PDAs is a recurring audit finding for market/pool factories.
@@ -0,0 +1,54 @@
1
+ # Rule 015: Insecure PDA Layout Across Upgrades
2
+
3
+ **Severity:** Medium
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ Solana programs are upgradeable, but PDA accounts created by an old version persist with their old data layout. If an upgrade changes an account struct's fields, order, or size — or changes the seed scheme — without a versioning/migration strategy, the new code misinterprets existing accounts' bytes or can no longer derive their addresses.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // v1
12
+ #[account]
13
+ pub struct Vault { pub authority: Pubkey, pub balance: u64 }
14
+
15
+ // v2 inserts a field in the middle — existing accounts now misparse:
16
+ #[account]
17
+ pub struct Vault {
18
+ pub authority: Pubkey,
19
+ pub admin: Pubkey, // NEW, shifts `balance` bytes
20
+ pub balance: u64,
21
+ }
22
+ // No version tag, no migration instruction.
23
+ ```
24
+
25
+ ## Why this is dangerous
26
+ Old accounts deserialize with shifted fields: `balance` reads bytes that used to be part of another field, producing wrong values that flow into withdrawal/accounting logic. If the seed scheme changed instead, old accounts become unreachable, stranding user funds. Either way an attacker can exploit the mismatch or the protocol simply loses correctness.
27
+
28
+ ## Fix pattern
29
+ ```rust
30
+ #[account]
31
+ pub struct Vault {
32
+ pub version: u8, // bump on layout change
33
+ pub authority: Pubkey,
34
+ pub balance: u64,
35
+ pub admin: Pubkey, // append new fields at the END
36
+ }
37
+ // Provide an explicit `migrate_vault` instruction that reads version,
38
+ // reallocs if needed, and writes the new layout. Append-only fields +
39
+ // realloc with zero-init (see Rule 040) preserve old data.
40
+ ```
41
+
42
+ ## Detection heuristic
43
+ - `#[account]` structs without a `version`/`schema` field in an upgradeable program
44
+ - Field insertions/removals/reorderings in account structs between versions (check git history)
45
+ - Seed-scheme changes for already-initialized PDAs with no migration instruction
46
+ - `realloc` on existing accounts without preserving prior field offsets
47
+
48
+ ## References
49
+ - Anchor docs — program upgrades and account layout (https://www.anchor-lang.com/docs)
50
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
51
+ - Sec3 — Solana program upgrade considerations (https://www.sec3.dev/blog)
52
+
53
+ ## Real-world exploits (if any)
54
+ No single attributed public exploit; layout-migration bugs surface in audits of long-lived upgradeable protocols and can corrupt accounting silently.
@@ -0,0 +1,49 @@
1
+ # Rule 016: Stored Bump Mismatch
2
+
3
+ **Severity:** Medium
4
+ **Category:** PDA
5
+
6
+ ## Description
7
+ A program stores a PDA's bump at initialization and later re-derives the PDA, but the verification path doesn't actually pin the derivation to the stored bump — or stores the bump but then re-derives with `find_program_address` (recomputing the canonical bump) inconsistently across instructions. Inconsistent bump handling lets a non-canonical-bump PDA pass in one instruction and fail in another, or lets the wrong account satisfy a check.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ // init stores the canonical bump
12
+ ctx.accounts.state.bump = ctx.bumps.state;
13
+
14
+ // ...but a later instruction re-derives canonically and signs with that,
15
+ // ignoring the stored bump entirely:
16
+ let (pda, bump) = Pubkey::find_program_address(&[b"state", user.key().as_ref()], ctx.program_id);
17
+ let seeds = &[b"state", user.key().as_ref(), &[bump]];
18
+ // If the account was created with a different (non-canonical) bump,
19
+ // `pda` won't match the real account, or signing fails silently.
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ Mismatched bump sources cause CPI signing to use the wrong signer seeds (transactions fail, or worse, a different valid PDA is authorized), and address checks that should reject an account can pass when a non-canonical bump was stored. The inconsistency is the bug: every code path must use the same stored canonical bump.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ // Store canonical bump once at init:
28
+ ctx.accounts.state.bump = ctx.bumps.state;
29
+
30
+ // Always reuse the stored bump for both constraints and signing:
31
+ #[account(seeds = [b"state", user.key().as_ref()], bump = state.bump)]
32
+ pub state: Account<'info, State>,
33
+
34
+ let seeds = &[b"state", user.key().as_ref(), &[ctx.accounts.state.bump]];
35
+ let signer = &[&seeds[..]];
36
+ ```
37
+
38
+ ## Detection heuristic
39
+ - A `bump` field stored at init but some instructions use `find_program_address` / `ctx.bumps` instead of the stored value
40
+ - `bump = state.bump` in some constraints and bare `bump` in others for the same PDA
41
+ - `invoke_signed` seeds whose bump source differs from the constraint's bump source
42
+
43
+ ## References
44
+ - Anchor docs — bumps and seeds (https://www.anchor-lang.com/docs/account-constraints)
45
+ - Coral sealevel-attacks — 7-bump-seed-canonicalization (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/7-bump-seed-canonicalization)
46
+ - Solana program security course — bump seed canonicalization (https://solana.com/developers/courses/program-security/bump-seed-canonicalization)
47
+
48
+ ## Real-world exploits (if any)
49
+ No public attributed exploit; reported in audits as a consistency/correctness issue that can brick CPI-signing instructions.
@@ -0,0 +1,56 @@
1
+ # Rule 017: Arbitrary CPI (Unvalidated Target Program)
2
+
3
+ **Severity:** Critical
4
+ **Category:** CPI
5
+
6
+ ## Description
7
+ A program performs a cross-program invocation to a program whose ID comes from a passed-in account, without verifying it against the expected program ID. The attacker supplies their own malicious program in place of the intended one (e.g. a fake "token program"), and the CPI executes attacker code with whatever accounts and signer seeds the calling program provides.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Transfer<'info> {
13
+ pub token_program: AccountInfo<'info>, // unvalidated
14
+ #[account(mut)]
15
+ pub from: Account<'info, TokenAccount>,
16
+ #[account(mut)]
17
+ pub to: Account<'info, TokenAccount>,
18
+ pub authority: Signer<'info>,
19
+ }
20
+
21
+ pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
22
+ let ix = /* build a transfer ix targeting ctx.accounts.token_program.key() */;
23
+ invoke(&ix, &[/* accounts */])?; // calls whatever program was passed
24
+ Ok(())
25
+ }
26
+ ```
27
+
28
+ ## Why this is dangerous
29
+ The attacker passes a program they control as `token_program`. Instead of transferring tokens, the fake program does nothing (so the caller believes a transfer happened) or manipulates the accounts it was handed. When the calling program signs the CPI with a PDA, the attacker's program inherits that PDA's authority for the duration of the call.
30
+
31
+ ## Fix pattern
32
+ ```rust
33
+ #[derive(Accounts)]
34
+ pub struct Transfer<'info> {
35
+ pub token_program: Program<'info, Token>, // Anchor verifies the ID
36
+ #[account(mut)]
37
+ pub from: Account<'info, TokenAccount>,
38
+ #[account(mut)]
39
+ pub to: Account<'info, TokenAccount>,
40
+ pub authority: Signer<'info>,
41
+ }
42
+ // Or, for raw AccountInfo: require_keys_eq!(token_program.key(), expected::ID);
43
+ ```
44
+
45
+ ## Detection heuristic
46
+ - CPI target programs typed as `AccountInfo`/`UncheckedAccount` rather than `Program<'info, T>`
47
+ - `invoke` / `invoke_signed` where the target program ID is read from accounts without a preceding `require_keys_eq!` against a known ID
48
+ - Program IDs taken from instruction data or config accounts that are themselves attacker-controllable
49
+
50
+ ## References
51
+ - Coral sealevel-attacks — 5-arbitrary-cpi (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/5-arbitrary-cpi)
52
+ - Solana program security course — arbitrary CPI (https://solana.com/developers/courses/program-security/arbitrary-cpi)
53
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
54
+
55
+ ## Real-world exploits (if any)
56
+ No single attributed public headline exploit; arbitrary-CPI is one of the most common critical findings across public Solana audit reports.