12 KiB
Common Vulnerability Patterns
Detailed examples of common Solana smart contract vulnerabilities with exploit scenarios and secure alternatives.
1. Missing Signer Validation
Vulnerability
// ❌ VULNERABLE
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
// No check that caller is authorized!
let vault = &mut ctx.accounts.vault;
vault.balance -= amount;
Ok(())
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
pub user: AccountInfo<'info>, // Not a Signer!
}
Exploit Scenario
Attacker can drain the vault by calling withdraw with any account as the user parameter. No signature verification means anyone can execute the instruction.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
mut,
has_one = authority, // Ensures vault.authority == authority.key()
)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>, // Must sign transaction
}
2. Integer Overflow/Underflow
Vulnerability
// ❌ VULNERABLE
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.balance = vault.balance + amount; // Can overflow!
Ok(())
}
Exploit Scenario
If vault.balance = u64::MAX - 100 and amount = 200, the addition overflows and wraps to 99, effectively stealing funds from the vault.
Secure Alternative
// ✅ SECURE
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.balance = vault
.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
3. PDA Substitution Attack
Vulnerability
// ❌ VULNERABLE
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut)]
pub config: Account<'info, Config>, // No PDA validation!
#[account(
mut,
seeds = [b"vault", config.key().as_ref()], // Uses unvalidated config
bump
)]
pub vault: Account<'info, Vault>,
}
Exploit Scenario
Attacker creates a fake config account with malicious settings. The vault PDA is derived from this fake config, potentially accessing wrong vault or bypassing security checks.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(
seeds = [b"config"], // Global config PDA
bump,
)]
pub config: Account<'info, Config>,
#[account(
mut,
seeds = [b"vault", config.key().as_ref()],
bump
)]
pub vault: Account<'info, Vault>,
}
4. Type Cosplay Attack
Vulnerability
// ❌ VULNERABLE
#[account(mut)]
pub user: AccountLoader<'info, User>, // Doesn't check discriminator!
Exploit Scenario
Attacker passes a UserAdmin account instead of User. Since AccountLoader doesn't check discriminators by default, the program treats the admin account as a regular user, potentially bypassing privilege checks.
Secure Alternative
// ✅ SECURE
#[account(mut)]
pub user: Account<'info, User>, // Enforces correct discriminator
5. Account Reloading Issues
Vulnerability
// ❌ VULNERABLE
pub fn complex_operation(ctx: Context<ComplexOp>) -> Result<()> {
let initial_balance = ctx.accounts.vault.balance;
// CPI that modifies vault
transfer_tokens(&ctx)?;
// Still using stale balance!
require!(
ctx.accounts.vault.balance >= initial_balance,
ErrorCode::InvalidBalance
);
Ok(())
}
Exploit Scenario
The balance value is cached from before the CPI. If the CPI modified the vault, the check uses stale data, potentially allowing invalid state transitions.
Secure Alternative
// ✅ SECURE
pub fn complex_operation(ctx: Context<ComplexOp>) -> Result<()> {
transfer_tokens(&ctx)?;
// Reload account to get fresh data
ctx.accounts.vault.reload()?;
require!(
ctx.accounts.vault.balance >= expected_balance,
ErrorCode::InvalidBalance
);
Ok(())
}
6. Improper Account Closing
Vulnerability
// ❌ VULNERABLE
pub fn close_account(ctx: Context<CloseAccount>) -> Result<()> {
**ctx.accounts.vault.to_account_info().lamports.borrow_mut() = 0;
// Data not zeroed, authority not reset!
Ok(())
}
Exploit Scenario
Account data remains accessible within the same transaction even after lamports are zeroed. Attacker can read sensitive data or reuse the account in unexpected ways.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct CloseAccount<'info> {
#[account(
mut,
close = receiver // Properly closes: transfers lamports, zeros data
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub receiver: SystemAccount<'info>,
}
7. Missing Lamports Check
Vulnerability
// ❌ VULNERABLE
pub fn process(ctx: Context<Process>) -> Result<()> {
let data = ctx.accounts.user_data.load()?; // Can read closed account!
// ... use data
Ok(())
}
Exploit Scenario
Account was closed earlier in the transaction but data is still readable. Processing closed account data can lead to inconsistent state or bypass business logic.
Secure Alternative
// ✅ SECURE
pub fn process(ctx: Context<Process>) -> Result<()> {
require!(
**ctx.accounts.user_data.to_account_info().lamports.borrow() > 0,
ErrorCode::AccountClosed
);
let data = ctx.accounts.user_data.load()?;
// ... use data
Ok(())
}
8. Arbitrary CPI
Vulnerability
// ❌ VULNERABLE
#[derive(Accounts)]
pub struct ArbitraryCPI<'info> {
pub token_program: AccountInfo<'info>, // Not validated!
}
pub fn transfer(ctx: Context<ArbitraryCPI>) -> Result<()> {
invoke(
&transfer_instruction,
&[
ctx.accounts.token_program.clone(), // Could be malicious!
// ...
]
)?;
Ok(())
}
Exploit Scenario
Attacker passes malicious program instead of real Token program. Malicious program can emit fake events, return success without transferring, or drain funds.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct SecureCPI<'info> {
pub token_program: Program<'info, Token>, // Type-checked!
}
// Or manual validation
require_keys_eq!(
*ctx.accounts.token_program.key,
spl_token::ID,
ErrorCode::InvalidTokenProgram
);
9. Duplicate Mutable Accounts
Vulnerability
// ❌ VULNERABLE
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(mut)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
}
pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
ctx.accounts.from.amount -= amount;
ctx.accounts.to.amount += amount; // Same account = double amount!
Ok(())
}
Exploit Scenario
If from and to are the same account, the user can double their balance by transferring to themselves.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(
mut,
constraint = from.key() != to.key() @ ErrorCode::SameAccount
)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
}
10. Bump Seed Canonicalization
Vulnerability
// ❌ VULNERABLE
pub fn init_vault(ctx: Context<InitVault>, bump: u8) -> Result<()> {
// Accepts any bump from user!
let seeds = &[b"vault", user.key().as_ref(), &[bump]];
// Multiple PDAs possible for same seeds!
}
Exploit Scenario
Attacker can create multiple vault PDAs with different bumps for the same user, fragmenting state or confusing off-chain systems.
Secure Alternative
// ✅ SECURE
#[derive(Accounts)]
pub struct InitVault<'info> {
#[account(
init,
payer = user,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault", user.key().as_ref()],
bump // Anchor derives and stores canonical bump automatically
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
11. Missing Owner Check
Vulnerability
// ❌ VULNERABLE
pub fn read_data(ctx: Context<ReadData>) -> Result<()> {
let oracle_data = ctx.accounts.oracle.try_borrow_data()?;
// No check that oracle is owned by Pyth program!
let price = parse_price(&oracle_data)?;
Ok(())
}
Exploit Scenario
Attacker creates fake oracle account owned by their own program, filled with manipulated price data. Program trusts the fake data.
Secure Alternative
// ✅ SECURE
pub fn read_data(ctx: Context<ReadData>) -> Result<()> {
require_keys_eq!(
*ctx.accounts.oracle.owner,
PYTH_PROGRAM_ID,
ErrorCode::InvalidOracleOwner
);
let oracle_data = ctx.accounts.oracle.try_borrow_data()?;
let price = parse_price(&oracle_data)?;
Ok(())
}
12. Precision Loss / Rounding Errors
Vulnerability
// ❌ VULNERABLE
pub fn calculate_shares(collateral: u64, rate: Decimal) -> Result<u64> {
Decimal::from(collateral)
.try_div(rate)?
.try_round_u64() // Rounding can be exploited!
}
Exploit Scenario
Attacker repeatedly deposits/withdraws small amounts. Rounding up gives slightly more shares each time, slowly draining the pool.
Secure Alternative
// ✅ SECURE
pub fn calculate_shares(collateral: u64, rate: Decimal) -> Result<u64> {
Decimal::from(collateral)
.try_div(rate)?
.try_floor_u64() // Always round down in user's favor
}
13. Unchecked Error Returns
Vulnerability
// ❌ VULNERABLE
spl_token::instruction::transfer(
token_program.key,
source.key,
destination.key,
authority.key,
&[],
amount,
); // Return value ignored!
Exploit Scenario
Transfer instruction fails silently but program continues as if it succeeded. State becomes inconsistent with actual token balances.
Secure Alternative
// ✅ SECURE
invoke(
&spl_token::instruction::transfer(
token_program.key,
source.key,
destination.key,
authority.key,
&[],
amount,
)?, // Propagates error
&[
source.clone(),
destination.clone(),
authority.clone(),
],
)?;
// Or use Anchor's CPI helpers
token::transfer(ctx, amount)?;
14. Init If Needed Vulnerability
Vulnerability
// ❌ VULNERABLE
#[account(
init_if_needed,
payer = user,
space = 8 + Account::INIT_SPACE
)]
pub user_account: Account<'info, UserAccount>,
Exploit Scenario
If account already exists, initialization is skipped but existing data might be inconsistent or malicious. Can bypass initialization checks.
Secure Alternative
// ✅ SECURE - Explicit initialization
#[account(
init,
payer = user,
space = 8 + Account::INIT_SPACE
)]
pub user_account: Account<'info, UserAccount>,
// Or if init_if_needed is truly needed, add validation
pub fn init_or_validate(ctx: Context<InitAccount>) -> Result<()> {
if ctx.accounts.user_account.is_initialized {
// Validate existing data
require!(
ctx.accounts.user_account.owner == ctx.accounts.user.key(),
ErrorCode::InvalidOwner
);
} else {
// Initialize new account
ctx.accounts.user_account.is_initialized = true;
ctx.accounts.user_account.owner = ctx.accounts.user.key();
}
Ok(())
}
15. Stale Oracle Data
Vulnerability
// ❌ VULNERABLE
pub fn get_price(pyth_account: &AccountInfo) -> Result<i64> {
let price_feed = load_price_feed(pyth_account)?;
Ok(price_feed.agg.price) // No staleness check!
}
Exploit Scenario
Oracle stopped updating hours ago due to network issues. Attacker exploits stale price to buy/sell at favorable outdated rates.
Secure Alternative
// ✅ SECURE
pub fn get_price(
pyth_account: &AccountInfo,
clock: &Clock
) -> Result<i64> {
let price_feed = load_price_feed(pyth_account)?;
// Check publishing time
let max_age_seconds = 60;
require!(
clock.unix_timestamp - price_feed.agg.publish_time <= max_age_seconds,
ErrorCode::StaleOraclePrice
);
// Check status
require!(
price_feed.agg.status == PriceStatus::Trading,
ErrorCode::OracleNotTrading
);
Ok(price_feed.agg.price)
}