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 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.
|