Sec3 logo — Solana smart contract security firm
Back to Blog
Security

Security of Solana Smart Contracts

Sec3 Research Team

Program derived addresses (PDAs) are used in virtually all Solana smart contracts. While excellent resources (e.g., the Solana Cookbook) exist for understanding PDAs, there is one important caveat deserving special attention:

> PDAs of a Solana program can have the same seeds with multiple valid bumps, giving multiple different addresses (quote from tweet by @armaniferrante)

This has a crucial security implication: PDAs can be faked (by providing different bump seeds) if their bump seeds are not validated.

In other words, please note the following:

  • PDA bump seeds are not unique; A seed can have multiple bumps.
  • The same seed and different valid bumps can generate different valid PDAs.
  • When using a PDA you should always validate its bump seed.

Here is an example:

let program_id: Pubkey = Pubkey::from_str("JPv1rCqrhagNNmJVM5J1he7msQ5ybtvE1nNuHpDHMNU").unwrap();
let (pda1: Pubkey, bump1: u8) = Pubkey::find_program_address(seeds: &[b"sec3_dev"], &program_id);
println!("[Find Bump] bump = {}, pda_find_program = {}", bump1, pda1);
// valid bumps: 254, 249, 246, ...
let pda_valid_1: Pubkey = Pubkey::create_program_address(seeds: &[b"sec3_dev", &[254_u8]], &program_id).unwrap();
println!("[Verify Bump] bump = 254, pda_valid_1: {}", pda_valid_1);
let pda_valid_2: Pubkey = Pubkey::create_program_address(seeds: &[b"sec3_dev", &[249_u8]], &program_id).unwrap();
println!("[Verify Bump] bump = 251, pda_valid_2: {}", pda_valid_2);
let pda_valid_3: Pubkey = Pubkey::create_program_address(seeds: &[b"sec3_dev", &[246_u8]], &program_id).unwrap();
println!("[Verify Bump] bump = 250, pda_valid_3: {}", pda_valid_3);

Example: same seeds, same program_id, three different bumps and PDAs

The output:

[Find Bump] bump = 254, pda_find_program = FY1pv9DiGtWDp7KqTtJsGBZNqY6uEHcMh3Tpe8HYbkjM
[Verify Bump] bump = 254, pda_valid_1: FY1pv9DiGtWDp7KqTtJsGBZNqY6uEHcMh3Tpe8HYbkjM
[Verify Bump] bump = 251, pda_valid_2: 6g93mruM6vTghbeuwJHQZ9NDzHAPoA9UDFJZ6DvgiWNQ
[Verify Bump] bump = 250, pda_valid_3: 7ygg3hmjXhcfvZd5KaNn3LZvisNVn3BXB7jimxN3bfrd

Why PDA Bump Seeds Are Not Unique?

Let’s first understand how PDAs are computed. The source code of Pubkey::find_program_address is shown below:

see find_program_address source code in Solana Github repo

pub fn find_program_address(seeds: &[&[u8]], program_id: &Pubkey) -> (Pubkey, u8) {
    Self::try_find_program_address(seeds, program_id)
        .unwrap_or_else(|| panic!("Unable to find a viable program address bump seed"))
}

see find_program_address source code in Solana Github repo

It takes two user-supplied inputs:

  • seeds
  • program_id

and it returns

  • a PDA (of type Pubkey)
  • a corresponding bump (of type u8)

Specifically, it calls try_find_program_address, in which bump_seed is introduced:

Note: for target_os = “solana” a system call is used instead to compute try_find_program_address

pub fn try_find_program_address(seeds: &[&[u8]], program_id: &Pubkey) -> Option<(Pubkey, u8)> {
    // Perform the calculation inline, calling this from within a program is
    // not supported
    #[cfg(not(target_os = “solana”))]
    {
        let mut bump_seed: [u8; 1] = [std::u8::MAX];
        for _ in 0..std::u8::MAX {
            {
                let mut seeds_with_bump: Vec<&[u8]> = seeds.to_vec();
                seeds_with_bump.push(&bump_seed);
                match Self::create_program_address(&seeds_with_bump, program_id) {
                    Ok(address: Pubkey) => return Some((address, bump_seed[0])),
                    Err(PubkeyError::InvalidSeeds) => (),
                    _ => break,
                }
            }
            bump_seed[0] -= 1;
        }
        None

Note: for target_os = “solana” a system call is used instead to compute try_find_program_address

In the above, line 479, bump_seed is a u8 variable with a value ranging between 0 to 255 (std::u8::MAX).

On lines 480–491, in a loop from 255 to 0, bump_seed is appended to the input seeds to call Pubkey::create_program_address (line 484):

pub fn create_program_address(
    seeds: &[&[u8]],
    program_id: &Pubkey,
) -> Result<Pubkey, PubkeyError> {

Pubkey::create_program_address either returns a valid PDA or an error

Pubkey::create_program_address either returns a valid PDA or an error

In create_program_address, it basically performs a set of hash operations over the input seeds and program_id to compute a key (line 579 below):

        let mut hasher: Hasher = crate::hash::Hasher::default();
        for seed: &&[u8] in seeds.iter() {
            hasher.hash(val: seed);
        }
        hasher.hashv(vals: &[program_id.as_ref(), PDA_MARKER]);
        let hash: Hash = hasher.result();

        if bytes_are_curve_point(_bytes: hash) {
            return Err(PubkeyError::InvalidSeeds);
        }

        Ok(Pubkey::new(pubkey_vec: hash.as_ref()))

It then verifies if the computed key is a valid PDA or not, i.e., by checking if the key lies on the ed25519 elliptic curve or not (line 581 bytes_are_curve_point)

If the computed key lies on the ed25519 elliptic curve, then it is a valid public key, thus not a valid PDA and an error will be returned (line 582).

(Assuming inputs are random) There is a ~50% chance that the computed key lies on the curve, so create_program_address will return error and the loop in try_find_program_address will continue with a new bump_seed.

When create_program_address returns a valid PDA, the corresponding bump_seed in try_find_program_address will also be returned.

Now, it should be clear that given the same seeds and program_id:

  • Pubkey::create_program_address can return different PDAs each with a different bump seed

  • Pubkey::find_program_address returns the PDA with the largest valid bump seed

How Attackers May Exploit Unvalidated PDAs?

An attacker may create a fake PDA with the same seeds and program_id but a different bump seed from the intended PDA.

Consider an example below:

A sample vulnerable PDA

    /// The account that stores the deposit notes
    #[account(mut,
              seeds = [
                  b"deposits".as_ref(),
                  reserve.key().as_ref(),
              ],
              bump = bump)]
    pub deposit_account: AccountInfo<'info>,

A sample vulnerable PDA

The deposit_account is a PDA that may be faked because its bump seed could be arbitrary, i.e., validated with an external data (bump).

If the faked PDA can pass an instruction’s internal checks, then it could be used to camouflage the intended PDA, e.g., transfer funds based on the fake PDA’s state rather than the intended state.

How Does sec3 Pro Detect the Vulnerability?

Sec3 Pro is the premier security analysis service for Solana smart contracts.

To detect such unvalidated PDA vulnerabilities. The tool first locates all the user-supplied PDAs in the smart smart including those declared with Anchor macros. The tool then verifies if the bump seed of every PDA is properly validated. E.g., with an equality constraint relating the bump seed to a bump returned by Pubkey::find_program_address.

If any PDA bump seed is not validated or it can be controlled by an external input, then the tool flags a potential vulnerability. The following shows a screenshot of BumpSeedNotValidated issue reported by sec3 Pro in the previous sample code. sec3 Pro is available at https://pro.sec3.dev.

===============VULNERABLE: BumpSeedNotValidated!===============
Found a potential vulnerability at line 65, column 8 in src/instructions/withdraw.rs
The account's bump seed is not validated and may be vulnerable to seed canonicalization attacks:
 59|    #[account(mut,
 60|              seeds = [
 61|                  b"deposits".as_ref(),
 62|                  reserve.key().as_ref(),
 63|              ],
 64|              bump = bump)]
>65|    pub deposit_account: AccountInfo<'info>,

How to Validate PDA Bump Seeds?

There are two common ways to validate PDA bump seeds:

  1. use Pubkey::find_program_address and check the result against the given bump seed. Abort if it doesn’t match.

  2. store the valid bump in an account, and use Pubkey::create_program_address with the stored bump

Approach 1. validate bump seed returned from find_program_address

let vault_seeds = &[b"vault", payer.key.as_ref(), &[vault_bump_seed]];
let expected_vault_pda = Pubkey::create_program_address(vault_seeds, program_id)?;

assert_eq!(vault_pda.key, &expected_vault_pda);

Approach 2. validate bump seed using create_program_address

        let (address, expected_bump) =
            Pubkey::find_program_address(&[key.to_le_bytes().as_ref()], ctx.program_id);

        if address != ctx.accounts.data.key() {
            return Err(ProgramError::InvalidArgument);
        }
        if expected_bump != bump {
            return Err(ProgramError::InvalidArgument);

Approach 1. validate bump seed returned from find_program_address

Approach 2. validate bump seed using create_program_address

For Approach 2, in Anchor, use bump=&lt;stored_valid_bump&gt;. For example, bump=multisig.nonce where nonce is the valid bump stored in the multisig account when the account is created:

Multisig sample source code


#[derive(Accounts)]
pub struct Auth<'info> {
    #[account(mut)]
    multisig: Box<Account<'info, Multisig>>,
    #[account(
        seeds = [multisig.key().as_ref()],
        bump = multisig.nonce,
    )]
    multisig_signer: Signer<'info>,

Multisig sample source code

About sec3 (Formerly Soteria)

sec3 is a security research firm that prepares Solana projects for millions of users. sec3’s Launch Audit is a rigorous, researcher-led code examination that investigates and certifies mainnet-grade smart contracts; sec3’s continuous auditing software platform, X-ray, integrates with GitHub to progressively scan pull requests, helping projects fortify code before deployment; and sec3’s post-deployment security solution, WatchTower, ensures funds stay safe. sec3 is building technology-based scalable solutions for Web3 projects to ensure protocols stay safe as they scale.

To learn more about sec3, please visit https://www.sec3.dev

Related Posts

Security

IDL Guesser

The Solana ecosystem thrives on innovation, but many Anchor-based programs do not publish up-to-date IDLs, which complicates the analysis of such programs and their transactions. To tackle this, we developed and open-sourced a prototype tool called IDL Guesser. This tool aims to automatically recover instruction definitions, required accounts (including signer/writable flags), and parameter information directly from closed-source Solana program binaries. This blog outlines the approach behind IDL Guesser and discusses potential areas for future improvement.

Read more
Security

All About Anchor Account Size

Smart contracts using Anchor require developers to allocate space for new accounts and specify the account size. Anchor provides guidelines for calculating the size based on the account structure, but many developers use std::mem::size_of instead, as they don't have to manually update the size when making changes to the account structure. Are they equivalent? In this blog post, we conduct a systematic comparison of the results produced by std::mem::size_of and the Anchor space reference.

Read more