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