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 031: Reinitialization Attack
2
+
3
+ **Severity:** High
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ An initialization instruction can be invoked more than once against the same account, resetting its state. If the account is already in use — holding a balance, an authority, or accumulated state — a second `init`-style call lets an attacker overwrite critical fields (e.g. reset the authority to themselves) or wipe accounting. Manual init flows that don't guard against re-entry, and `init_if_needed` used carelessly, are the usual culprits.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn initialize(ctx: Context<Initialize>, authority: Pubkey) -> Result<()> {
12
+ let state = &mut ctx.accounts.state;
13
+ // No check whether `state` was already initialized — callable repeatedly
14
+ state.authority = authority;
15
+ state.balance = 0; // wipes any existing balance on re-call
16
+ Ok(())
17
+ }
18
+ ```
19
+
20
+ ## Why this is dangerous
21
+ After legitimate setup, the attacker calls `initialize` again, setting `authority` to their own key (taking over the account) or zeroing accounting fields to erase debts/balances. Because the account already exists and is owned by the program, only an explicit "already initialized" guard prevents the overwrite.
22
+
23
+ ## Fix pattern
24
+ ```rust
25
+ #[derive(Accounts)]
26
+ pub struct Initialize<'info> {
27
+ // `init` fails if the account already exists / has a discriminator
28
+ #[account(init, payer = payer, space = 8 + State::INIT_SPACE)]
29
+ pub state: Account<'info, State>,
30
+ #[account(mut)]
31
+ pub payer: Signer<'info>,
32
+ pub system_program: Program<'info, System>,
33
+ }
34
+ // For manual flows: require!(!state.is_initialized, ErrorCode::AlreadyInit);
35
+ // state.is_initialized = true;
36
+ ```
37
+
38
+ ## Detection heuristic
39
+ - "initialize"/"setup"/"create" handlers that write state without `init` or an `is_initialized` guard
40
+ - `init_if_needed` on accounts whose re-initialization would reset sensitive fields (see Rule 033)
41
+ - Authority/owner fields assignable by an instruction reachable more than once
42
+ - Manual account creation followed by configuration that can be replayed
43
+
44
+ ## References
45
+ - Coral sealevel-attacks — 4-initialization (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/4-initialization)
46
+ - Solana program security course — reinitialization attacks (https://solana.com/developers/courses/program-security/reinitialization-attacks)
47
+ - Anchor docs — init / init_if_needed (https://www.anchor-lang.com/docs/account-constraints)
48
+
49
+ ## Real-world exploits (if any)
50
+ No single attributed public headline exploit; reinitialization is a standard high/critical finding in public audits, especially for config and authority accounts.
@@ -0,0 +1,49 @@
1
+ # Rule 032: Closed Account Revival
2
+
3
+ **Severity:** High
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ The counterpart to Rule 010. An account "closed" by only draining its lamports keeps its data for the rest of the transaction and is garbage-collected only after the transaction ends. An attacker re-funds the account (sending it rent-exempt lamports in a later instruction of the same transaction, or before GC) so it survives, then reuses the stale-but-valid account in subsequent instructions that assume it was destroyed.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn close(ctx: Context<Close>) -> Result<()> {
12
+ let acc = ctx.accounts.position.to_account_info();
13
+ let dest = ctx.accounts.owner.to_account_info();
14
+ **dest.try_borrow_mut_lamports()? += acc.lamports();
15
+ **acc.try_borrow_mut_lamports()? = 0; // data NOT zeroed, no closed marker
16
+ Ok(())
17
+ }
18
+ // Attacker, in the same tx: transfer rent-exempt lamports back to `position`,
19
+ // then call an instruction that still treats `position` as a live, funded position.
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ The revived account retains its old discriminator and field values, so type and owner checks still pass. The attacker double-claims rewards tied to a "closed" position, keeps using collateral that was supposed to be released, or replays one-time actions. The lamport drain alone is not a close.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ #[derive(Accounts)]
28
+ pub struct Close<'info> {
29
+ #[account(mut, has_one = owner, close = owner)] // zeroes data + closed marker
30
+ pub position: Account<'info, Position>,
31
+ #[account(mut)]
32
+ pub owner: Signer<'info>,
33
+ }
34
+ ```
35
+ Anchor's `close` writes `CLOSED_ACCOUNT_DISCRIMINATOR`, so a revived account fails the discriminator check on next use.
36
+
37
+ ## Detection heuristic
38
+ - Manual lamport-draining closes (see Rule 010) without zeroing data and writing a closed discriminator
39
+ - Accounts that are "closed" in one instruction and read in another within plausible transaction flows
40
+ - Reward/claim/one-time-action accounts closed without Anchor's `close` constraint
41
+ - No re-check of discriminator/initialized flag on accounts that may have been closed
42
+
43
+ ## References
44
+ - Coral sealevel-attacks — 9-closing-accounts (https://github.com/coral-xyz/sealevel-attacks/tree/master/programs/9-closing-accounts)
45
+ - Solana program security course — closing accounts & revival (https://solana.com/developers/courses/program-security/closing-accounts)
46
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
47
+
48
+ ## Real-world exploits (if any)
49
+ No single attributed public headline exploit; account-revival is a well-documented critical pattern in the sealevel-attacks corpus and recurs in audits.
@@ -0,0 +1,66 @@
1
+ # Rule 033: `init_if_needed` Misuse
2
+
3
+ **Severity:** High
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ Anchor's `init_if_needed` initializes an account if it doesn't exist and otherwise loads it. It is convenient but dangerous: when the account already exists, the initialization body is skipped, so any field-setting logic placed in the handler runs against existing state — or, conversely, an attacker can pre-create the account so "needed" init never happens. Used without a follow-up guard, it enables reinitialization-style takeovers and state resets.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Deposit<'info> {
13
+ #[account(
14
+ init_if_needed,
15
+ payer = user,
16
+ space = 8 + Position::INIT_SPACE,
17
+ seeds = [b"position", user.key().as_ref()],
18
+ bump,
19
+ )]
20
+ pub position: Account<'info, Position>,
21
+ #[account(mut)]
22
+ pub user: Signer<'info>,
23
+ pub system_program: Program<'info, System>,
24
+ }
25
+
26
+ pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
27
+ // Runs every call. On an existing account this is fine; but if the
28
+ // handler (re)sets owner/authority here, it overwrites it each call.
29
+ ctx.accounts.position.owner = ctx.accounts.user.key();
30
+ ctx.accounts.position.amount += amount;
31
+ Ok(())
32
+ }
33
+ ```
34
+
35
+ ## Why this is dangerous
36
+ If sensitive fields are (re)assigned in the handler, a second caller's data can clobber the first's (when seeds aren't user-bound), or accumulated state is reset. When `init_if_needed` is enabled program-wide, every account it touches must be analyzed for reinitialization (Rule 031). Attackers also pre-create accounts to control which branch runs.
37
+
38
+ ## Fix pattern
39
+ ```rust
40
+ pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
41
+ let position = &mut ctx.accounts.position;
42
+ // Only set identity once; never overwrite on subsequent calls.
43
+ if position.owner == Pubkey::default() {
44
+ position.owner = ctx.accounts.user.key();
45
+ } else {
46
+ require_keys_eq!(position.owner, ctx.accounts.user.key());
47
+ }
48
+ position.amount = position.amount.checked_add(amount).ok_or(ErrorCode::Overflow)?;
49
+ Ok(())
50
+ }
51
+ ```
52
+ Prefer a separate explicit `init` instruction where feasible; bind PDA seeds to the user.
53
+
54
+ ## Detection heuristic
55
+ - `init_if_needed` anywhere — each use needs reinitialization analysis
56
+ - Handlers that unconditionally assign identity/authority fields on an `init_if_needed` account
57
+ - `init_if_needed` PDAs whose seeds are not bound to the calling principal
58
+ - The `init-if-needed` Anchor feature enabled without per-account guards
59
+
60
+ ## References
61
+ - Anchor docs — init_if_needed (https://www.anchor-lang.com/docs/account-constraints)
62
+ - Solana program security course — reinitialization attacks (https://solana.com/developers/courses/program-security/reinitialization-attacks)
63
+ - Sec3 — init_if_needed risks (https://www.sec3.dev/blog)
64
+
65
+ ## Real-world exploits (if any)
66
+ No single attributed public exploit; `init_if_needed` misuse is a recurring high-severity audit finding and the reason the feature is gated behind a Cargo flag.
@@ -0,0 +1,55 @@
1
+ # Rule 034: Missing `has_one` Relationship Enforcement
2
+
3
+ **Severity:** High
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ A state account stores pubkeys describing its relationships (`owner`, `mint`, `vault`, `authority`), but the instruction doesn't enforce that the corresponding passed-in accounts actually match those stored fields. `has_one = x` makes Anchor assert `account.x == x.key()`. Omitting it (and not replacing it with an explicit check) lets an attacker pass a mismatched but otherwise-valid account.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Harvest<'info> {
13
+ #[account(mut)] // Farm stores `authority` and `reward_vault` — neither enforced
14
+ pub farm: Account<'info, Farm>,
15
+ pub authority: Signer<'info>,
16
+ #[account(mut)]
17
+ pub reward_vault: Account<'info, TokenAccount>,
18
+ }
19
+
20
+ pub fn harvest(ctx: Context<Harvest>) -> Result<()> {
21
+ // Uses farm.reward_vault implicitly, but reward_vault could be any account
22
+ // and authority could be anyone — no link to farm.authority is checked.
23
+ Ok(())
24
+ }
25
+ ```
26
+
27
+ ## Why this is dangerous
28
+ Without `has_one`, the signer need not be the farm's authority, and the reward vault need not be the farm's real vault. The attacker harvests someone else's farm, or redirects rewards to a vault they control, because the program trusts the passed accounts instead of the relationships recorded in state.
29
+
30
+ ## Fix pattern
31
+ ```rust
32
+ #[derive(Accounts)]
33
+ pub struct Harvest<'info> {
34
+ #[account(mut, has_one = authority, has_one = reward_vault)]
35
+ pub farm: Account<'info, Farm>,
36
+ pub authority: Signer<'info>,
37
+ #[account(mut)]
38
+ pub reward_vault: Account<'info, TokenAccount>,
39
+ }
40
+ ```
41
+ Each `has_one = field` requires a matching context account named `field` and asserts equality.
42
+
43
+ ## Detection heuristic
44
+ - State structs with relationship pubkey fields (`authority`, `owner`, `mint`, `vault`, `pool`) whose instructions lack matching `has_one`
45
+ - Accounts used in logic by reference to a stored pubkey but passed in unconstrained
46
+ - Authority signers not tied to the state account via `has_one`/`address`/explicit check
47
+ - `require_keys_eq!` checks that are present in some handlers but missing in siblings
48
+
49
+ ## References
50
+ - Anchor docs — has_one constraint (https://www.anchor-lang.com/docs/account-constraints)
51
+ - Solana program security course — account data matching (https://solana.com/developers/courses/program-security/account-data-matching)
52
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
53
+
54
+ ## Real-world exploits (if any)
55
+ No single attributed public headline exploit; missing relationship checks are among the most common high/critical audit findings (closely related to Cashio's unvalidated account chain).
@@ -0,0 +1,49 @@
1
+ # Rule 035: Insecure Admin Transfer (No Acceptance Handshake)
2
+
3
+ **Severity:** Medium
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ Transferring a privileged role (admin, owner, upgrade authority) in a single instruction that immediately sets the new authority is fragile: a typo or a wrong/uncontrolled address permanently locks out administration with no recovery. The safe pattern is a two-step handshake — the current admin *nominates* a pending admin, and the nominee must *accept* — so an unusable address can never take ownership.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn set_admin(ctx: Context<SetAdmin>, new_admin: Pubkey) -> Result<()> {
12
+ // One-step: if new_admin is wrong or unowned, admin control is lost forever
13
+ ctx.accounts.config.admin = new_admin;
14
+ Ok(())
15
+ }
16
+ ```
17
+
18
+ ## Why this is dangerous
19
+ A single-step transfer to a mistyped address, an exchange deposit address, or a contract that can't sign irreversibly bricks every admin-gated function (pause, upgrade, fee changes, emergency withdrawal). There is no way to prove the new admin can actually sign before handing over control. This is a self-inflicted-loss and incident-response risk, not a direct theft vector.
20
+
21
+ ## Fix pattern
22
+ ```rust
23
+ pub fn nominate_admin(ctx: Context<AdminOnly>, candidate: Pubkey) -> Result<()> {
24
+ ctx.accounts.config.pending_admin = candidate;
25
+ Ok(())
26
+ }
27
+
28
+ pub fn accept_admin(ctx: Context<AcceptAdmin>) -> Result<()> {
29
+ require_keys_eq!(ctx.accounts.config.pending_admin, ctx.accounts.candidate.key());
30
+ ctx.accounts.config.admin = ctx.accounts.candidate.key(); // candidate must sign
31
+ ctx.accounts.config.pending_admin = Pubkey::default();
32
+ Ok(())
33
+ }
34
+ ```
35
+ The `accept_admin` context requires `candidate: Signer`, proving control.
36
+
37
+ ## Detection heuristic
38
+ - Admin/owner/authority fields reassigned in one instruction from an argument, with no `pending_*` field
39
+ - Absence of a paired nominate/accept (or propose/claim) instruction set
40
+ - Upgrade-authority or critical-role transfers without a signature from the incoming party
41
+ - No `Pubkey::default()` / sanity guard on the new authority value
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 — privileged role management (https://www.sec3.dev/blog)
46
+ - OpenZeppelin — Ownable2Step (concept reference) (https://docs.openzeppelin.com/contracts/4.x/api/access#Ownable2Step)
47
+
48
+ ## Real-world exploits (if any)
49
+ No theft exploit; single-step authority transfers have caused permanent loss of admin control (bricked protocols) across ecosystems. Standard medium audit finding.
@@ -0,0 +1,50 @@
1
+ # Rule 036: Missing Pause / Freeze Guards
2
+
3
+ **Severity:** Low
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ Critical value-moving instructions (deposit, withdraw, swap, borrow) have no mechanism to be paused or frozen in an emergency. When a vulnerability or anomaly is detected in production, the team has no way to halt the affected paths short of an upgrade, which takes time and may not be possible if the program is immutable. A pause flag, gated to an admin/guardian, is a standard defense-in-depth control.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
12
+ // No pause check anywhere in the program; if an exploit is live,
13
+ // there is no way to stop withdrawals while a fix is prepared.
14
+ transfer_out(ctx, amount)
15
+ }
16
+ ```
17
+
18
+ ## Why this is dangerous
19
+ Lacking a circuit breaker turns a contained incident into a full drain: once exploitation begins, the team can only watch until an upgrade lands (and immutable programs can't even do that). A pause/freeze guard buys time to investigate, patch, and protect remaining funds. Its absence is a missing safety control rather than an exploitable bug by itself.
20
+
21
+ ## Fix pattern
22
+ ```rust
23
+ #[account]
24
+ pub struct Config { pub admin: Pubkey, pub paused: bool /* ... */ }
25
+
26
+ pub fn set_paused(ctx: Context<AdminOnly>, paused: bool) -> Result<()> {
27
+ ctx.accounts.config.paused = paused; // admin/guardian gated
28
+ Ok(())
29
+ }
30
+
31
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
32
+ require!(!ctx.accounts.config.paused, ErrorCode::Paused);
33
+ transfer_out(ctx, amount)
34
+ }
35
+ ```
36
+ Consider granular pausing (per-instruction) and a separate, fast-acting guardian role.
37
+
38
+ ## Detection heuristic
39
+ - No `paused`/`frozen` field on the program's config/global state
40
+ - Value-moving instructions with no `require!(!config.paused, ...)` guard
41
+ - No admin/guardian instruction to toggle a pause
42
+ - Immutable programs (upgrade authority burned) with no in-program emergency stop
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 — emergency controls and circuit breakers (https://www.sec3.dev/blog)
47
+ - Solana program security best practices (https://solana.com/developers/courses/program-security)
48
+
49
+ ## Real-world exploits (if any)
50
+ No exploit caused by this directly; in numerous incidents the absence of a pause turned a detectable exploit into a total loss. Common informational/low audit recommendation.
@@ -0,0 +1,53 @@
1
+ # Rule 037: Clock / Time-Based Logic Without Bounds
2
+
3
+ **Severity:** Medium
4
+ **Category:** Auth
5
+
6
+ ## Description
7
+ Logic that depends on the Clock sysvar (`unix_timestamp` or `slot`) for vesting, auctions, cooldowns, TWAPs, or expiry must account for the fact that timestamps are validator-influenced and can drift, and that slot-to-time conversions are approximate. Using raw timestamps without sanity bounds, staleness checks, or monotonicity guards lets edge cases and minor manipulation skew time-sensitive outcomes.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ pub fn claim_vested(ctx: Context<Claim>) -> Result<()> {
12
+ let now = Clock::get()?.unix_timestamp;
13
+ // Assumes `now` is exact and strictly increasing; no bounds, no
14
+ // check that start <= now, no guard against a recorded future time.
15
+ let elapsed = now - ctx.accounts.vest.start_ts;
16
+ let vested = ctx.accounts.vest.total * elapsed as u64 / ctx.accounts.vest.duration;
17
+ // ...
18
+ Ok(())
19
+ }
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ `unix_timestamp` can be slightly ahead of or behind real time and is not guaranteed strictly monotonic across the boundary cases the program may assume. If `now < start_ts`, `elapsed` underflows (Rule 023); unbounded `elapsed` can over-vest. For oracle/auction logic, even small timestamp influence lets a validator-adjacent actor nudge outcomes. Relying on time for high-value, fine-grained decisions is fragile.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ pub fn claim_vested(ctx: Context<Claim>) -> Result<()> {
28
+ let now = Clock::get()?.unix_timestamp;
29
+ let v = &ctx.accounts.vest;
30
+ require!(now >= v.start_ts, ErrorCode::NotStarted);
31
+ let elapsed = (now - v.start_ts).min(v.duration as i64) as u64; // clamp
32
+ let vested = (v.total as u128)
33
+ .checked_mul(elapsed as u128).ok_or(ErrorCode::Overflow)?
34
+ .checked_div(v.duration as u128).ok_or(ErrorCode::DivByZero)? as u64;
35
+ // ...
36
+ Ok(())
37
+ }
38
+ ```
39
+ Clamp ranges, reject out-of-order timestamps, and avoid time as the sole gate for high-value actions.
40
+
41
+ ## Detection heuristic
42
+ - `Clock::get()?.unix_timestamp` / `slot` used in subtraction without a `now >= start` guard (underflow risk)
43
+ - Time deltas not clamped to a maximum (over-vesting / over-accrual)
44
+ - Slot-count used as wall-clock time via a hardcoded slot duration
45
+ - Auction/oracle/expiry decisions gated solely on validator-influenced time
46
+
47
+ ## References
48
+ - Solana docs — Clock sysvar and timestamp semantics (https://docs.solanalabs.com/runtime/sysvars#clock)
49
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
50
+ - Sec3 — time-based logic pitfalls (https://www.sec3.dev/blog)
51
+
52
+ ## Real-world exploits (if any)
53
+ No single attributed public Solana exploit; time-handling issues are recurring medium audit findings in vesting, auction, and oracle code.
@@ -0,0 +1,53 @@
1
+ # Rule 038: Missing `address` Validation on Fixed-Identity Accounts
2
+
3
+ **Severity:** Medium
4
+ **Category:** Constraints
5
+
6
+ ## Description
7
+ Some accounts have a single correct value known at compile time or stored in config: a specific fee recipient, a known oracle, the protocol treasury, a particular program. When such an account is accepted without an `address = ...` constraint (or equivalent check), an attacker substitutes their own account, redirecting fees, feeding a fake oracle, or pointing the program at the wrong fixed dependency.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Swap<'info> {
13
+ // Fee should always go to the protocol treasury, but any account passes
14
+ #[account(mut)]
15
+ pub fee_recipient: Account<'info, TokenAccount>,
16
+ // Oracle should be a specific known account, but unvalidated
17
+ /// CHECK: price oracle
18
+ pub oracle: AccountInfo<'info>,
19
+ // ...
20
+ }
21
+ ```
22
+
23
+ ## Why this is dangerous
24
+ The attacker passes their own token account as `fee_recipient` and collects the protocol's fees, or supplies a fake `oracle` account with attacker-chosen prices that the program reads as authoritative (mispricing swaps/liquidations). Any account whose identity is supposed to be fixed but isn't pinned is an injection point.
25
+
26
+ ## Fix pattern
27
+ ```rust
28
+ #[derive(Accounts)]
29
+ pub struct Swap<'info> {
30
+ #[account(mut, address = config.treasury)]
31
+ pub fee_recipient: Account<'info, TokenAccount>,
32
+ /// CHECK: pinned to the configured oracle
33
+ #[account(address = config.oracle)]
34
+ pub oracle: AccountInfo<'info>,
35
+ pub config: Account<'info, Config>,
36
+ // ...
37
+ }
38
+ ```
39
+ For compile-time constants use `address = some_known_pubkey::ID`.
40
+
41
+ ## Detection heuristic
42
+ - Accounts representing fixed dependencies (treasury, fee recipient, oracle, known program/account) without an `address =` constraint
43
+ - Pubkeys stored in config but the corresponding account passed unconstrained
44
+ - `/// CHECK:` accounts that are dereferenced for trusted data with no address pin
45
+ - Fee/royalty destinations taken from instruction input rather than config
46
+
47
+ ## References
48
+ - Anchor docs — address constraint (https://www.anchor-lang.com/docs/account-constraints)
49
+ - Helius — A Hitchhiker's Guide to Solana Program Security (https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security)
50
+ - Neodyme — Solana common pitfalls (https://neodyme.io/en/blog/solana_common_pitfalls/)
51
+
52
+ ## Real-world exploits (if any)
53
+ No single attributed public exploit; unvalidated oracle/fee accounts are recurring medium/high audit findings, and fake-oracle injection underlies several DeFi price-manipulation incidents.
@@ -0,0 +1,54 @@
1
+ # Rule 039: Constraint Evaluation Stage (Pre- vs Post-State)
2
+
3
+ **Severity:** Medium
4
+ **Category:** Constraints
5
+
6
+ ## Description
7
+ Anchor evaluates account constraints during account validation, before the instruction handler body runs. A `constraint = ...` expression therefore sees the *pre-handler* state of the accounts. Developers sometimes write constraints expecting them to hold after the handler mutates state, or place a critical invariant only in a constraint that is checked too early, leaving a window where the post-state violates the intended invariant.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Withdraw<'info> {
13
+ // Evaluated BEFORE the handler runs, so it validates the OLD balance,
14
+ // not the balance after the withdrawal. It does not guarantee the
15
+ // post-withdraw invariant the author intended.
16
+ #[account(mut, constraint = vault.balance >= MIN_RESERVE @ ErrorCode::ReserveBreached)]
17
+ pub vault: Account<'info, Vault>,
18
+ // ...
19
+ }
20
+
21
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
22
+ ctx.accounts.vault.balance -= amount; // post-state never re-checked
23
+ Ok(())
24
+ }
25
+ ```
26
+
27
+ ## Why this is dangerous
28
+ The reserve check passes against the pre-withdrawal balance, then the handler withdraws an amount that breaches the reserve — the invariant the constraint was meant to protect is violated after the fact. Relying on a pre-state constraint to guard a post-state property is a logic gap an attacker exercises by choosing `amount` to slip through the early check.
29
+
30
+ ## Fix pattern
31
+ ```rust
32
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
33
+ let vault = &mut ctx.accounts.vault;
34
+ vault.balance = vault.balance.checked_sub(amount).ok_or(ErrorCode::Underflow)?;
35
+ // Re-check the invariant against POST-state, in the handler:
36
+ require!(vault.balance >= MIN_RESERVE, ErrorCode::ReserveBreached);
37
+ Ok(())
38
+ }
39
+ ```
40
+ Use constraints for pre-conditions; assert post-conditions explicitly in the handler.
41
+
42
+ ## Detection heuristic
43
+ - `constraint = ...` expressions that reference balances/state the same handler mutates, intended as post-conditions
44
+ - Invariants present only as account constraints but not re-asserted after mutation
45
+ - Handlers that change a value a constraint depends on, with no post-mutation `require!`
46
+ - Comments implying "after" semantics on a constraint (which always runs "before")
47
+
48
+ ## References
49
+ - Anchor docs — constraint evaluation order (https://www.anchor-lang.com/docs/account-constraints)
50
+ - The Anchor Book — constraints (https://book.anchor-lang.com/anchor_in_depth/the_accounts_struct.html)
51
+ - Sec3 — Anchor constraint pitfalls (https://www.sec3.dev/blog)
52
+
53
+ ## Real-world exploits (if any)
54
+ No single attributed public exploit; pre/post-state confusion is a subtle logic finding that appears in audits of programs with reserve/ratio invariants.
@@ -0,0 +1,53 @@
1
+ # Rule 040: `realloc` Without Zero-Init
2
+
3
+ **Severity:** Medium
4
+ **Category:** Constraints
5
+
6
+ ## Description
7
+ When an account is grown with `realloc`, the newly added bytes are not automatically zeroed unless zero-init is requested. If `realloc(..., zero_init = false)` is used to *increase* size, the new region may contain leftover data from a previous, larger allocation of that memory, which then deserializes into account fields as garbage or attacker-influenced values.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct Grow<'info> {
13
+ #[account(
14
+ mut,
15
+ realloc = 8 + NewLayout::INIT_SPACE, // larger than before
16
+ realloc::payer = payer,
17
+ realloc::zero = false, // new bytes NOT zeroed
18
+ )]
19
+ pub state: Account<'info, NewLayout>,
20
+ #[account(mut)]
21
+ pub payer: Signer<'info>,
22
+ pub system_program: Program<'info, System>,
23
+ }
24
+ ```
25
+
26
+ ## Why this is dangerous
27
+ The new tail bytes can hold stale contents (from prior data at that memory, or non-deterministic leftovers), so newly-added fields deserialize to nonzero, unexpected values instead of clean defaults. Logic that assumes appended fields start at zero (counters, flags, balances) is then wrong from the first read, which an attacker may be able to steer.
28
+
29
+ ## Fix pattern
30
+ ```rust
31
+ #[account(
32
+ mut,
33
+ realloc = 8 + NewLayout::INIT_SPACE,
34
+ realloc::payer = payer,
35
+ realloc::zero = true, // zero the newly-added bytes when growing
36
+ )]
37
+ pub state: Account<'info, NewLayout>,
38
+ ```
39
+ Use `realloc::zero = true` whenever increasing size; `false` is only safe when shrinking or immediately overwriting the entire new region.
40
+
41
+ ## Detection heuristic
42
+ - `realloc::zero = false` (or the raw `AccountInfo::realloc(new_len, false)`) on size *increases*
43
+ - New fields appended via realloc that are read before being explicitly written
44
+ - Manual `realloc` calls that don't memset the grown region to zero
45
+ - Growth paths assuming default (zero) values for newly-added fields
46
+
47
+ ## References
48
+ - Anchor docs — realloc constraint (https://www.anchor-lang.com/docs/account-constraints)
49
+ - Solana docs — AccountInfo::realloc semantics (https://docs.rs/solana-program/latest/solana_program/account_info/struct.AccountInfo.html#method.realloc)
50
+ - Sec3 — realloc safety (https://www.sec3.dev/blog)
51
+
52
+ ## Real-world exploits (if any)
53
+ No single attributed public exploit; non-zeroed realloc is a documented medium audit finding for programs that grow accounts over time.
@@ -0,0 +1,59 @@
1
+ # Rule 041: `init` Without `payer` (or Wrong Payer)
2
+
3
+ **Severity:** Low
4
+ **Category:** Constraints
5
+
6
+ ## Description
7
+ The `init` constraint requires a `payer` to fund the new account's rent-exemption. Beyond the compile/runtime requirement, the *choice* of payer matters: using a program-controlled or shared account as payer, or letting a payer fund accounts without their explicit signature, can drain the wrong party. The related defect is initializing accounts at someone else's expense or griefing a shared funding source.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[derive(Accounts)]
12
+ pub struct CreateEntry<'info> {
13
+ #[account(
14
+ init,
15
+ space = 8 + Entry::INIT_SPACE,
16
+ payer = treasury, // shared protocol account pays for anyone's account
17
+ )]
18
+ pub entry: Account<'info, Entry>,
19
+ #[account(mut)]
20
+ pub treasury: Account<'info, TokenAccount>, // not the caller
21
+ pub user: Signer<'info>,
22
+ pub system_program: Program<'info, System>,
23
+ }
24
+ ```
25
+
26
+ ## Why this is dangerous
27
+ If a shared/treasury account funds arbitrary user-created accounts, an attacker creates many accounts to drain the treasury's lamports (griefing / denial of funds). Conversely, a missing/incorrect payer makes legitimate initialization fail. The payer should normally be the caller who benefits, and must sign.
28
+
29
+ ## Fix pattern
30
+ ```rust
31
+ #[derive(Accounts)]
32
+ pub struct CreateEntry<'info> {
33
+ #[account(
34
+ init,
35
+ space = 8 + Entry::INIT_SPACE,
36
+ payer = user, // the caller funds their own account
37
+ seeds = [b"entry", user.key().as_ref()],
38
+ bump,
39
+ )]
40
+ pub entry: Account<'info, Entry>,
41
+ #[account(mut)]
42
+ pub user: Signer<'info>,
43
+ pub system_program: Program<'info, System>,
44
+ }
45
+ ```
46
+
47
+ ## Detection heuristic
48
+ - `init` with `payer = <shared/treasury/PDA>` rather than the calling principal
49
+ - `init` where the payer is not a `Signer` (mut) in the same context
50
+ - Permissionless instructions that initialize accounts funded by a protocol-owned account
51
+ - Account creation with no per-caller rate limiting funded from a common source
52
+
53
+ ## References
54
+ - Anchor docs — init and payer (https://www.anchor-lang.com/docs/account-constraints)
55
+ - The Anchor Book — init constraint (https://book.anchor-lang.com/anchor_in_depth/the_accounts_struct.html)
56
+ - Solana docs — rent and account creation (https://solana.com/docs/core/fees#rent)
57
+
58
+ ## Real-world exploits (if any)
59
+ No single attributed public exploit; treasury-funded-init griefing and payer misconfiguration are low/medium audit and robustness findings.
@@ -0,0 +1,53 @@
1
+ # Rule 042: Incorrect `space` Allocation
2
+
3
+ **Severity:** Medium
4
+ **Category:** Constraints
5
+
6
+ ## Description
7
+ The `space` value in an `init` constraint must exactly accommodate the 8-byte discriminator plus the serialized size of every field, including the worst-case length of variable-length fields (`String`, `Vec<T>`). Too small and writes either fail or, for fixed layouts computed by hand, corrupt adjacent data; mis-estimating variable-length capacity strands the account at a size that can't hold its intended contents.
8
+
9
+ ## Vulnerable pattern
10
+ ```rust
11
+ #[account]
12
+ pub struct Profile {
13
+ pub authority: Pubkey, // 32
14
+ pub name: String, // 4 (len prefix) + N bytes
15
+ pub friends: Vec<Pubkey>, // 4 + 32 * count
16
+ }
17
+
18
+ #[account(init, payer = user, space = 8 + 32)] // forgot name + friends
19
+ pub profile: Account<'info, Profile>,
20
+ ```
21
+
22
+ ## Why this is dangerous
23
+ Under-allocating means later writes that grow the `String`/`Vec` fail (bricking updates) or require a separate realloc the program never performs. Hand-computed sizes that are wrong can also misalign serialization. Over-allocating wastes rent but is safe; under-allocating is the security/robustness problem, sometimes blocking critical paths like updating a record needed for withdrawal.
24
+
25
+ ## Fix pattern
26
+ ```rust
27
+ #[account]
28
+ #[derive(InitSpace)] // Anchor computes fixed-size contribution automatically
29
+ pub struct Profile {
30
+ pub authority: Pubkey,
31
+ #[max_len(32)] // cap variable-length fields explicitly
32
+ pub name: String,
33
+ #[max_len(50)]
34
+ pub friends: Vec<Pubkey>,
35
+ }
36
+
37
+ #[account(init, payer = user, space = 8 + Profile::INIT_SPACE)]
38
+ pub profile: Account<'info, Profile>,
39
+ ```
40
+
41
+ ## Detection heuristic
42
+ - Hand-written `space = 8 + <number>` literals not derived from `INIT_SPACE` / a documented size calc
43
+ - Variable-length fields (`String`, `Vec`) with no `#[max_len]` and no accounting in `space`
44
+ - `space` smaller than the sum of field sizes (32 per Pubkey, 8 per u64, 1 per bool/u8, 4 + content for collections)
45
+ - Structs that gained fields without a corresponding `space` update
46
+
47
+ ## References
48
+ - Anchor docs — space and InitSpace (https://www.anchor-lang.com/docs/space)
49
+ - The Anchor Book — account size (https://book.anchor-lang.com/anchor_in_depth/the_accounts_struct.html)
50
+ - Sec3 — account sizing pitfalls (https://www.sec3.dev/blog)
51
+
52
+ ## Real-world exploits (if any)
53
+ No single attributed public exploit; incorrect space is a common correctness/availability finding, occasionally bricking update or close paths.