PDA Validation Remediation
How to fix missing PDA address validation that allows attackers to substitute attacker-controlled accounts.
PDA Validation Remediation
Overview
Related Detector: PDA Validation
A PDA is only secure if the program checks that a passed-in account’s address matches the address derived from the expected seeds. Without this check, an attacker can pass a regular keypair account they control and bypass every assumption that depends on PDA ownership.
Recommended Fix
Before (Vulnerable)
pub fn withdraw(accounts: &[AccountInfo], user: Pubkey) -> ProgramResult {
let vault = &accounts[0];
// VULNERABLE: vault could be any account.
let mut data = vault.try_borrow_mut_data()?;
let amount = u64::from_le_bytes(data[..8].try_into().unwrap());
transfer_lamports(vault, &accounts[1], amount)?;
Ok(())
}
After (Fixed)
pub fn withdraw(
program_id: &Pubkey,
accounts: &[AccountInfo],
user: Pubkey,
) -> ProgramResult {
let vault = &accounts[0];
// Re-derive the expected PDA and compare.
let (expected, _bump) = Pubkey::find_program_address(
&[b"vault", user.as_ref()],
program_id,
);
if vault.key != &expected {
return Err(ProgramError::InvalidSeeds);
}
// Also verify ownership — otherwise an attacker could pre-create
// an account at the expected address with a different owner.
if vault.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
let mut data = vault.try_borrow_mut_data()?;
let amount = u64::from_le_bytes(data[..8].try_into().unwrap());
transfer_lamports(vault, &accounts[1], amount)?;
Ok(())
}
Address validation alone is insufficient — pair it with an owner check so an attacker cannot front-run by creating a system-owned account at the same address.
Alternative Mitigations
Store and verify the bump seed
let pda = Pubkey::create_program_address(
&[b"vault", user.as_ref(), &[stored_bump]],
program_id,
)?;
if vault.key != &pda {
return Err(ProgramError::InvalidSeeds);
}
create_program_address is cheaper than find_program_address because it does not search for a bump. Store the canonical bump in account state at initialization and reuse it.
Use Anchor’s seeds + bump constraint
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump = vault.bump,
)]
pub vault: Account<'info, Vault>,
Anchor performs both the address derivation and the discriminator/owner check at deserialization. This is the lowest-effort safe pattern.
Common Mistakes
Verifying address but not owner. An attacker can call the System Program to allocate an account at any address, including a PDA address. Without an owner check, the program treats it as legitimate state.
Using find_program_address on every call. It costs ~1500 CU per invocation because it searches for the canonical bump. For hot paths, store the bump and use create_program_address.
Trusting client-supplied bumps. A non-canonical bump can derive a different valid PDA. Always store the canonical bump on initialization and reject any other value.
Skipping validation on read-only accounts. Even read-only PDAs control program logic (e.g., oracle PDAs). Validate them too.