PDA Manipulation Remediation
How to prevent PDA substitution attacks by using canonical bumps, storing bump seeds, and verifying PDA derivation with Anchor's seeds constraint.
PDA Manipulation Remediation
Overview
Related Detector: Bump Seed Canonicalization
Program Derived Addresses (PDAs) are accounts exclusively controlled by a program — derived from the program’s ID and a set of seeds, with no corresponding private key. The vulnerability arises when a program verifies a PDA by comparing key equality alone, rather than by re-deriving the expected address from the authoritative program ID and seeds. An attacker can derive a PDA using the same seeds but a different program ID, producing a different address that nonetheless may pass a loose key comparison. Even within the same program, using a non-canonical bump seed (any of the 255 valid bump values instead of the highest one) allows an attacker to derive multiple valid-looking PDAs for the same seeds.
The fix is to always verify PDA derivation using Anchor’s seeds and bump constraints, or by explicitly calling Pubkey::find_program_address with the authoritative program ID at verification time.
Recommended Fix
Before (Vulnerable)
use anchor_lang::prelude::*;
#[program]
mod vulnerable_lending {
pub fn repay_loan(ctx: Context<RepayLoan>, amount: u64) -> Result<()> {
// VULNERABLE: only checks key equality — does not verify derivation path
let expected = derive_loan_address(ctx.accounts.borrower.key());
require!(
ctx.accounts.loan.key() == expected,
LendingError::InvalidLoan
);
// Attacker can pass a loan account derived from a different program
// with crafted data (e.g., inflated balance) that passes this check
let loan = &ctx.accounts.loan;
// ... repay logic using loan.amount_owed
Ok(())
}
}
fn derive_loan_address(borrower: Pubkey) -> Pubkey {
// Returns a Pubkey — does not include the program_id in the derivation check
let (pda, _bump) = Pubkey::find_program_address(
&[b"loan", borrower.as_ref()],
&crate::id(),
);
pda
}
#[derive(Accounts)]
pub struct RepayLoan<'info> {
/// CHECK: Verified by key comparison only — insufficient!
#[account(mut)]
pub loan: AccountInfo<'info>,
pub borrower: Signer<'info>,
}
After (Fixed)
use anchor_lang::prelude::*;
#[program]
mod secure_lending {
pub fn repay_loan(ctx: Context<RepayLoan>, amount: u64) -> Result<()> {
// FIXED: Anchor's seeds + bump constraint verifies:
// 1. The account address is derived from these seeds + this program's ID
// 2. The bump matches the stored canonical bump
// 3. The account is owned by this program (via Account<'info, Loan>)
let loan = &ctx.accounts.loan;
// ... repay logic using loan.amount_owed — derivation is guaranteed
Ok(())
}
}
#[derive(Accounts)]
pub struct RepayLoan<'info> {
#[account(
mut,
seeds = [b"loan", borrower.key().as_ref()],
bump = loan.bump, // Uses the stored canonical bump — not find_program_address at runtime
has_one = borrower, // Verifies loan.borrower == borrower.key()
)]
pub loan: Account<'info, Loan>,
pub borrower: Signer<'info>,
}
#[account]
pub struct Loan {
pub borrower: Pubkey,
pub amount_owed: u64,
pub bump: u8, // Canonical bump stored during initialization
}
The seeds constraint re-derives the expected PDA from the program’s own ID and the provided seeds, then compares it to the supplied account key. Because the program ID is always the executing program’s ID, a PDA derived from a different program cannot match.
Alternative Mitigations
1. Store the canonical bump at initialization
Always store the canonical bump seed returned by find_program_address in the account data during initialization. Use that stored value — not a fresh find_program_address call — for all subsequent CPI signing:
#[program]
mod secure_protocol {
pub fn initialize_loan(ctx: Context<InitializeLoan>) -> Result<()> {
let loan = &mut ctx.accounts.loan;
loan.borrower = ctx.accounts.borrower.key();
loan.amount_owed = 0;
// Store canonical bump returned by find_program_address (via Anchor's bump)
loan.bump = ctx.bumps.loan;
Ok(())
}
pub fn sign_as_pda(ctx: Context<SignAsPda>) -> Result<()> {
let loan = &ctx.accounts.loan;
// Use the stored canonical bump — not find_program_address — for CPI signing
let seeds = &[
b"loan".as_ref(),
loan.borrower.as_ref(),
&[loan.bump], // Stored canonical bump
];
let signer_seeds = &[seeds.as_slice()];
invoke_signed(
&some_instruction,
&[/* accounts */],
signer_seeds,
)?;
Ok(())
}
}
2. Native PDA verification without Anchor
When not using Anchor, explicitly call find_program_address and compare:
use solana_program::{
account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey,
};
fn verify_pda(
account: &AccountInfo,
seeds: &[&[u8]],
program_id: &Pubkey,
) -> Result<u8, ProgramError> {
let (expected_pda, canonical_bump) = Pubkey::find_program_address(seeds, program_id);
if account.key != &expected_pda {
msg!(
"PDA mismatch: expected {}, got {}",
expected_pda,
account.key
);
return Err(ProgramError::InvalidArgument);
}
Ok(canonical_bump)
}
// Usage:
let bump = verify_pda(
loan_account,
&[b"loan", borrower.key.as_ref()],
program_id,
)?;
3. Enforce canonical bump during creation
find_program_address searches from bump 255 downward and returns the first valid bump. Always use this canonical value — never accept a caller-supplied bump:
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = payer,
space = 8 + Loan::LEN,
seeds = [b"loan", borrower.key().as_ref()],
bump, // Anchor finds and stores the canonical bump automatically
)]
pub loan: Account<'info, Loan>,
pub borrower: Signer<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
Common Mistakes
Mistake 1: Accepting a caller-supplied bump seed
// WRONG: the caller can supply a non-canonical bump to derive a different address
pub fn process(ctx: Context<Process>, bump: u8) -> Result<()> {
let seeds = &[b"vault", ctx.accounts.owner.key().as_ref(), &[bump]];
let (expected_pda, _) = Pubkey::create_program_address(seeds, ctx.program_id)?;
require!(ctx.accounts.vault.key() == expected_pda, ErrorCode::InvalidVault);
// ...
}
Never accept a bump from the caller. Use only the stored canonical bump or re-derive it via find_program_address.
Mistake 2: Skipping the bump constraint on read instructions
// WRONG: omitting bump check on read-only instructions still allows PDA substitution
#[account(
seeds = [b"config"],
// bump constraint missing — any address derived from these seeds (any bump) accepted
)]
pub config: Account<'info, Config>,
Always include the bump constraint (using the stored canonical value) on every instruction that uses the PDA, not just write instructions.
Mistake 3: Using create_program_address instead of find_program_address for verification
// RISKY: create_program_address accepts any bump — use find_program_address
let pda = Pubkey::create_program_address(
&[b"vault", owner.key.as_ref(), &[caller_supplied_bump]],
program_id,
)?;
// pda is valid for *that* bump but may not be the canonical PDA
create_program_address is appropriate for CPI signing when you already know the canonical bump. Use find_program_address for verification when you do not have a stored bump.