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.
- package/README.md +28 -0
- package/dist/auditor.d.ts +16 -0
- package/dist/auditor.js +235 -0
- package/dist/auditor.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata.d.ts +25 -0
- package/dist/metadata.js +48 -0
- package/dist/metadata.js.map +1 -0
- package/dist/reporter.d.ts +18 -0
- package/dist/reporter.js +177 -0
- package/dist/reporter.js.map +1 -0
- package/dist/rules-loader.d.ts +12 -0
- package/dist/rules-loader.js +65 -0
- package/dist/rules-loader.js.map +1 -0
- package/dist/scanner.d.ts +6 -0
- package/dist/scanner.js +42 -0
- package/dist/scanner.js.map +1 -0
- package/package.json +41 -0
- package/rules/001-missing-signer-check.md +57 -0
- package/rules/002-missing-owner-check.md +53 -0
- package/rules/003-missing-discriminator-check.md +53 -0
- package/rules/004-account-substitution.md +54 -0
- package/rules/005-sysvar-spoofing.md +57 -0
- package/rules/006-missing-rent-exemption-check.md +47 -0
- package/rules/007-account-aliasing.md +53 -0
- package/rules/008-uninitialized-account-use.md +53 -0
- package/rules/009-missing-mut-constraint.md +52 -0
- package/rules/010-missing-close-constraint.md +48 -0
- package/rules/011-pda-seed-collision.md +49 -0
- package/rules/012-missing-bump-validation.md +55 -0
- package/rules/013-non-canonical-bump-accepted.md +52 -0
- package/rules/014-predictable-pda.md +57 -0
- package/rules/015-insecure-pda-across-upgrades.md +54 -0
- package/rules/016-bump-mismatch.md +49 -0
- package/rules/017-arbitrary-cpi.md +56 -0
- package/rules/018-cpi-confused-deputy.md +50 -0
- package/rules/019-missing-program-id-check-spl.md +51 -0
- package/rules/020-reentrancy-via-cpi.md +50 -0
- package/rules/021-untrusted-callback.md +49 -0
- package/rules/022-cpi-with-attacker-accounts.md +58 -0
- package/rules/023-lamport-overflow.md +50 -0
- package/rules/024-token-amount-overflow.md +52 -0
- package/rules/025-precision-loss.md +42 -0
- package/rules/026-rounding-direction.md +43 -0
- package/rules/027-token-decimal-mismatch.md +50 -0
- package/rules/028-integer-cast-truncation.md +42 -0
- package/rules/029-off-by-one.md +45 -0
- package/rules/030-missing-authorization.md +51 -0
- package/rules/031-reinitialization-attack.md +50 -0
- package/rules/032-closed-account-revival.md +49 -0
- package/rules/033-init-if-needed-misuse.md +66 -0
- package/rules/034-missing-has-one.md +55 -0
- package/rules/035-insecure-admin-transfer.md +49 -0
- package/rules/036-missing-pause-guards.md +50 -0
- package/rules/037-clock-manipulation.md +53 -0
- package/rules/038-missing-address-validation.md +53 -0
- package/rules/039-constraint-evaluation-stage.md +54 -0
- package/rules/040-realloc-zero-init.md +53 -0
- package/rules/041-missing-payer-on-init.md +59 -0
- package/rules/042-incorrect-space-allocation.md +53 -0
- package/rules/043-account-vs-account-info.md +53 -0
- package/rules/044-token-account-owner-unverified.md +56 -0
- package/rules/045-token-mint-unverified.md +58 -0
- package/rules/046-ata-assumption-errors.md +53 -0
- package/rules/047-token-program-id-hardcoded.md +55 -0
- package/rules/048-compute-budget-abuse.md +51 -0
- package/rules/049-log-spam-dos.md +49 -0
- package/rules/050-stack-overflow-deep-cpi.md +48 -0
- package/rules/INDEX.md +65 -0
- 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.
|