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:
- Create a different PDA using the same program but different seeds that shares the same owner program
- Pass the counterfeit PDA in place of the expected vault or authority PDA
- 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:
- PDA-dependent operations: Identifies accounts used in
StoreAccountDataorTransferLamportsoperations that appear to act as PDAs (based on ownership and usage patterns). - Derivation tracking: Searches for
FindProgramAddressorCreateProgramAddresscalls in the same function or in predecessor blocks. - Key comparison: Verifies that the result of the derivation is compared (
CheckKey) against the account key before the privileged operation. - Anchor detection: Recognizes Anchor’s discriminator-plus-seeds verification pattern and suppresses findings for accounts properly validated by Anchor’s macro-generated code.
- 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_addressapproach (checking thatcreate_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.
Related Detectors
- Bump Seed Canonicalization — detects failure to use canonical bump seeds
- Account Alias Attack — detects account substitution attacks
- Missing Owner Check — detects missing account owner verification