Skip to main content
Sigvex

PDA Validation

Detects Program Derived Addresses that are used without verifying their seeds and bump, allowing attackers to substitute counterfeit PDAs.

PDA Validation

Overview

Remediation Guide: How to Fix PDA Validation

The pda-validation detector identifies Solana programs that use Program Derived Addresses (PDAs) in privileged operations without verifying that the provided account actually corresponds to the expected PDA derivation. A PDA is derived deterministically from a program ID and a set of seeds using Pubkey::find_program_address. If a program accepts an account as a PDA but does not verify its derivation, an attacker can substitute any account with the same owner — or even craft a different PDA with different seeds that happens to pass the owner check.

Sigvex tracks PDA usage through the HIR by identifying FindProgramAddress or CreateProgramAddress calls and correlating them with the accounts passed to the instruction. The detector flags cases where an account expected to be a PDA is used in privileged operations (lamport transfers, data writes) without a preceding seed derivation and key comparison.

PDA validation is particularly important for accounts that act as vaults, authority signers, or state stores — these are the accounts most valuable to an attacker who can substitute a counterfeit.

Why This Is an Issue

PDAs derive their authority from the combination of specific seeds and the program ID. When a program fails to verify that an account is the PDA it expects, an attacker can:

  1. Create a different PDA using the same program but different seeds that shares the same owner program
  2. Pass the counterfeit PDA in place of the expected vault or authority PDA
  3. Trigger the program to sign operations using the wrong PDA, or read/write the wrong state

In programs that use PDAs as signers in CPIs (via invoke_signed), passing a counterfeit PDA can cause the CPI to fail or succeed with unintended behavior. More critically, programs that read configuration or authorization data from what they assume is a validated PDA will operate on attacker-controlled data if the PDA is not verified.

The Solend governance exploit and numerous Anchor programs have been affected by PDA confusion, where the program’s assumed account identity did not match the actual on-chain account.

How to Resolve

Always verify PDA derivation by re-computing the expected address and comparing it to the provided account:

// Before: Vulnerable — uses account assumed to be a PDA without verification
pub fn withdraw_from_vault(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let user = &accounts[0];
    let vault = &accounts[1]; // Assumed to be program vault — NOT verified!
    let destination = &accounts[2];

    // Missing: verify vault is actually derived from expected seeds
    **vault.lamports.borrow_mut() -= amount;
    **destination.lamports.borrow_mut() += amount;
    Ok(())
}

// After: Fixed — verify PDA derivation before using
pub fn withdraw_from_vault(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let user = &accounts[0];
    let vault = &accounts[1];
    let destination = &accounts[2];
    let program_id = &accounts[3];

    // Verify the vault is the expected PDA
    let (expected_vault, bump) = Pubkey::find_program_address(
        &[b"vault", user.key.as_ref()],
        program_id.key,
    );

    if vault.key != &expected_vault {
        return Err(ProgramError::InvalidAccountData);
    }

    // Also verify ownership
    if vault.owner != program_id.key {
        return Err(ProgramError::InvalidAccountData);
    }

    **vault.lamports.borrow_mut() -= amount;
    **destination.lamports.borrow_mut() += amount;
    Ok(())
}

For Anchor programs, use the seeds and bump constraints which perform this validation automatically:

#[derive(Accounts)]
pub struct WithdrawFromVault<'info> {
    pub user: Signer<'info>,

    #[account(
        mut,
        seeds = [b"vault", user.key().as_ref()],
        bump,  // Anchor re-derives and verifies the PDA
    )]
    pub vault: Account<'info, VaultState>,

    /// CHECK: Validated destination
    #[account(mut)]
    pub destination: AccountInfo<'info>,
}

pub fn withdraw_from_vault(ctx: Context<WithdrawFromVault>, amount: u64) -> Result<()> {
    // Anchor has already verified vault is the correct PDA
    let vault = &mut ctx.accounts.vault;
    vault.balance -= amount;
    Ok(())
}

Examples

Vulnerable Code

// Attacker passes a different PDA (same owner) as the "config" account
pub fn update_fee(accounts: &[AccountInfo], new_fee: u64) -> ProgramResult {
    let admin = &accounts[0];
    let config = &accounts[1]; // Not verified to be the canonical config PDA

    if !admin.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Reads from attacker-controlled account if config is not verified
    let mut data = config.try_borrow_mut_data()?;
    let config_data = Config::try_from_slice(&data)?;

    // Writes to attacker's account — real config is unchanged
    data[FEE_OFFSET..FEE_OFFSET + 8].copy_from_slice(&new_fee.to_le_bytes());
    Ok(())
}

Fixed Code

use solana_program::pubkey::Pubkey;

const CONFIG_SEED: &[u8] = b"config";

pub fn update_fee(accounts: &[AccountInfo], new_fee: u64) -> ProgramResult {
    let admin = &accounts[0];
    let config = &accounts[1];
    let program_id = &accounts[2];

    if !admin.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Verify config is the expected canonical PDA
    let (expected_config, _bump) = Pubkey::find_program_address(
        &[CONFIG_SEED],
        program_id.key,
    );

    if config.key != &expected_config {
        msg!("Config account does not match expected PDA");
        return Err(ProgramError::InvalidAccountData);
    }

    // Safe to write — config is verified
    let mut data = config.try_borrow_mut_data()?;
    data[FEE_OFFSET..FEE_OFFSET + 8].copy_from_slice(&new_fee.to_le_bytes());
    Ok(())
}

Sample Sigvex Output

{
  "detector_id": "pda-validation",
  "severity": "high",
  "confidence": 0.72,
  "description": "Account config is used in a write operation but no PDA seed derivation and key comparison was found. An attacker can substitute a counterfeit PDA account.",
  "location": { "function": "update_fee", "offset": 8 }
}

Detection Methodology

The detector performs multi-pass analysis:

  1. PDA-dependent operations: Identifies accounts used in StoreAccountData or TransferLamports operations that appear to act as PDAs (based on ownership and usage patterns).
  2. Derivation tracking: Searches for FindProgramAddress or CreateProgramAddress calls in the same function or in predecessor blocks.
  3. Key comparison: Verifies that the result of the derivation is compared (CheckKey) against the account key before the privileged operation.
  4. Anchor detection: Recognizes Anchor’s discriminator-plus-seeds verification pattern and suppresses findings for accounts properly validated by Anchor’s macro-generated code.
  5. Confidence adjustment: Reduces confidence when the function name suggests initialization (PDAs being created rather than validated) or when the account is a new allocation.

Limitations

False positives:

  • Programs that validate PDAs in a separate function called before the current instruction will be flagged if the validation is not visible in the current function’s bytecode.
  • Read-only accesses to unverified PDAs may be flagged even when the program cannot be exploited through reads alone.

False negatives:

  • Programs that validate PDAs using the verify_program_address approach (checking that create_program_address(seeds, bump) equals the account key) may not be recognized as validating.
  • Complex seed computation logic spread across multiple functions is not fully tracked.

References