Security of Solana Smart Contracts
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:
seedsprogram_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_addresscan return different PDAs each with a different bump seed -
Pubkey::find_program_addressreturns 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:
-
use
Pubkey::find_program_addressand check the result against the given bump seed. Abort if it doesn’t match. -
store the valid bump in an account, and use
Pubkey::create_program_addresswith 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=<stored_valid_bump>. For example, bump=multisig.nonce where nonce is the valid bump stored in the multisig account when the account is created:
#[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