Skip to main content
Sigvex

Bump Seed Canonicalization

Detects PDA operations using potentially non-canonical bump seeds via create_program_address, which can enable PDA collision and address confusion attacks.

Bump Seed Canonicalization

Overview

Remediation Guide: How to Fix Bump Seed Canonicalization

The bump seed canonicalization detector identifies Solana programs that create or use Program Derived Addresses (PDAs) via create_program_address with a user-supplied bump seed that has not been verified to be canonical. The canonical bump is the first valid bump value (starting from 255 counting down) returned by find_program_address. Using a non-canonical bump can allow multiple different addresses to be derived for the same seed set, creating ambiguity and enabling collision attacks.

Sigvex scans for sol_create_program_address syscall invocations and checks whether the bump seed argument was produced by sol_find_program_address (safe) or loaded from account data (a stored canonical bump — also safe), or taken from unvalidated input (flagged).

Why This Is an Issue

Solana’s PDA derivation starts from bump = 255 and decrements until it finds a bump that produces a valid off-curve address. The first (highest) valid bump is the “canonical bump.” Using find_program_address always returns the canonical bump.

The problem arises when a program stores the bump and later uses create_program_address with a stored or user-supplied bump. If an attacker can supply a non-canonical bump:

  • Different bump values may produce different PDAs, confusing the program’s state model
  • An attacker can front-run initialization to create a PDA using a non-canonical bump before the legitimate user creates it with the canonical bump
  • The program’s validation logic (which may compare against a canonical address) fails silently

This is a well-known vulnerability class in production Solana programs, cataloged in the coral-xyz/sealevel-attacks repository.

How to Resolve

// Before: Vulnerable — uses user-provided bump with create_program_address
pub fn initialize_vault(accounts: &[AccountInfo], user_bump: u8) -> ProgramResult {
    let vault_pda = Pubkey::create_program_address(
        &[b"vault", accounts[0].key.as_ref(), &[user_bump]], // user_bump not validated!
        &crate::id(),
    )?;
    // vault_pda may not match the canonical PDA
    if vault_pda != *accounts[1].key {
        return Err(ProgramError::InvalidSeeds);
    }
    Ok(())
}

// After: Use find_program_address or validate stored canonical bump
pub fn initialize_vault(accounts: &[AccountInfo]) -> ProgramResult {
    // find_program_address always returns the canonical bump
    let (canonical_pda, canonical_bump) = Pubkey::find_program_address(
        &[b"vault", accounts[0].key.as_ref()],
        &crate::id(),
    );

    if canonical_pda != *accounts[1].key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Store the canonical bump for future use
    let vault_data = &mut accounts[1].data.borrow_mut();
    vault_data[0] = canonical_bump;
    Ok(())
}

If using a stored canonical bump:

pub fn use_vault(accounts: &[AccountInfo]) -> ProgramResult {
    let vault = &accounts[1];
    let data = vault.data.borrow();
    let stored_bump = data[0]; // Loaded from account data — treated as canonical

    // Verify the vault address matches what we expect
    let expected_pda = Pubkey::create_program_address(
        &[b"vault", accounts[0].key.as_ref(), &[stored_bump]],
        &crate::id(),
    )?;

    if expected_pda != *vault.key {
        return Err(ProgramError::InvalidSeeds);
    }
    Ok(())
}

Examples

Vulnerable Code

// User supplies bump — non-canonical bumps can create multiple valid PDAs
pub fn create_escrow(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
    // data[0] is attacker-controlled bump
    let bump = data[0];

    let escrow_pda = Pubkey::create_program_address(
        &[b"escrow", accounts[0].key.as_ref(), &[bump]],
        &crate::id(),
    )?;
    // Multiple bumps (e.g., 253, 254, 255) may all be valid — attacker picks one
    // that gives them an address they pre-computed
    require!(escrow_pda == *accounts[1].key, MyError::InvalidEscrow);
    // Initialize escrow at attacker-chosen address...
    Ok(())
}

Fixed Code

pub fn create_escrow(accounts: &[AccountInfo]) -> ProgramResult {
    // Always use find_program_address — returns canonical bump only
    let (escrow_pda, bump) = Pubkey::find_program_address(
        &[b"escrow", accounts[0].key.as_ref()],
        &crate::id(),
    );

    require!(escrow_pda == *accounts[1].key, MyError::InvalidEscrow);

    // Store canonical bump in account data for future cross-reference
    let data = &mut accounts[1].data.borrow_mut();
    data[0] = bump;
    Ok(())
}

Sample Sigvex Output

{
  "detector_id": "bump-seed-canonicalization",
  "severity": "high",
  "confidence": 0.80,
  "description": "PDA created using create_program_address with a potentially non-canonical bump seed. Non-canonical bumps can lead to PDA collision attacks where multiple different bump values derive the same address.",
  "location": { "function": "create_escrow", "offset": 5 }
}

Detection Methodology

The detector implements a two-pass analysis:

Pass 1 — Validation collection:

  • Variables assigned from sol_find_program_address syscall are marked as canonical bumps.
  • Variables loaded from HirExpr::AccountData (any offset) are treated as stored canonical bumps — a common Anchor and native pattern for re-validating PDAs.
  • Branch conditions comparing variables via BinOp::Eq or BinOp::Ne mark both sides as validated.

Pass 2 — PDA operation analysis:

  • Identifies sol_create_program_address syscalls.
  • Extracts the bump seed argument (typically the last argument).
  • Reports findings for bump seeds not in the validated set.
  • sol_find_program_address calls are always safe — no finding generated.

Context modifiers: Anchor programs reduce confidence by 0.30x (with discriminator validation by 0.30x, without by 0.50x — Anchor’s #[account(seeds = [...], bump)] stores and validates canonical bump). Read-only functions reduce by 0.40x.

Limitations

False positives:

  • Variables loaded from account data are broadly marked as validated — a program that stores a non-canonical bump and reads it back would not be flagged, even though the stored bump might be non-canonical.
  • Anchor’s seeds and bump constraints handle this automatically — Anchor programs receive reduced confidence.

False negatives:

  • Programs that validate the bump via a custom comparison function (not a simple Eq branch) may not have their bump recognized as validated.
  • Arbitrary CPI — detects unvalidated CPI targets that can exploit PDA confusion
  • Missing Owner Check — detects missing ownership validation related to PDA-derived accounts

References