DEV Community

ohmygod
ohmygod

Posted on

Solana PDA Security: 7 Deadly Mistakes That Have Cost Protocols $100M+ — And the Anchor Patterns That Prevent Each One

TL;DR

Program Derived Addresses (PDAs) are the backbone of Solana program state management. They're also the source of some of the most expensive bugs in Solana's history — from the $52M Cashio hack (account confusion via unchecked PDA ownership) to ongoing audit findings in 2026 where programs accept non-canonical bumps, use insufficient seeds, or fail to validate PDA relationships.

This article catalogs 7 recurring PDA mistakes, shows the vulnerable code pattern for each, and provides the Anchor constraint that prevents it. If you're building on Solana, this is your PDA security checklist.


Why PDAs Are a Unique Attack Surface

On Ethereum, contracts have a single address and their storage is isolated. On Solana, programs are stateless — all data lives in accounts, and those accounts are passed into instructions by the caller. The program must verify every account it receives.

PDAs solve the "who controls this account?" problem by creating deterministic addresses that only a specific program can sign for. But PDAs introduce their own class of vulnerabilities because:

  1. The caller chooses which accounts to pass. A program can't assume it received the PDA it expects — it must derive and verify.
  2. Multiple valid bumps exist for the same seeds. Without canonical bump enforcement, an attacker can create parallel "shadow" accounts.
  3. Seeds are arbitrary bytes. Insufficient seed uniqueness means different logical entities can collide into the same PDA.
  4. PDA relationships form a trust graph. If Account A's PDA is derived from Account B's address, and Account B isn't validated, the entire chain of trust collapses.

Let's walk through each mistake.


Mistake 1: Not Enforcing Canonical Bump Seeds

The bug: When a program derives a PDA using create_program_address with a user-supplied bump instead of using find_program_address (which returns the canonical/highest bump), an attacker can create up to 256 different valid PDAs for the same logical entity.

Real-world impact: Airdrop claim programs that track "has this user claimed?" via a PDA can be exploited to claim 256 times — once per valid bump.

// ❌ VULNERABLE: Accepts any bump the user provides
#[derive(Accounts)]
pub struct ClaimAirdrop<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + ClaimRecord::INIT_SPACE,
        seeds = [b"claim", user.key().as_ref()],
        bump  // User can pass any valid bump
    )]
    pub claim_record: Account<'info, ClaimRecord>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

// The attacker calls this instruction 256 times with different bumps,
// each time creating a new claim_record PDA and receiving tokens.
Enter fullscreen mode Exit fullscreen mode

The fix: Store the canonical bump at initialization and verify it on all subsequent accesses.

// ✅ SECURE: Enforces canonical bump
#[account]
#[derive(InitSpace)]
pub struct ClaimRecord {
    pub user: Pubkey,
    pub claimed: bool,
    pub bump: u8,  // Store the canonical bump
}

#[derive(Accounts)]
pub struct ClaimAirdrop<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + ClaimRecord::INIT_SPACE,
        seeds = [b"claim", user.key().as_ref()],
        bump  // Anchor automatically uses find_program_address (canonical)
    )]
    pub claim_record: Account<'info, ClaimRecord>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

// On subsequent access:
#[derive(Accounts)]
pub struct VerifyClaim<'info> {
    #[account(
        seeds = [b"claim", user.key().as_ref()],
        bump = claim_record.bump,  // Verify against stored canonical bump
    )]
    pub claim_record: Account<'info, ClaimRecord>,
    pub user: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Anchor shortcut: When you use bump without a value in init, Anchor calls find_program_address and uses the canonical bump. Store it in your account struct and reference it with bump = account.bump on reads.


Mistake 2: Account Type Cosplay (Missing Discriminator Checks)

The bug: A program deserializes an account without verifying its type discriminator. An attacker creates a fake account with a data layout that "looks like" the expected type but is actually controlled by a different program — or is a different account type from the same program.

Real-world impact: The $52M Cashio hack. The attacker passed a fake collateral account that had the right data shape but wrong semantics, bypassing validation checks.

// ❌ VULNERABLE: Using UncheckedAccount without discriminator validation
#[derive(Accounts)]
pub struct Withdraw<'info> {
    /// CHECK: We just read data from this
    pub collateral_account: UncheckedAccount<'info>,
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    pub user: Signer<'info>,
}

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    // Reads raw data without verifying this is actually a Collateral account
    let data = ctx.accounts.collateral_account.data.borrow();
    let collateral_value = u64::from_le_bytes(data[8..16].try_into().unwrap());

    require!(collateral_value >= amount, ErrorCode::Undercollateralized);
    // Attacker passes a fake account with inflated "collateral_value"
    transfer_from_vault(&ctx.accounts.vault, amount)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The fix: Always use typed Account<'info, T> wrappers. Anchor automatically checks the 8-byte discriminator.

// ✅ SECURE: Anchor validates discriminator and owner automatically
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(
        has_one = user,  // Also verify the collateral belongs to this user
        constraint = collateral_account.is_active @ ErrorCode::InactiveCollateral,
    )]
    pub collateral_account: Account<'info, Collateral>,
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    pub user: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Rule: If you ever write /// CHECK: in an Anchor program, you need to either:

  1. Immediately validate the account in the instruction body, or
  2. Have a very good reason documented in the comment (e.g., it's a known system program)

Mistake 3: Insufficient Seed Uniqueness

The bug: PDA seeds don't include enough identifying information, causing different logical entities to derive the same address.

// ❌ VULNERABLE: Seeds only use a static prefix
// Every user's "config" account derives to the SAME PDA
#[account(
    init,
    payer = user,
    space = 8 + UserConfig::INIT_SPACE,
    seeds = [b"config"],  // Missing user-specific seed!
    bump
)]
pub user_config: Account<'info, UserConfig>,
Enter fullscreen mode Exit fullscreen mode

This might seem obvious, but subtler variants appear in production:

// ❌ VULNERABLE: Using only token mint as seed for a per-user-per-mint account
// Two different users with the same mint get the same PDA
#[account(
    seeds = [b"position", mint.key().as_ref()],
    bump
)]
pub position: Account<'info, Position>,

// ✅ SECURE: Include both user and mint
#[account(
    seeds = [b"position", user.key().as_ref(), mint.key().as_ref()],
    bump
)]
pub position: Account<'info, Position>,
Enter fullscreen mode Exit fullscreen mode

The fix: Include all dimensions of uniqueness in your seeds:

// Seed design checklist:
// 1. Static prefix (identifies the account type): b"position"
// 2. Owner/authority: user.key().as_ref()
// 3. Related entity: mint.key().as_ref()
// 4. Additional discriminator if needed: &pool_id.to_le_bytes()

#[account(
    seeds = [
        b"position",
        user.key().as_ref(),
        pool.key().as_ref(),
        mint.key().as_ref(),
    ],
    bump = position.bump,
)]
pub position: Account<'info, Position>,
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Missing has_one Relationship Validation

The bug: A PDA is correctly derived and has the right type, but the program doesn't verify it belongs to the right parent entity. The attacker substitutes their own valid PDA from a different context.

// ❌ VULNERABLE: Vault PDA is valid, but might not belong to this pool
#[derive(Accounts)]
pub struct SwapTokens<'info> {
    pub pool: Account<'info, Pool>,
    #[account(mut)]
    pub vault_a: Account<'info, TokenAccount>,  // Could be any vault!
    #[account(mut)]
    pub vault_b: Account<'info, TokenAccount>,
    pub user: Signer<'info>,
}

// Attacker passes vault_a from Pool X but pool from Pool Y
// If Pool Y has more favorable rates, they profit from the mismatch
Enter fullscreen mode Exit fullscreen mode

The fix: Use has_one or explicit constraints to verify relationships:

// ✅ SECURE: Enforce vault-pool relationships
#[derive(Accounts)]
pub struct SwapTokens<'info> {
    #[account(
        has_one = vault_a,  // pool.vault_a must == vault_a.key()
        has_one = vault_b,  // pool.vault_b must == vault_b.key()
    )]
    pub pool: Account<'info, Pool>,
    #[account(mut)]
    pub vault_a: Account<'info, TokenAccount>,
    #[account(mut)]
    pub vault_b: Account<'info, TokenAccount>,
    pub user: Signer<'info>,
}

// Alternative: seed-based relationship enforcement
#[derive(Accounts)]
pub struct SwapTokens<'info> {
    pub pool: Account<'info, Pool>,
    #[account(
        mut,
        seeds = [b"vault_a", pool.key().as_ref()],
        bump = pool.vault_a_bump,
    )]
    pub vault_a: Account<'info, TokenAccount>,
    #[account(
        mut,
        seeds = [b"vault_b", pool.key().as_ref()],
        bump = pool.vault_b_bump,
    )]
    pub vault_b: Account<'info, TokenAccount>,
    pub user: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: PDA Authority Without Signer Verification

The bug: A PDA is used as an authority (e.g., token account owner), but the program doesn't properly invoke invoke_signed with the correct seeds, or doesn't verify that a user-provided authority is actually a PDA derived from the expected seeds.

// ❌ VULNERABLE: Trusts that `authority` is the program's PDA
// without verifying its derivation
pub fn withdraw_tokens(ctx: Context<WithdrawTokens>, amount: u64) -> Result<()> {
    let authority = &ctx.accounts.authority;

    // What if the attacker passes their own keypair as authority?
    // If they've been set as the token account authority, this drains funds
    token::transfer(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.destination.to_account_info(),
                authority: authority.to_account_info(),
            },
        ),
        amount,
    )?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The fix: Derive the PDA authority in the accounts struct and use invoke_signed:

// ✅ SECURE: Authority is derived PDA, signed with seeds
#[derive(Accounts)]
pub struct WithdrawTokens<'info> {
    #[account(
        seeds = [b"vault_authority", pool.key().as_ref()],
        bump = pool.authority_bump,
    )]
    /// CHECK: PDA authority, verified by seeds constraint
    pub authority: UncheckedAccount<'info>,
    #[account(
        mut,
        token::authority = authority,  // Verify vault is owned by this PDA
    )]
    pub vault: Account<'info, TokenAccount>,
    #[account(mut)]
    pub destination: Account<'info, TokenAccount>,
    pub pool: Account<'info, Pool>,
    pub token_program: Program<'info, Token>,
}

pub fn withdraw_tokens(ctx: Context<WithdrawTokens>, amount: u64) -> Result<()> {
    let pool_key = ctx.accounts.pool.key();
    let seeds = &[
        b"vault_authority",
        pool_key.as_ref(),
        &[ctx.accounts.pool.authority_bump],
    ];
    let signer_seeds = &[&seeds[..]];

    token::transfer(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.vault.to_account_info(),
                to: ctx.accounts.destination.to_account_info(),
                authority: ctx.accounts.authority.to_account_info(),
            },
            signer_seeds,
        ),
        amount,
    )?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Mistake 6: Reinitialization — Creating Over Existing PDAs

The bug: A PDA account can be initialized more than once, allowing an attacker to overwrite existing state. This typically happens when using create_program_address manually without checking if the account already exists.

// ❌ VULNERABLE: Manual PDA creation without checking existence
pub fn initialize_pool(ctx: Context<InitPool>) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
    // If pool already exists and has real data, this overwrites everything
    pool.authority = ctx.accounts.authority.key();
    pool.total_deposits = 0;  // Reset! All deposit tracking lost
    pool.fee_rate = 0;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The fix: Anchor's init constraint handles this automatically — it will fail if the account already exists (space is already allocated):

// ✅ SECURE: Anchor's init prevents reinitialization
#[derive(Accounts)]
pub struct InitPool<'info> {
    #[account(
        init,  // Fails if account already exists
        payer = authority,
        space = 8 + Pool::INIT_SPACE,
        seeds = [b"pool", mint.key().as_ref()],
        bump
    )]
    pub pool: Account<'info, Pool>,
    pub mint: Account<'info, Mint>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

For programs that need re-initialization (e.g., pool reset by admin), use an explicit is_initialized flag:

// ✅ SECURE: Explicit initialization guard
#[account]
#[derive(InitSpace)]
pub struct Pool {
    pub is_initialized: bool,
    pub authority: Pubkey,
    pub total_deposits: u64,
    pub bump: u8,
}

pub fn initialize_pool(ctx: Context<InitPool>) -> Result<()> {
    let pool = &mut ctx.accounts.pool;
    require!(!pool.is_initialized, ErrorCode::AlreadyInitialized);
    pool.is_initialized = true;
    pool.authority = ctx.accounts.authority.key();
    pool.bump = ctx.bumps.pool;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Mistake 7: Closing PDAs Without Zeroing Data (Revival Attacks)

The bug: When a PDA account is closed (lamports transferred out), the account data persists in memory until the end of the transaction. If another instruction in the same transaction references the "closed" account, it reads stale data — including valid discriminators and state.

// ❌ VULNERABLE: Close without zeroing allows same-transaction revival
pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
    let position = &ctx.accounts.position;
    let dest = &ctx.accounts.destination;

    // Transfer lamports out (Anchor's close does this)
    **dest.to_account_info().lamports.borrow_mut() += 
        position.to_account_info().lamports();
    **position.to_account_info().lamports.borrow_mut() = 0;

    // BUG: Data not zeroed! Account can be "revived" by re-funding in same tx
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The fix: Use Anchor's close constraint, which zeros the data AND transfers lamports:

// ✅ SECURE: Anchor's close zeros the account data
#[derive(Accounts)]
pub struct ClosePosition<'info> {
    #[account(
        mut,
        close = destination,  // Zeros data + transfers lamports
        has_one = owner,
        seeds = [b"position", owner.key().as_ref(), pool.key().as_ref()],
        bump = position.bump,
    )]
    pub position: Account<'info, Position>,
    pub pool: Account<'info, Pool>,
    pub owner: Signer<'info>,
    /// CHECK: Receives the reclaimed rent
    #[account(mut)]
    pub destination: UncheckedAccount<'info>,
}
Enter fullscreen mode Exit fullscreen mode

For extra paranoia, add a force_defund check that prevents the account from being refunded in the same transaction:

// ✅ EXTRA SECURE: Check account is truly empty after close
pub fn close_and_verify(ctx: Context<ClosePosition>) -> Result<()> {
    // Anchor's close handles zeroing and lamport transfer
    // But verify the discriminator is actually zeroed
    let data = ctx.accounts.position.to_account_info().data.borrow();
    let disc = &data[..8];
    require!(
        disc == &[0u8; 8],
        ErrorCode::AccountNotFullyClosed
    );
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The PDA Security Audit Checklist

Run this against every Anchor program before mainnet:

# Check Severity Tool
1 All PDAs use canonical bump (stored + verified) Critical Manual + Semgrep
2 No UncheckedAccount without explicit validation Critical anchor build warnings
3 Seeds include all uniqueness dimensions High Manual review
4 has_one or seed-based relationship enforcement High Slither-Anchor
5 PDA authorities use invoke_signed with correct seeds Critical Trident fuzzer
6 init constraint prevents reinitialization High Anchor (built-in)
7 close constraint zeros data on account closure High Anchor (built-in)
8 No raw create_program_address without bump validation Critical Semgrep
9 Token account authorities verified via token::authority High Manual
10 Cross-instruction PDA state consistency (reload after CPI) Critical Manual

Semgrep Rules for PDA Security

# .semgrep/solana-pda-security.yml
rules:
  - id: raw-create-program-address
    pattern: |
      create_program_address($SEEDS, $PROGRAM_ID)
    message: >
      Direct use of create_program_address without canonical bump validation.
      Use find_program_address or Anchor's seeds/bump constraints instead.
    severity: ERROR
    languages: [rust]

  - id: unchecked-account-without-validation
    pattern: |
      /// CHECK: $MSG
      pub $NAME: UncheckedAccount<'info>,
    message: >
      UncheckedAccount usage detected. Ensure explicit validation in instruction body
      or replace with typed Account<'info, T> for automatic discriminator checks.
    severity: WARNING
    languages: [rust]

  - id: pda-without-bump-storage
    patterns:
      - pattern: |
          #[account(
              init,
              ...
              seeds = [$...SEEDS],
              bump
          )]
          pub $NAME: Account<'info, $TYPE>,
      - pattern-not: |
          pub bump: u8,
    message: >
      PDA initialized with bump but account struct may not store the canonical bump.
      Add 'bump: u8' field and set it in the init handler.
    severity: WARNING
    languages: [rust]
Enter fullscreen mode Exit fullscreen mode

Testing PDA Security With Trident

Trident (the Solana fuzzer by Ackee Blockchain) can systematically test PDA edge cases:

// trident-tests/fuzz_tests/fuzz_pda_security.rs
use trident_client::fuzzing::*;

#[derive(Arbitrary, Debug)]
pub struct FuzzPdaAttack {
    pub bump_override: u8,          // Try non-canonical bumps
    pub use_wrong_seeds: bool,      // Derive PDA from wrong entity
    pub substitute_account: bool,   // Pass PDA from different program
    pub reinitialize: bool,         // Try to re-init existing PDA
    pub close_and_revive: bool,     // Close then use in same tx
}

impl FuzzPdaAttack {
    fn execute(&self, program: &ProgramTestContext) -> Result<()> {
        if self.bump_override != canonical_bump {
            // Should fail: non-canonical bump
            let result = call_with_custom_bump(self.bump_override);
            assert!(result.is_err(), "Non-canonical bump accepted!");
        }

        if self.substitute_account {
            // Should fail: PDA from attacker's program
            let fake_pda = Pubkey::find_program_address(
                &[b"position", user.as_ref()],
                &attacker_program_id,  // Wrong program
            );
            let result = call_with_account(fake_pda.0);
            assert!(result.is_err(), "Foreign PDA accepted!");
        }

        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. PDAs aren't automatically safe. They're a mechanism, not a security guarantee. Every PDA interaction needs explicit validation.

  2. Anchor protects you — if you use its constraints. Every time you reach for UncheckedAccount or manual PDA derivation, you're opting out of Anchor's safety net.

  3. Seeds are your schema. Design them like database keys — include all dimensions of uniqueness, use type-specific prefixes, and document the derivation logic.

  4. Store the canonical bump. Always. In every PDA account struct. Verify it on every access.

  5. Test the attacker's perspective. For every PDA in your program, ask: "What happens if I pass a different valid PDA here?" If the answer isn't "the program rejects it," you have a bug.

  6. Fuzz PDA boundaries. Trident and custom fuzz harnesses should test non-canonical bumps, foreign PDAs, reinitialization, and close-then-revive attacks.

  7. Audit the trust graph. Map which PDAs derive from which other accounts. If any node in the chain is unvalidated, the entire graph is compromised.


This article is part of the DeFi Security Research series covering smart contract vulnerabilities, audit techniques, and defense patterns across Solana and EVM ecosystems.

Top comments (0)