1135 lines
27 KiB
Markdown
1135 lines
27 KiB
Markdown
# Solana Program Security & Validation
|
|
|
|
This reference provides comprehensive security guidance for native Rust Solana program development, covering validation patterns, common vulnerabilities, and defensive programming practices.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Security Mindset](#security-mindset)
|
|
2. [Core Validation Patterns](#core-validation-patterns)
|
|
3. [Common Vulnerabilities](#common-vulnerabilities)
|
|
4. [Input Validation](#input-validation)
|
|
5. [State Management Security](#state-management-security)
|
|
6. [Arithmetic Safety](#arithmetic-safety)
|
|
7. [Re-entrancy Protection](#re-entrancy-protection)
|
|
8. [Security Checklist](#security-checklist)
|
|
|
|
---
|
|
|
|
## Security Mindset
|
|
|
|
### Think Like an Attacker
|
|
|
|
**The fundamental principle of secure programming: ask "How do I break this?"**
|
|
|
|
Presented at Breakpoint 2021 by [Neodyme](https://workshop.neodyme.io/), this mindset shift is critical:
|
|
|
|
- **Don't just test expected functionality** - explore how it can be broken
|
|
- **All programs can be exploited** - the goal is to make it as difficult as possible
|
|
- **You control nothing** - once deployed, you can't control what transactions are sent
|
|
- **Assume malicious input** - every account, every parameter, every edge case
|
|
|
|
### The Harsh Reality
|
|
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Your Program (Deployed) │
|
|
├─────────────────────────────────────────┤
|
|
│ • No control over incoming transactions │
|
|
│ • No control over accounts passed in │
|
|
│ • No control over instruction data │
|
|
│ • No control over timing │
|
|
└─────────────────────────────────────────┘
|
|
▲ ▲ ▲
|
|
│ │ │
|
|
Legitimate Malicious Buggy
|
|
User Attacker Client
|
|
```
|
|
|
|
**Your only control:** How your program handles inputs.
|
|
|
|
### Security is Not Optional
|
|
|
|
**Example Impact:**
|
|
|
|
Without proper validation, a simple "update note" function becomes:
|
|
- ❌ Anyone can update anyone's notes
|
|
- ❌ Drain program funds
|
|
- ❌ Corrupt global state
|
|
- ❌ Brick the entire program
|
|
|
|
**With validation:**
|
|
- ✅ Only note author can update
|
|
- ✅ Funds are protected
|
|
- ✅ State remains consistent
|
|
- ✅ Program operates as intended
|
|
|
|
---
|
|
|
|
## Core Validation Patterns
|
|
|
|
### 1. Signer Checks
|
|
|
|
**Purpose:** Verify that an account signed the transaction, authorizing the operation.
|
|
|
|
**When Required:**
|
|
- Transferring funds from an account
|
|
- Modifying user-specific data
|
|
- Any privileged operation
|
|
|
|
**Pattern:**
|
|
|
|
```rust
|
|
use solana_program::{
|
|
account_info::AccountInfo,
|
|
entrypoint::ProgramResult,
|
|
program_error::ProgramError,
|
|
msg,
|
|
};
|
|
|
|
pub fn check_signer(account: &AccountInfo) -> ProgramResult {
|
|
if !account.is_signer {
|
|
msg!("Missing required signature");
|
|
return Err(ProgramError::MissingRequiredSignature);
|
|
}
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Real-World Example:**
|
|
|
|
```rust
|
|
pub fn update_user_profile(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
new_name: String,
|
|
) -> ProgramResult {
|
|
let account_info_iter = &mut accounts.iter();
|
|
let user = next_account_info(account_info_iter)?;
|
|
let profile_pda = next_account_info(account_info_iter)?;
|
|
|
|
// CRITICAL: Verify user signed the transaction
|
|
if !user.is_signer {
|
|
msg!("User must sign to update profile");
|
|
return Err(ProgramError::MissingRequiredSignature);
|
|
}
|
|
|
|
// Validate PDA belongs to this user
|
|
let (expected_pda, _) = Pubkey::find_program_address(
|
|
&[b"profile", user.key.as_ref()],
|
|
program_id,
|
|
);
|
|
|
|
if expected_pda != *profile_pda.key {
|
|
msg!("Profile PDA doesn't match user");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
|
|
// Safe to update
|
|
let mut profile = UserProfile::try_from_slice(&profile_pda.data.borrow())?;
|
|
profile.name = new_name;
|
|
profile.serialize(&mut &mut profile_pda.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 2. Ownership Checks
|
|
|
|
**Purpose:** Verify an account is owned by the expected program.
|
|
|
|
**When Required:**
|
|
- Before reading/writing account data
|
|
- When validating PDAs
|
|
- Before performing any account-specific operations
|
|
|
|
**Pattern:**
|
|
|
|
```rust
|
|
pub fn check_ownership(
|
|
account: &AccountInfo,
|
|
expected_owner: &Pubkey,
|
|
) -> ProgramResult {
|
|
if account.owner != expected_owner {
|
|
msg!("Account owner mismatch");
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Common Use Cases:**
|
|
|
|
```rust
|
|
// 1. Verify program owns its PDA
|
|
if note_pda.owner != program_id {
|
|
msg!("Note account not owned by this program");
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
|
|
// 2. Verify account owned by System Program (user wallet)
|
|
use solana_program::system_program;
|
|
|
|
if wallet.owner != &system_program::ID {
|
|
msg!("Expected a system account (wallet)");
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
|
|
// 3. Verify account owned by Token Program
|
|
use spl_token::ID as TOKEN_PROGRAM_ID;
|
|
|
|
if token_account.owner != &TOKEN_PROGRAM_ID {
|
|
msg!("Expected a token account");
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
```
|
|
|
|
### 3. PDA Validation
|
|
|
|
**Purpose:** Ensure a provided PDA matches the expected derivation.
|
|
|
|
**Critical for Security:** Multiple bumps can derive different PDAs. Always use canonical bump.
|
|
|
|
**Pattern:**
|
|
|
|
```rust
|
|
pub fn validate_pda(
|
|
pda_account: &AccountInfo,
|
|
seeds: &[&[u8]],
|
|
program_id: &Pubkey,
|
|
) -> Result<u8, ProgramError> {
|
|
// Derive expected PDA with canonical bump
|
|
let (expected_pda, bump_seed) = Pubkey::find_program_address(seeds, program_id);
|
|
|
|
// Validate match
|
|
if expected_pda != *pda_account.key {
|
|
msg!("Invalid PDA derivation");
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
|
|
Ok(bump_seed)
|
|
}
|
|
```
|
|
|
|
**Complete Validation:**
|
|
|
|
```rust
|
|
pub fn validate_user_vault(
|
|
program_id: &Pubkey,
|
|
user: &AccountInfo,
|
|
vault_pda: &AccountInfo,
|
|
) -> ProgramResult {
|
|
// 1. Derive expected PDA
|
|
let (expected_pda, _bump) = Pubkey::find_program_address(
|
|
&[b"vault", user.key.as_ref()],
|
|
program_id,
|
|
);
|
|
|
|
// 2. Validate address match
|
|
if expected_pda != *vault_pda.key {
|
|
msg!("Vault PDA seeds don't match");
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
|
|
// 3. Validate ownership
|
|
if vault_pda.owner != program_id {
|
|
msg!("Vault not owned by program");
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
|
|
// 4. Validate initialization
|
|
let vault_data = VaultAccount::try_from_slice(&vault_pda.data.borrow())?;
|
|
if !vault_data.is_initialized {
|
|
msg!("Vault not initialized");
|
|
return Err(ProgramError::UninitializedAccount);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 4. Initialization Checks
|
|
|
|
**Purpose:** Prevent re-initialization or use of uninitialized accounts.
|
|
|
|
**Pattern: Discriminator Field**
|
|
|
|
```rust
|
|
#[derive(BorshSerialize, BorshDeserialize)]
|
|
pub struct AccountData {
|
|
pub is_initialized: bool,
|
|
// ... other fields
|
|
}
|
|
|
|
// On creation - ensure NOT initialized
|
|
if account_data.is_initialized {
|
|
msg!("Account already initialized");
|
|
return Err(ProgramError::AccountAlreadyInitialized);
|
|
}
|
|
|
|
account_data.is_initialized = true;
|
|
|
|
// On update - ensure IS initialized
|
|
if !account_data.is_initialized {
|
|
msg!("Account not initialized");
|
|
return Err(ProgramError::UninitializedAccount);
|
|
}
|
|
```
|
|
|
|
**Advanced: Enum Discriminator**
|
|
|
|
```rust
|
|
#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
|
|
pub enum AccountState {
|
|
Uninitialized,
|
|
Initialized,
|
|
Frozen,
|
|
Closed,
|
|
}
|
|
|
|
#[derive(BorshSerialize, BorshDeserialize)]
|
|
pub struct GameAccount {
|
|
pub state: AccountState,
|
|
pub player: Pubkey,
|
|
pub score: u64,
|
|
}
|
|
|
|
// Validation
|
|
let account = GameAccount::try_from_slice(&account_info.data.borrow())?;
|
|
|
|
match account.state {
|
|
AccountState::Uninitialized => {
|
|
msg!("Account not initialized");
|
|
return Err(ProgramError::UninitializedAccount);
|
|
}
|
|
AccountState::Frozen => {
|
|
msg!("Account is frozen");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
AccountState::Closed => {
|
|
msg!("Account is closed");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
AccountState::Initialized => {
|
|
// Proceed
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Account Type Validation
|
|
|
|
**Purpose:** Ensure account contains the expected data structure.
|
|
|
|
**Pattern: Type Discriminator**
|
|
|
|
```rust
|
|
#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
|
|
#[repr(u8)]
|
|
pub enum AccountType {
|
|
Uninitialized = 0,
|
|
UserProfile = 1,
|
|
GameState = 2,
|
|
Leaderboard = 3,
|
|
}
|
|
|
|
#[derive(BorshSerialize, BorshDeserialize)]
|
|
pub struct GenericAccount {
|
|
pub account_type: AccountType,
|
|
// ... rest of data varies by type
|
|
}
|
|
|
|
// Validation
|
|
pub fn validate_account_type(
|
|
account_info: &AccountInfo,
|
|
expected_type: AccountType,
|
|
) -> ProgramResult {
|
|
let account = GenericAccount::try_from_slice(&account_info.data.borrow())?;
|
|
|
|
if account.account_type != expected_type {
|
|
msg!("Unexpected account type");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 6. Writable Validation
|
|
|
|
**Purpose:** Ensure accounts that need modification are marked writable.
|
|
|
|
**Pattern:**
|
|
|
|
```rust
|
|
pub fn check_writable(account: &AccountInfo) -> ProgramResult {
|
|
if !account.is_writable {
|
|
msg!("Account must be writable");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Note:** Runtime enforces this, but explicit checks improve clarity and error messages.
|
|
|
|
---
|
|
|
|
## Common Vulnerabilities
|
|
|
|
### 1. Missing Signer Check
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - no signer check
|
|
pub fn withdraw_funds(
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let vault = &accounts[1];
|
|
|
|
// Anyone can call this to withdraw anyone's funds!
|
|
**user.lamports.borrow_mut() += amount;
|
|
**vault.lamports.borrow_mut() -= amount;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Exploit:**
|
|
```
|
|
Attacker creates transaction:
|
|
- Passes victim's account as user
|
|
- Drains vault to victim's account
|
|
- Profits by intercepting the transaction or social engineering
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
// ✅ SECURE - with signer check
|
|
pub fn withdraw_funds(
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let vault = &accounts[1];
|
|
|
|
if !user.is_signer {
|
|
return Err(ProgramError::MissingRequiredSignature);
|
|
}
|
|
|
|
**user.lamports.borrow_mut() += amount;
|
|
**vault.lamports.borrow_mut() -= amount;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 2. Missing Ownership Check
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - no ownership check
|
|
pub fn update_score(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
new_score: u64,
|
|
) -> ProgramResult {
|
|
let player_account = &accounts[0];
|
|
|
|
// Could be ANY account with matching data structure!
|
|
let mut player = PlayerData::try_from_slice(&player_account.data.borrow())?;
|
|
player.score = new_score;
|
|
player.serialize(&mut &mut player_account.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Exploit:**
|
|
```
|
|
Attacker creates a fake account:
|
|
- Owned by attacker's program
|
|
- Has same data structure
|
|
- Passes it to victim program
|
|
- Victim program modifies attacker's account!
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
// ✅ SECURE - with ownership check
|
|
pub fn update_score(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
new_score: u64,
|
|
) -> ProgramResult {
|
|
let player_account = &accounts[0];
|
|
|
|
// Verify ownership
|
|
if player_account.owner != program_id {
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
|
|
let mut player = PlayerData::try_from_slice(&player_account.data.borrow())?;
|
|
player.score = new_score;
|
|
player.serialize(&mut &mut player_account.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 3. PDA Substitution Attack
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - accepts any PDA
|
|
pub fn claim_reward(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let reward_pda = &accounts[1];
|
|
|
|
// No PDA validation!
|
|
let mut reward = RewardData::try_from_slice(&reward_pda.data.borrow())?;
|
|
reward.claimed = true;
|
|
reward.serialize(&mut &mut reward_pda.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Exploit:**
|
|
```
|
|
Attacker passes someone else's reward PDA:
|
|
- Creates transaction with victim's reward PDA
|
|
- Claims victim's rewards
|
|
- Victim loses rewards
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
// ✅ SECURE - validates PDA derivation
|
|
pub fn claim_reward(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let reward_pda = &accounts[1];
|
|
|
|
// Validate PDA belongs to this user
|
|
let (expected_pda, _) = Pubkey::find_program_address(
|
|
&[b"reward", user.key.as_ref()],
|
|
program_id,
|
|
);
|
|
|
|
if expected_pda != *reward_pda.key {
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
|
|
let mut reward = RewardData::try_from_slice(&reward_pda.data.borrow())?;
|
|
reward.claimed = true;
|
|
reward.serialize(&mut &mut reward_pda.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### 4. Non-Canonical Bump
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - accepts user-provided bump
|
|
pub fn update_data(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
bump: u8, // User provides bump!
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let data_pda = &accounts[1];
|
|
|
|
// Uses user's bump - could derive DIFFERENT PDA!
|
|
let derived_pda = Pubkey::create_program_address(
|
|
&[b"data", user.key.as_ref(), &[bump]],
|
|
program_id,
|
|
)?;
|
|
|
|
if derived_pda != *data_pda.key {
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
|
|
// Proceeds with potentially wrong PDA
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Exploit:**
|
|
```
|
|
Multiple bumps derive different valid PDAs:
|
|
- Canonical bump 254: User A's PDA
|
|
- Bump 253: User B's PDA (also valid!)
|
|
- Attacker uses bump 253 to access User B's data
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
// ✅ SECURE - uses canonical bump only
|
|
pub fn update_data(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let data_pda = &accounts[1];
|
|
|
|
// Always use find_program_address (canonical bump)
|
|
let (expected_pda, _bump) = Pubkey::find_program_address(
|
|
&[b"data", user.key.as_ref()],
|
|
program_id,
|
|
);
|
|
|
|
if expected_pda != *data_pda.key {
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
|
|
// Safe - validated with canonical bump
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 5. Type Cosplay Attack
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - assumes account type
|
|
pub fn admin_withdraw(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let admin_config = &accounts[0];
|
|
|
|
// No type validation!
|
|
let config = AdminConfig::try_from_slice(&admin_config.data.borrow())?;
|
|
|
|
// Proceeds assuming it's actually an AdminConfig
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Exploit:**
|
|
```
|
|
Attacker creates fake account:
|
|
- UserProfile with same memory layout as AdminConfig
|
|
- First field happens to match admin pubkey format
|
|
- Deserializes successfully as AdminConfig
|
|
- Attacker gains admin privileges!
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
#[derive(BorshSerialize, BorshDeserialize)]
|
|
pub struct AdminConfig {
|
|
pub discriminator: [u8; 8], // Type identifier
|
|
pub admin: Pubkey,
|
|
// ... other fields
|
|
}
|
|
|
|
const ADMIN_CONFIG_DISCRIMINATOR: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
|
|
|
|
// ✅ SECURE - validates type
|
|
pub fn admin_withdraw(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let admin_config = &accounts[0];
|
|
|
|
let config = AdminConfig::try_from_slice(&admin_config.data.borrow())?;
|
|
|
|
// Validate discriminator
|
|
if config.discriminator != ADMIN_CONFIG_DISCRIMINATOR {
|
|
msg!("Invalid account type");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
|
|
// Safe - type validated
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 6. Uninitialized Account Reuse
|
|
|
|
**Vulnerability:**
|
|
|
|
```rust
|
|
// ❌ VULNERABLE - no initialization check
|
|
pub fn update_balance(
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let balance_account = &accounts[0];
|
|
|
|
let mut balance = BalanceData::try_from_slice(&balance_account.data.borrow())?;
|
|
|
|
// What if this account was never initialized?
|
|
// Default values could lead to undefined behavior
|
|
balance.amount += amount;
|
|
|
|
balance.serialize(&mut &mut balance_account.data.borrow_mut()[..])?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Fix:**
|
|
|
|
```rust
|
|
// ✅ SECURE - checks initialization
|
|
pub fn update_balance(
|
|
accounts: &[AccountInfo],
|
|
amount: u64,
|
|
) -> ProgramResult {
|
|
let balance_account = &accounts[0];
|
|
|
|
let mut balance = BalanceData::try_from_slice(&balance_account.data.borrow())?;
|
|
|
|
if !balance.is_initialized {
|
|
msg!("Account not initialized");
|
|
return Err(ProgramError::UninitializedAccount);
|
|
}
|
|
|
|
balance.amount += amount;
|
|
balance.serialize(&mut &mut balance_account.data.borrow_mut()[..])?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Input Validation
|
|
|
|
### Validate All Input Data
|
|
|
|
**Never trust instruction data.** Always validate constraints.
|
|
|
|
```rust
|
|
pub fn allocate_stat_points(
|
|
accounts: &[AccountInfo],
|
|
strength: u8,
|
|
agility: u8,
|
|
intelligence: u8,
|
|
) -> ProgramResult {
|
|
let character_account = &accounts[0];
|
|
let mut character = Character::try_from_slice(&character_account.data.borrow())?;
|
|
|
|
// 1. Validate individual stat caps
|
|
let new_strength = character.strength.checked_add(strength)
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
|
|
if new_strength > 100 {
|
|
msg!("Strength cannot exceed 100");
|
|
return Err(ProgramError::InvalidArgument);
|
|
}
|
|
|
|
// 2. Validate total points spent
|
|
let total_spent = (strength as u64)
|
|
.checked_add(agility as u64)
|
|
.and_then(|sum| sum.checked_add(intelligence as u64))
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
|
|
if total_spent > character.available_points {
|
|
msg!("Insufficient available points");
|
|
return Err(ProgramError::InsufficientFunds);
|
|
}
|
|
|
|
// 3. Safe to apply
|
|
character.strength = new_strength;
|
|
character.agility += agility;
|
|
character.intelligence += intelligence;
|
|
character.available_points -= total_spent;
|
|
|
|
character.serialize(&mut &mut character_account.data.borrow_mut()[..])?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
### String Length Validation
|
|
|
|
```rust
|
|
pub fn set_username(
|
|
accounts: &[AccountInfo],
|
|
username: String,
|
|
) -> ProgramResult {
|
|
// Validate length
|
|
if username.len() < 3 {
|
|
msg!("Username too short (min 3 characters)");
|
|
return Err(ProgramError::InvalidArgument);
|
|
}
|
|
|
|
if username.len() > 20 {
|
|
msg!("Username too long (max 20 characters)");
|
|
return Err(ProgramError::InvalidArgument);
|
|
}
|
|
|
|
// Validate characters (alphanumeric only)
|
|
if !username.chars().all(|c| c.is_alphanumeric()) {
|
|
msg!("Username must be alphanumeric");
|
|
return Err(ProgramError::InvalidArgument);
|
|
}
|
|
|
|
// Safe to use
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Enum Validation
|
|
|
|
```rust
|
|
#[derive(BorshDeserialize)]
|
|
#[repr(u8)]
|
|
pub enum Rarity {
|
|
Common = 0,
|
|
Uncommon = 1,
|
|
Rare = 2,
|
|
Epic = 3,
|
|
Legendary = 4,
|
|
}
|
|
|
|
pub fn create_item(
|
|
accounts: &[AccountInfo],
|
|
rarity_value: u8,
|
|
) -> ProgramResult {
|
|
// Validate enum range
|
|
if rarity_value > 4 {
|
|
msg!("Invalid rarity value");
|
|
return Err(ProgramError::InvalidArgument);
|
|
}
|
|
|
|
let rarity: Rarity = unsafe {
|
|
std::mem::transmute(rarity_value)
|
|
};
|
|
|
|
// Safe to use
|
|
// ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## State Management Security
|
|
|
|
### Avoid Race Conditions
|
|
|
|
**Problem:** Multiple transactions modifying shared state.
|
|
|
|
**Solution:** Use account-level locking and atomic operations.
|
|
|
|
```rust
|
|
pub fn claim_limited_reward(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let user = &accounts[0];
|
|
let global_pool = &accounts[1];
|
|
let user_claim = &accounts[2];
|
|
|
|
// Load global state
|
|
let mut pool = RewardPool::try_from_slice(&global_pool.data.borrow())?;
|
|
|
|
// Check availability
|
|
if pool.claimed >= pool.total_rewards {
|
|
msg!("No rewards remaining");
|
|
return Err(ProgramError::InsufficientFunds);
|
|
}
|
|
|
|
// Check user hasn't claimed
|
|
let mut claim = UserClaim::try_from_slice(&user_claim.data.borrow())?;
|
|
if claim.has_claimed {
|
|
msg!("User already claimed");
|
|
return Err(ProgramError::Custom(0));
|
|
}
|
|
|
|
// Atomically update both accounts
|
|
pool.claimed += 1;
|
|
claim.has_claimed = true;
|
|
|
|
pool.serialize(&mut &mut global_pool.data.borrow_mut()[..])?;
|
|
claim.serialize(&mut &mut user_claim.data.borrow_mut()[..])?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Note:** Solana's account locking prevents true race conditions within a single transaction, but be aware of state assumptions across multiple transactions.
|
|
|
|
### Prevent State Corruption
|
|
|
|
**Always validate state transitions:**
|
|
|
|
```rust
|
|
#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
|
|
pub enum GameState {
|
|
NotStarted,
|
|
InProgress,
|
|
Finished,
|
|
}
|
|
|
|
pub fn start_game(
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let game_account = &accounts[0];
|
|
let mut game = Game::try_from_slice(&game_account.data.borrow())?;
|
|
|
|
// Validate current state
|
|
if game.state != GameState::NotStarted {
|
|
msg!("Game already started or finished");
|
|
return Err(ProgramError::InvalidAccountData);
|
|
}
|
|
|
|
// Transition state
|
|
game.state = GameState::InProgress;
|
|
game.start_time = Clock::get()?.unix_timestamp;
|
|
|
|
game.serialize(&mut &mut game_account.data.borrow_mut()[..])?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Arithmetic Safety
|
|
|
|
### Always Use Checked Math
|
|
|
|
**Rust default:** Integer overflow/underflow panics in debug, wraps in release.
|
|
|
|
**Solana requirement:** Use checked operations to prevent wrapping.
|
|
|
|
```rust
|
|
// ❌ DANGEROUS - can overflow/underflow
|
|
let total = a + b;
|
|
let remaining = balance - withdrawal;
|
|
|
|
// ✅ SAFE - returns error on overflow/underflow
|
|
let total = a.checked_add(b)
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
|
|
let remaining = balance.checked_sub(withdrawal)
|
|
.ok_or(ProgramError::InsufficientFunds)?;
|
|
```
|
|
|
|
### Common Checked Operations
|
|
|
|
```rust
|
|
// Addition
|
|
let sum = a.checked_add(b)
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
|
|
// Subtraction
|
|
let diff = a.checked_sub(b)
|
|
.ok_or(ProgramError::InsufficientFunds)?;
|
|
|
|
// Multiplication
|
|
let product = a.checked_mul(b)
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
|
|
// Division
|
|
let quotient = a.checked_div(b)
|
|
.ok_or(ProgramError::InvalidArgument)?; // b could be 0
|
|
|
|
// Power
|
|
let power = base.checked_pow(exponent)
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
```
|
|
|
|
### Compound Operations
|
|
|
|
```rust
|
|
// Calculate: (a + b) * c / d
|
|
let result = a.checked_add(b)
|
|
.and_then(|sum| sum.checked_mul(c))
|
|
.and_then(|product| product.checked_div(d))
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
```
|
|
|
|
### Precision Loss
|
|
|
|
**Be careful with division:**
|
|
|
|
```rust
|
|
// ❌ Loses precision
|
|
let fee = amount / 100; // 1.5% becomes 1%
|
|
|
|
// ✅ Better - multiply first, then divide
|
|
let fee = amount.checked_mul(15)
|
|
.and_then(|v| v.checked_div(1000))
|
|
.ok_or(ProgramError::ArithmeticOverflow)?;
|
|
```
|
|
|
|
---
|
|
|
|
## Re-entrancy Protection
|
|
|
|
### Solana's Built-in Protection
|
|
|
|
**Good news:** Solana provides strong protection against traditional re-entrancy:
|
|
|
|
- **Account locking:** Accounts are locked during transaction execution
|
|
- **No concurrent modification:** Same account can't be modified by multiple instructions simultaneously
|
|
- **Atomic transactions:** Either all instructions succeed or all fail
|
|
|
|
### Residual Risks
|
|
|
|
**Cross-program state assumptions:**
|
|
|
|
```rust
|
|
// ❌ RISKY - state can change between checks
|
|
pub fn risky_operation(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let vault = &accounts[0];
|
|
let mut vault_data = VaultData::try_from_slice(&vault.data.borrow())?;
|
|
|
|
// Check balance
|
|
let balance = **vault.lamports.borrow();
|
|
if balance < 1000 {
|
|
return Err(ProgramError::InsufficientFunds);
|
|
}
|
|
|
|
// CPI that might modify vault
|
|
invoke(&some_instruction, accounts)?;
|
|
|
|
// Balance might have changed!
|
|
// Don't rely on previous check
|
|
**vault.lamports.borrow_mut() -= 1000; // Could underflow!
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**✅ Better:**
|
|
|
|
```rust
|
|
pub fn safe_operation(
|
|
program_id: &Pubkey,
|
|
accounts: &[AccountInfo],
|
|
) -> ProgramResult {
|
|
let vault = &accounts[0];
|
|
|
|
// CPI first
|
|
invoke(&some_instruction, accounts)?;
|
|
|
|
// Check and modify atomically
|
|
let balance = **vault.lamports.borrow();
|
|
let new_balance = balance.checked_sub(1000)
|
|
.ok_or(ProgramError::InsufficientFunds)?;
|
|
|
|
**vault.lamports.borrow_mut() = new_balance;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Checklist
|
|
|
|
### Pre-Deployment Checklist
|
|
|
|
**Account Validation:**
|
|
- ✅ All signers verified with `is_signer`
|
|
- ✅ All account owners checked
|
|
- ✅ All PDAs validated with canonical bump
|
|
- ✅ All accounts checked for initialization
|
|
- ✅ Account types validated (discriminators)
|
|
- ✅ Writable accounts verified
|
|
|
|
**Input Validation:**
|
|
- ✅ All numeric inputs range-checked
|
|
- ✅ All string inputs length-limited
|
|
- ✅ All enum values validated
|
|
- ✅ All business logic constraints enforced
|
|
|
|
**Arithmetic:**
|
|
- ✅ All additions use `checked_add`
|
|
- ✅ All subtractions use `checked_sub`
|
|
- ✅ All multiplications use `checked_mul`
|
|
- ✅ All divisions check for zero
|
|
- ✅ No unsafe casting that could overflow
|
|
|
|
**State Management:**
|
|
- ✅ State transitions validated
|
|
- ✅ Initialization flags checked
|
|
- ✅ No assumptions across CPI boundaries
|
|
- ✅ Atomicity maintained
|
|
|
|
**Error Handling:**
|
|
- ✅ All errors properly propagated
|
|
- ✅ Meaningful error messages
|
|
- ✅ No silent failures
|
|
- ✅ Proper cleanup on errors
|
|
|
|
### Testing Checklist
|
|
|
|
**Security Testing:**
|
|
- ✅ Test with missing signers
|
|
- ✅ Test with wrong account owners
|
|
- ✅ Test with wrong PDAs (non-canonical bumps)
|
|
- ✅ Test with uninitialized accounts
|
|
- ✅ Test with re-initialized accounts
|
|
- ✅ Test integer overflow/underflow
|
|
- ✅ Test boundary conditions
|
|
- ✅ Test with maximum values
|
|
- ✅ Test with malicious input
|
|
|
|
**Fuzzing:**
|
|
- ✅ Random account combinations
|
|
- ✅ Random instruction data
|
|
- ✅ Random ordering
|
|
- ✅ Edge case values
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**Core Security Principles:**
|
|
|
|
1. **Validate Everything** - Assume all inputs are malicious
|
|
2. **Fail Fast** - Return errors immediately when validation fails
|
|
3. **Use Checked Math** - Prevent integer overflow/underflow
|
|
4. **Think Like an Attacker** - Ask "How do I break this?"
|
|
5. **Test Malicious Cases** - Don't just test happy paths
|
|
|
|
**The Three Pillars of Account Security:**
|
|
|
|
```rust
|
|
// 1. Signer Check
|
|
if !account.is_signer {
|
|
return Err(ProgramError::MissingRequiredSignature);
|
|
}
|
|
|
|
// 2. Ownership Check
|
|
if account.owner != expected_owner {
|
|
return Err(ProgramError::IllegalOwner);
|
|
}
|
|
|
|
// 3. PDA Validation (if applicable)
|
|
let (expected_pda, _) = Pubkey::find_program_address(&seeds, program_id);
|
|
if expected_pda != *account.key {
|
|
return Err(ProgramError::InvalidSeeds);
|
|
}
|
|
```
|
|
|
|
**Remember:** Once deployed, you have no control over what transactions are sent to your program. Your only defense is rigorous validation.
|
|
|
|
Security is not a feature—it's a requirement.
|