Is the program actually checking who owns this account?
On Solana, any transaction can pass ANY account whose data happens to deserialize — the runtime does not verify an account belongs to your program. The classic bug: an instruction reads a state account via raw `AccountInfo` (or Anchor `UncheckedAccount`) and trusts its contents without checking `account.owner == &program_id`. An attacker crafts their own account under their own program (or the system program), writes attacker-controlled bytes into it, and passes it in as the "config", "oracle", or "vault" account. Fix: always verify `owner`, or use Anchor's `Account<'info, T>` which enforces owner = declaring program plus the 8-byte discriminator. Detection signal: any `try_from_slice`, `deserialize`, or `UncheckedAccount` without a preceding owner check. This is the Solana-native equivalent of trusting unvalidated external storage.
Can two same-type accounts be swapped in this instruction?
Account confusion: when an instruction takes two accounts of the SAME type (e.g., `user_a` and `user_b`, or `vault` and `treasury` — both `Account<'info, Vault>`), the owner and discriminator checks pass for either slot, so nothing stops an attacker from swapping them or passing the attacker's own account where the victim's is expected. The fix is relational validation: every state account should store the pubkeys of the accounts it's tied to (authority, mint, vault), and the instruction must assert those links — in Anchor that's `has_one = authority`, `has_one = mint`, or `constraint = vault.authority == authority.key()`. Audit procedure: for every account pair of identical type in a `#[derive(Accounts)]` struct, ask "what breaks if these are swapped or aliased?" If the answer involves value moving the wrong way, it's a finding.
Are duplicate mutable accounts rejected here?
If an instruction expects two distinct mutable accounts (e.g., `from` and `to` token accounts, `source` and `destination` staking accounts), Solana happily passes the SAME account in both slots. Effects range from broken accounting (a self-transfer that mints or burns net value because debit and credit read/write different cached states) to full exploits when the two roles have different trust assumptions. Fix in Anchor: `constraint = from.key() != to.key() @ ErrorCode::DuplicateAccounts`, or design so aliasing is harmless. Raw programs: compare `from.key == to.key` explicitly. Detection signal: any instruction taking two or more `#[account(mut)]` accounts of the same type with no inequality constraint. Note the reverse trap: some protocols intentionally require aliasing (merge operations) — check the spec before reporting.
Could a fake token mint slip past this validation?
Creating a new SPL mint costs a fraction of a SOL, so any mint pubkey an attacker supplies is presumed hostile. Classic patterns: a program accepts a deposit of "collateral" and credits it against a hardcoded mint it never verifies; a reward program trusts `mint` from a user-supplied token account without checking `token_account.mint == expected_mint`; or a pricing path reads decimals from the attacker's mint (6 vs 9 decimals = 1000x mispricing). Fix: hardcode or store canonical mint addresses and assert equality — Anchor: `constraint = mint.key() == EXPECTED_MINT` or `address = EXPECTED_MINT`; always derive token-account expectations from the mint, not the reverse. Detection signal: `ctx.accounts.mint` used in transfer/burn/mint CPI whose address is never pinned against a constant or a stored config field.
What stops abuse of remaining_accounts in this instruction?
`ctx.remaining_accounts` is an unvalidated free-for-all: Anchor applies ZERO checks to accounts passed there, and raw programs doing manual iteration often skip owner/signer/writability validation entirely. Abuse shapes: passing N attacker-owned accounts where the protocol expected program-owned ones (each credited as a deposit); passing the same account repeatedly to multiply rewards; passing a fake oracle or fee-destination account. Rule: every account pulled from `remaining_accounts` must get the same treatment as a named account — owner check, discriminator/deserialization check, address or seeds check against stored state, and explicit duplicate detection if iteration assumes uniqueness. Detection signal: loops over `remaining_accounts` doing CPI or balance reads with only `try_from_slice` inside. Flag any reward/fee/collateral logic driven by unvalidated tail accounts.
Can lamports be drained or rent-exemption broken on this account?
Solana accounts carry a lamport balance independent of their data. As of early 2026 the runtime enforces rent exemption for newly allocated accounts, but live accounts can still be pushed below the rent-exempt minimum or drained entirely by bugs: paying transaction fees from a protocol PDA, `close`-style code that transfers lamports to a user-supplied recipient without validation, or `assign`/`allocate` patterns that shrink the balance an attacker can then siphon via a subsequent instruction. Draining a vault PDA's lamports below rent exemption breaks the account; draining to zero + reassigning owner enables full reinitialization. Rule: lamport-moving code (`**lamports.borrow_mut()`, `system_instruction::transfer`, `close`) must check the recipient against stored state and leave the source at or above `Rent::minimum_balance(data_len)`. Audit every direct lamport mutation — it's where raw-program exploits hide.
What happens when one account fills two roles in the same instruction?
Account aliasing beyond simple duplicates: in a liquidation-style instruction, an attacker passes the SAME account as both `collateral_account` and `debt_account`. The program burns tokens from "debt" and mints/transfers to "collateral" — but both roles are one account, so the net effect depends on instruction order and cached state: burn-then-mint can leave the attacker with freshly minted tokens and no real debt reduction, or the debit and credit are both computed from the same pre-state and both applied, creating value from nothing. Rule: any instruction where two mutable accounts play different trust roles (collateral vs debt, source vs destination, vault vs fee) must assert inequality — Anchor: `constraint = collateral.key() != debt.key()`. When iterating liquidatable positions via `remaining_accounts`, enforce the same uniqueness discipline per entry, since Anchor applies zero checks to tail accounts.
Is reading lamports from remaining_accounts dangerous by itself?
No — and confusing read vs write here produces both false positives and missed criticals. READING `account.lamports()` or data from an unvalidated `remaining_accounts` entry cannot move value; the risk is decision corruption: fee logic, caps, or eligibility computed from an attacker's fake balances — an input-validation finding, not a drain. Actual DRAIN requires write authority: only an account's OWNER program can debit its lamports or mutate its data, so your program can only lose lamports from accounts it owns (PDAs, vaults) via `**lamports.borrow_mut()`, a system-transfer CPI it signs, or a close path. The critical pattern: a writable protocol-owned account in the tail, closed or transferred to a caller-supplied recipient without checking that recipient against stored state. Audit split: reads → validate provenance of anything influencing logic; writes/closes → validate recipient and authority, every time.
Why is a hardcoded rent-exempt threshold a bug?
Rent-exemption amounts are not constants: they depend on account data length AND the cluster's rent parameters (`lamports_per_byte_year`, exemption threshold), which governance can change. A program hardcoding e.g. `const RENT_MIN: u64 = 890_880` (the classic 165-byte token-account figure) embeds two failure modes: if cluster rent parameters change, the hardcoded floor is wrong — accounts the program considers "safe" fall below the real exemption minimum and become fee-vulnerable or purgeable, and any protection logic gating on the stale constant silently breaks; and if account sizes migrate (Token-2022 extension accounts are larger), the constant was wrong from day one. Rule: always compute at runtime — `Rent::get()?.minimum_balance(data_len)` or Anchor's rent sysvar — using the CURRENT account length. Audit signal: any lamport literal compared against a balance, or a `minimum_balance` result cached in state without a refresh path.
What's wrong with closing an account by zeroing its lamports?
Raw-program close recipes fail in two directions. Setting `**account.lamports.borrow_mut() = 0` WITHOUT transferring the lamports to a recipient burns them — value is destroyed (deducted from total lamport supply), not stolen, so it's loss-of-funds for the protocol rather than theft. Meanwhile, transferring lamports but NOT zeroing data or reassigning the owner leaves a zombie account: zero balance but intact data and discriminator, which `init_if_needed`-style flows and discriminator-only checks may treat as still initialized — the classic reinit springboard. The correct manual close is three steps: transfer the FULL lamport balance to a validated recipient, `realloc(0)` / zero the data, and `assign` the account back to the system program. Anchor's `close = recipient` does all three plus writes the CLOSED discriminator (defense against resurrection within the same tx). Audit: any lamport-zeroing or close emulation missing a step is a finding; the recipient must come from stored state, not caller input.