Skip to main content
Sigvex

PDA Manipulation Remediation

How to prevent PDA substitution attacks by using canonical bumps, storing bump seeds, and verifying PDA derivation with the account-validation framework'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 the account-validation framework’s seeds and bump constraints, or by explicitly calling Pubkey::find_program_address with the authoritative program ID at verification time.

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: the account-validation framework'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 the account-validation framework'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 an account-validation framework, 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.

References