All audits
Passed with issuesSolanaIndependent

Anchor PDA Security Patterns

Smart contract security audit by TierZero · 2024-05-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

0
Critical
1
High
2
Medium
3
Low
3
Informational

Independent research by TierZero — an educational design review of a public, widely-deployed framework pattern. Not a commissioned audit of any specific program.

What it is

Anchor is the dominant Rust framework for writing Solana programs, and its account-validation macros are the de facto standard for how the ecosystem thinks about Program Derived Address (PDA) security. A PDA is an address derived deterministically from a set of seeds plus a program ID, with no corresponding private key — the owning program signs for it via invoke_signed. Anchor wraps the raw Sealevel account model (where every instruction receives a flat list of untyped, attacker-supplied AccountInfo entries) with typed guards: Signer<'info>, Account<'info, T>, Program<'info, T>, the #[account(...)] constraint DSL, and an 8-byte account discriminator computed from the account type's name that Anchor prepends to every serialized account and checks on deserialization.

None of this is exotic. It's the same pattern reviewed in essentially every Solana program, and it's the pattern most implementer mistakes cluster around. This review looks at why the design holds when used correctly, and catalogs the specific ways teams defeat their own safety net.

Threat model

On Solana, every account passed into an instruction is attacker-controlled data at the type level, even if the runtime enforces certain invariants (ownership, signer flags, balance) before your handler runs. The relevant adversary is a caller who can:

  • Substitute any account they own or control in place of an expected PDA, token account, or config account.
  • Omit a signature and still pass an account whose pubkey matches an "authority" field.
  • Pass an account of the wrong Anchor type but the right byte layout, hoping your deserialization logic doesn't check what it thinks it's reading.
  • Recreate a previously closed account at the same address to replay stale state (the classic revival/reinit path).
  • Derive a PDA with a non-canonical bump seed to get a second, unintended valid address for the same seed set.

None of these require exotic cryptography. They're all "the program trusted structure it never verified."

Why the design holds (key invariants & mitigations)

Anchor's account wrapper types collapse several checks into the type system so the compiler — not a runtime if the author might forget — enforces them:

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        mut,
        seeds = [b"vault", authority.key().as_ref()],
        bump = vault.bump,
        has_one = authority
    )]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
    #[account(mut, close = authority)]
    pub metadata: Account<'info, Metadata>,
}

Four invariants are doing the real work here:

  1. Signer enforcement. Signer<'info> fails deserialization unless is_signer is true on that account. There's no code path where authority reaches the handler unsigned.
  2. Owner + discriminator checks. Account<'info, Vault> verifies the account's owner equals the declared program ID and that the leading 8 bytes match Vault's discriminator before touching the rest of the buffer. This is what stops type confusion — an attacker can't hand you a Config account and have it parsed as a Vault.
  3. Relationship binding via has_one. The constraint checks vault.authority == authority.key(), closing the gap where an attacker supplies a real, correctly-typed, correctly-owned Vault account that simply isn't theirs.
  4. Canonical bump + seed binding. seeds + bump re-derive the PDA server-side and compare against the passed-in pubkey, so a caller can't substitute an arbitrary account at a different address that merely resembles a vault. Anchor also stores and re-uses the canonical bump rather than trusting a caller-supplied one, closing the multiple-valid-PDA class of bug.

The close = authority constraint is worth calling out separately: on close, Anchor zeroes the discriminator and transfers lamports, so a subsequently re-funded account at that address deserializes as garbage rather than stale valid state — this is what prevents reinitialization/revival attacks against closed accounts.

Where implementers get it wrong

The failures are almost never in Anchor's checks — they're in code that routes around them:

  • UncheckedAccount / raw AccountInfo used out of laziness. Any field typed this way skips owner and discriminator checks entirely. It's sometimes legitimate (e.g., accounts validated manually via CPI return data), but every instance needs a /// CHECK: comment that actually explains the manual check — not a placeholder.
  • Missing has_one on multi-tenant accounts. A correctly-typed, correctly-owned PDA that isn't constrained back to the caller is the single most common real finding in Solana reviews — this is the exact bug class documented in Anchor's own account discriminator type-confusion writeups.
  • Trusting client-supplied bump seeds instead of the stored canonical bump, reintroducing the multi-PDA problem Anchor's bump constraint exists to solve.
  • Skipping remaining-accounts validation in CPI-heavy instructions, where the discriminator/owner guarantees on the primary accounts don't extend to accounts forwarded into a downstream program — a distinct risk surface covered in our review of cross-program invocation risk in Solana DeFi.
  • Assuming Anchor checks imply economic safety. Correct account plumbing says nothing about whether the instruction's math, oracle inputs, or state transitions are sound — a well-known class of "correctly authorized, still exploitable" bugs, distinct from what's discussed in our Solana program security audit checklist for trading systems.

A useful comparison point outside Anchor's guardrails entirely: the 2022 Cashio exploit (~$52M) is the canonical illustration of this exact bug class in the wild — a forged collateral account passed validation because the program never checked the relationship between the token mint and the expected collateral bank. It's the missing-has_one problem, just without Anchor's macro to catch it.

Takeaways

Anchor's account constraints are a genuinely strong default — signer, owner, discriminator, and relationship checks are declarative and hard to accidentally omit when you stay inside Account<'info, T> and Signer<'info>. The risk concentrates at the seams: raw AccountInfo escape hatches, missing has_one bindings on multi-tenant state, client-controlled bump seeds, and CPI boundaries where Anchor's guarantees don't travel with the account. None of these are framework bugs — they're the same handful of implementer mistakes recurring across otherwise well-built programs, and they're exactly what a structured smart contract audit is built to catch before mainnet.

If your program leans on Anchor's account macros and you want a second set of eyes on where the constraints actually stop, TierZero's smart contract audit service is a good next step.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit